Add local-port and public-tunnel reachability checks

This commit is contained in:
2026-10-30 22:42:40 +08:00
parent baff9449bd
commit 5f98c2834f
6 changed files with 411 additions and 8 deletions
+3 -1
View File
@@ -199,7 +199,9 @@ what we take vs. skip). Never vendor it into the build.
update both tap files' URLs/SHAs on each release. The app has update both tap files' URLs/SHAs on each release. The app has
**Connect on launch** (`auto_connect`); add it to macOS Login Items to **Connect on launch** (`auto_connect`); add it to macOS Login Items to
reconnect at login. The CLI `tunnel up` remains a detached process; the reconnect at login. The CLI `tunnel up` remains a detached process; the
desktop app runs the service in-process. desktop app runs the service in-process. `tunnel check [route]` and the
desktop **Check now** button check the local TCP target plus a public TLS
handshake (DNS, proxy, bridge, certificate); public checks use a 5s deadline.
- Milestones: M1 core data path (done); M2 real ACME DNS-01 + renewals (done); - Milestones: M1 core data path (done); M2 real ACME DNS-01 + renewals (done);
M3 rate limits/admin/hardening (done); M4 Go client (done); M5 desktop app M3 rate limits/admin/hardening (done); M4 Go client (done); M5 desktop app
(done). Remaining optional work: launchd/systemd agents for the Go client, (done). Remaining optional work: launchd/systemd agents for the Go client,
+8 -3
View File
@@ -69,6 +69,7 @@ Routes reload without restarting the service.
| `tunnel config show` | show API, tunnel, certificate and routes (never the token) | | `tunnel config show` | show API, tunnel, certificate and routes (never the token) |
| `tunnel route add <port\|host:port> [--name NAME] [--proxy-protocol v1\|v2]` | add/update a route; random name if omitted | | `tunnel route add <port\|host:port> [--name NAME] [--proxy-protocol v1\|v2]` | add/update a route; random name if omitted |
| `tunnel route remove <name>` / `route list` | manage routes | | `tunnel route remove <name>` / `route list` | manage routes |
| `tunnel check [name]` | check local TCP targets and the public TLS path |
| `tunnel up` / `down` | start/stop the background service | | `tunnel up` / `down` | start/stop the background service |
| `tunnel serve` | run in the foreground | | `tunnel serve` | run in the foreground |
| `tunnel status` | local state, daemon, server state and certificate state | | `tunnel status` | local state, daemon, server state and certificate state |
@@ -119,9 +120,13 @@ brew install --cask tzgyn-tunnel-app
It uses `~/.config/tunnel/config.json` like the CLI, shows tunnel status and It uses `~/.config/tunnel/config.json` like the CLI, shows tunnel status and
certificate expiry, routes with copy/open/remove buttons, live logs, settings certificate expiry, routes with copy/open/remove buttons, live logs, settings
and profile import, and has a start/stop button that runs the service and profile import, and has a start/stop button that runs the service
in-process. The Settings dialog has **Connect on launch**; add the app to in-process. **Check now** (and an automatic check after connecting) shows
macOS Login Items (or the Linux/Windows equivalent) to reconnect at login. If per-route `local`/`public` health and a summary; “local” is a TCP connect to
the CLI service is already running, a banner offers to stop it first. the route target, “public” is a TLS handshake through the real public hostname
(DNS + proxy + bridge + certificate). The Settings dialog has **Connect on
launch**; add the app to macOS Login Items (or the Linux/Windows equivalent) to
reconnect at login. If the CLI service is already running, a banner offers to
stop it first.
Fyne needs CGO: the macOS build is tested; Linux and Windows must be built on Fyne needs CGO: the macOS build is tested; Linux and Windows must be built on
those platforms (or with a native CGO cross toolchain), not with those platforms (or with a native CGO cross toolchain), not with
+52
View File
@@ -50,6 +50,8 @@ func run(args []string) int {
return runConfig(configPath, rest) return runConfig(configPath, rest)
case "route": case "route":
return runRoute(configPath, rest) return runRoute(configPath, rest)
case "check":
return runCheck(configPath, rest)
case "import": case "import":
return runImport(configPath, rest) return runImport(configPath, rest)
case "up": case "up":
@@ -87,6 +89,7 @@ Commands:
route add <port|host:port> [--name NAME] [--proxy-protocol v1|v2] route add <port|host:port> [--name NAME] [--proxy-protocol v1|v2]
route remove <name> route remove <name>
route list route list
check [name] Check local targets and the public TLS path
import --profile NAME --api URL import --profile NAME --api URL
Take over an existing official opentunnel profile Take over an existing official opentunnel profile
up Provision if needed and run in the background up Provision if needed and run in the background
@@ -335,6 +338,55 @@ func runImport(configPath string, args []string) int {
return 0 return 0
} }
func runCheck(configPath string, args []string) int {
if len(args) > 1 {
fmt.Fprintln(os.Stderr, "usage: tunnel check [route]")
return 2
}
cfg, err := client.Load(configPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
if len(cfg.Routes) == 0 {
fmt.Println("No routes.")
return 0
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
var results []client.RouteCheck
if len(args) == 1 {
results = []client.RouteCheck{client.CheckRoute(ctx, cfg, args[0])}
} else {
results = client.CheckAll(ctx, cfg)
}
failed := false
for _, result := range results {
local, public := "down", "down"
if result.LocalUp {
local = "up"
}
if result.PublicUp {
public = "up"
}
fmt.Printf("%-20s local=%-4s public=%-4s\n", result.Route, local, public)
if result.LocalError != "" {
fmt.Printf(" local: %s\n", result.LocalError)
}
if result.PublicError != "" {
fmt.Printf(" public: %s\n", result.PublicError)
}
if !result.LocalUp || !result.PublicUp {
failed = true
}
}
if failed {
return 1
}
return 0
}
func runUp(configPath string) int { func runUp(configPath string) int {
cfg, err := client.Load(configPath) cfg, err := client.Load(configPath)
if err != nil { if err != nil {
+115
View File
@@ -0,0 +1,115 @@
package client
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/url"
"sort"
"sync"
"time"
)
// RouteCheck is the result of checking one route's local target and its
// public TLS path.
type RouteCheck struct {
Route string
LocalUp bool
PublicUp bool
LocalError string
PublicError string
Checked time.Time
}
// CheckLocal dials the route target's TCP port.
func CheckLocal(ctx context.Context, target string) (bool, string) {
dialer := net.Dialer{Timeout: 3 * time.Second}
conn, err := dialer.DialContext(ctx, "tcp", target)
if err != nil {
return false, err.Error()
}
conn.Close()
return true, ""
}
// CheckPublic performs a TLS handshake against the public route hostname. It
// goes through DNS, the public proxy/relay, the bridge and the client's TLS
// certificate, so success means the external path is up. It intentionally
// does not send application data, so it works for non-HTTP routes too.
func CheckPublic(ctx context.Context, publicURL string) (bool, string) {
u, err := url.Parse(publicURL)
if err != nil || u.Hostname() == "" {
return false, "no public hostname"
}
host := u.Hostname()
port := u.Port()
if port == "" {
port = "443"
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
raw, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(host, port))
if err != nil {
return false, err.Error()
}
defer raw.Close()
// A TCP connect can succeed while the TLS handshake stalls (for example
// when the tunnel has no bridge); bound it independently of the dialer.
_ = raw.SetDeadline(time.Now().Add(5 * time.Second))
tlsConn := tls.Client(raw, &tls.Config{
ServerName: host,
MinVersion: tls.VersionTLS12,
})
if err := tlsConn.HandshakeContext(ctx); err != nil {
return false, err.Error()
}
return true, ""
}
// CheckRoute checks one route.
func CheckRoute(ctx context.Context, cfg *Config, name string) RouteCheck {
result := RouteCheck{Route: name, Checked: time.Now()}
route, ok := cfg.Routes[name]
if !ok {
result.LocalError = "route not found"
result.PublicError = "route not found"
return result
}
result.LocalUp, result.LocalError = CheckLocal(ctx, route.Target)
result.PublicUp, result.PublicError = CheckPublic(ctx, cfg.RouteURL(name))
return result
}
// CheckAll checks every route concurrently.
func CheckAll(ctx context.Context, cfg *Config) []RouteCheck {
names := make([]string, 0, len(cfg.Routes))
for name := range cfg.Routes {
names = append(names, name)
}
sort.Strings(names)
results := make([]RouteCheck, len(names))
var wg sync.WaitGroup
for i, name := range names {
wg.Add(1)
go func(i int, name string) {
defer wg.Done()
results[i] = CheckRoute(ctx, cfg, name)
}(i, name)
}
wg.Wait()
return results
}
// CheckSummary formats the counts for a UI.
func CheckSummary(results []RouteCheck) string {
local, public := 0, 0
for _, result := range results {
if result.LocalUp {
local++
}
if result.PublicUp {
public++
}
}
return fmt.Sprintf("local %d/%d up · public %d/%d up", local, len(results), public, len(results))
}
+50
View File
@@ -0,0 +1,50 @@
package client
import (
"context"
"net"
"testing"
)
func TestCheckLocal(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
conn.Close()
}
}()
ctx := context.Background()
if up, detail := CheckLocal(ctx, listener.Addr().String()); !up {
t.Errorf("listener should be up: %s", detail)
}
if up, _ := CheckLocal(ctx, "127.0.0.1:1"); up {
t.Error("closed port should be down")
}
}
func TestCheckSummary(t *testing.T) {
results := []RouteCheck{
{LocalUp: true, PublicUp: true},
{LocalUp: false, PublicUp: true},
}
if got := CheckSummary(results); got != "local 1/2 up · public 2/2 up" {
t.Errorf("summary = %q", got)
}
}
func TestCheckRouteMissing(t *testing.T) {
cfg := &Config{Routes: map[string]Route{}, Hostname: "abc.tunnel.example"}
result := CheckRoute(context.Background(), cfg, "missing")
if result.LocalUp || result.PublicUp || result.LocalError == "" {
t.Errorf("missing route result: %+v", result)
}
}
+183 -4
View File
@@ -64,8 +64,20 @@ type App struct {
configLabel *widget.Label configLabel *widget.Label
startButton *widget.Button startButton *widget.Button
routeBox *fyne.Container routeBox *fyne.Container
routeRows map[string]*routeRow
checkSummary *widget.Label
logText *widget.TextGrid logText *widget.TextGrid
banner fyne.CanvasObject banner fyne.CanvasObject
checkMu sync.Mutex
checks map[string]client.RouteCheck
checking bool
lastCheck time.Time
}
type routeRow struct {
local *widget.Label
public *widget.Label
} }
// Run creates and runs the desktop app. // Run creates and runs the desktop app.
@@ -167,9 +179,16 @@ func (a *App) buildUI() {
routeHeader := container.NewHBox( routeHeader := container.NewHBox(
widget.NewLabelWithStyle("Routes", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("Routes", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
layout.NewSpacer(), layout.NewSpacer(),
widget.NewButtonWithIcon("Check now", theme.ViewRefreshIcon(), a.checkRoutes),
widget.NewButtonWithIcon("Add route", theme.ContentAddIcon(), a.addRouteDialog), widget.NewButtonWithIcon("Add route", theme.ContentAddIcon(), a.addRouteDialog),
) )
a.routeBox = container.NewVBox() a.routeBox = container.NewVBox()
a.routeRows = map[string]*routeRow{}
a.checks = map[string]client.RouteCheck{}
a.checkSummary = widget.NewLabel("Not checked yet")
a.checkSummary.Importance = widget.LowImportance
checkDetails := widget.NewButton("Details", a.showCheckDetails)
checkBar := container.NewHBox(a.checkSummary, checkDetails)
a.logText = widget.NewTextGrid() a.logText = widget.NewTextGrid()
logScroll := container.NewScroll(a.logText) logScroll := container.NewScroll(a.logText)
logScroll.SetMinSize(fyne.NewSize(0, 220)) logScroll.SetMinSize(fyne.NewSize(0, 220))
@@ -190,6 +209,7 @@ func (a *App) buildUI() {
a.banner, a.banner,
routeHeader, routeHeader,
a.routeBox, a.routeBox,
checkBar,
widget.NewLabelWithStyle("Log", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("Log", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
logScroll, logScroll,
) )
@@ -230,10 +250,20 @@ func (a *App) refresh() {
} }
a.refreshRoutes() a.refreshRoutes()
a.updateStatus() a.updateStatus()
if a.svc != nil && a.svc.CurrentStatus().Connected {
a.maybeCheck()
}
} }
func (a *App) refreshRoutes() { func (a *App) refreshRoutes() {
a.routeBox.Objects = nil a.routeBox.Objects = nil
a.routeRows = map[string]*routeRow{}
a.checkMu.Lock()
checks := make(map[string]client.RouteCheck, len(a.checks))
for name, check := range a.checks {
checks[name] = check
}
a.checkMu.Unlock()
for _, name := range a.routeNames() { for _, name := range a.routeNames() {
route := a.cfg.Routes[name] route := a.cfg.Routes[name]
linkURL := a.cfg.RouteURL(name) linkURL := a.cfg.RouteURL(name)
@@ -241,25 +271,169 @@ func (a *App) refreshRoutes() {
if parsed, err := url.Parse(linkURL); err == nil { if parsed, err := url.Parse(linkURL); err == nil {
link.SetURL(parsed) link.SetURL(parsed)
} }
local := widget.NewLabel("local —")
public := widget.NewLabel("public —")
local.Importance = widget.LowImportance
public.Importance = widget.LowImportance
row := &routeRow{local: local, public: public}
if check, ok := checks[name]; ok {
setCheckLabel(local, "local", check.LocalUp, check.LocalError)
setCheckLabel(public, "public", check.PublicUp, check.PublicError)
}
a.routeRows[name] = row
copyButton := widget.NewButtonWithIcon("", theme.ContentCopyIcon(), func() { copyButton := widget.NewButtonWithIcon("", theme.ContentCopyIcon(), func() {
a.app.Clipboard().SetContent(linkURL) a.app.Clipboard().SetContent(linkURL)
}) })
deleteButton := widget.NewButtonWithIcon("", theme.DeleteIcon(), func() { deleteButton := widget.NewButtonWithIcon("", theme.DeleteIcon(), func() {
a.removeRoute(name) a.removeRoute(name)
}) })
row := container.NewHBox( line := container.NewHBox(
container.NewGridWrap(fyne.NewSize(130, 32), widget.NewLabel(name)), container.NewGridWrap(fyne.NewSize(120, 32), widget.NewLabel(name)),
container.NewGridWrap(fyne.NewSize(170, 32), widget.NewLabel(route.Target)), container.NewGridWrap(fyne.NewSize(150, 32), widget.NewLabel(route.Target)),
link, link,
layout.NewSpacer(), layout.NewSpacer(),
local,
public,
copyButton, copyButton,
deleteButton, deleteButton,
) )
a.routeBox.Add(row) a.routeBox.Add(line)
} }
a.routeBox.Refresh() a.routeBox.Refresh()
} }
func setCheckLabel(label *widget.Label, kind string, up bool, detail string) {
if up {
label.SetText(kind + " ✓")
label.Importance = widget.SuccessImportance
} else {
label.SetText(kind + " ✗")
label.Importance = widget.DangerImportance
}
label.Refresh()
_ = detail
}
func (a *App) checkRoutes() {
if a.cfg == nil {
return
}
routes := make(map[string]client.Route, len(a.cfg.Routes))
for name, route := range a.cfg.Routes {
routes[name] = route
}
if len(routes) == 0 {
a.checkSummary.SetText("No routes")
a.checkSummary.Importance = widget.LowImportance
a.checkSummary.Refresh()
return
}
snapshot := &client.Config{API: a.cfg.API, Hostname: a.cfg.Hostname, Routes: routes}
a.checkMu.Lock()
if a.checking {
a.checkMu.Unlock()
return
}
a.checking = true
a.checkMu.Unlock()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
results := client.CheckAll(ctx, snapshot)
checks := make(map[string]client.RouteCheck, len(results))
for _, result := range results {
checks[result.Route] = result
}
a.checkMu.Lock()
a.checks = checks
a.lastCheck = time.Now()
a.checking = false
a.checkMu.Unlock()
fyne.Do(a.applyChecks)
}()
}
func (a *App) maybeCheck() {
a.checkMu.Lock()
due := !a.checking && time.Since(a.lastCheck) > 30*time.Second
a.checkMu.Unlock()
if due {
a.checkRoutes()
}
}
func (a *App) applyChecks() {
a.checkMu.Lock()
checks := make(map[string]client.RouteCheck, len(a.checks))
for name, check := range a.checks {
checks[name] = check
}
a.checkMu.Unlock()
results := make([]client.RouteCheck, 0, len(a.routeRows))
for name, row := range a.routeRows {
check, ok := checks[name]
if !ok {
continue
}
setCheckLabel(row.local, "local", check.LocalUp, check.LocalError)
setCheckLabel(row.public, "public", check.PublicUp, check.PublicError)
results = append(results, check)
}
if len(results) == 0 {
a.checkSummary.SetText("Not checked yet")
a.checkSummary.Importance = widget.LowImportance
} else {
a.checkSummary.SetText(client.CheckSummary(results))
a.checkSummary.Importance = widget.SuccessImportance
for _, result := range results {
if !result.LocalUp || !result.PublicUp {
a.checkSummary.Importance = widget.DangerImportance
break
}
}
}
a.checkSummary.Refresh()
}
func (a *App) showCheckDetails() {
a.checkMu.Lock()
checks := make(map[string]client.RouteCheck, len(a.checks))
for name, check := range a.checks {
checks[name] = check
}
a.checkMu.Unlock()
lines := []string{}
for _, name := range a.routeNames() {
check, ok := checks[name]
if !ok {
continue
}
if check.LocalError != "" {
lines = append(lines, fmt.Sprintf("%s local: %s", name, check.LocalError))
}
if check.PublicError != "" {
lines = append(lines, fmt.Sprintf("%s public: %s", name, check.PublicError))
}
}
if len(lines) == 0 {
lines = []string{"No errors recorded."}
}
a.showInfo(strings.Join(lines, "\n"))
}
func (a *App) resetChecks() {
a.checkMu.Lock()
a.checks = map[string]client.RouteCheck{}
a.checking = false
a.lastCheck = time.Time{}
a.checkMu.Unlock()
a.checkSummary.SetText("Not checked yet")
a.checkSummary.Importance = widget.LowImportance
a.checkSummary.Refresh()
}
func (a *App) refreshLog() { func (a *App) refreshLog() {
a.logText.SetText(a.logWriter.text(300)) a.logText.SetText(a.logWriter.text(300))
} }
@@ -307,6 +481,9 @@ func (a *App) updateStatus() {
a.dot.Refresh() a.dot.Refresh()
a.startButton.Enable() a.startButton.Enable()
a.refreshRoutes() a.refreshRoutes()
if status.Connected {
a.maybeCheck()
}
} }
func (a *App) isRunning() bool { func (a *App) isRunning() bool {
@@ -471,6 +648,7 @@ func (a *App) addRouteDialog() {
a.showError(err) a.showError(err)
return return
} }
a.resetChecks()
a.refresh() a.refresh()
if !a.isRunning() && a.cfg.API != "" { if !a.isRunning() && a.cfg.API != "" {
a.start() a.start()
@@ -487,6 +665,7 @@ func (a *App) removeRoute(name string) {
a.showError(err) a.showError(err)
return return
} }
a.resetChecks()
a.refresh() a.refresh()
} }