diff --git a/AGENTS.md b/AGENTS.md index eecb61e..ae9d5d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 **Connect on launch** (`auto_connect`); add it to macOS Login Items to 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); M3 rate limits/admin/hardening (done); M4 Go client (done); M5 desktop app (done). Remaining optional work: launchd/systemd agents for the Go client, diff --git a/README.md b/README.md index cdaa9b2..657e61c 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Routes reload without restarting the service. | `tunnel config show` | show API, tunnel, certificate and routes (never the token) | | `tunnel route add [--name NAME] [--proxy-protocol v1\|v2]` | add/update a route; random name if omitted | | `tunnel route remove ` / `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 serve` | run in the foreground | | `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 certificate expiry, routes with copy/open/remove buttons, live logs, settings 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 -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. +in-process. **Check now** (and an automatic check after connecting) shows +per-route `local`/`public` health and a summary; “local” is a TCP connect to +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 those platforms (or with a native CGO cross toolchain), not with diff --git a/cmd/tunnel/main.go b/cmd/tunnel/main.go index d24198a..238c39c 100644 --- a/cmd/tunnel/main.go +++ b/cmd/tunnel/main.go @@ -50,6 +50,8 @@ func run(args []string) int { return runConfig(configPath, rest) case "route": return runRoute(configPath, rest) + case "check": + return runCheck(configPath, rest) case "import": return runImport(configPath, rest) case "up": @@ -87,6 +89,7 @@ Commands: route add [--name NAME] [--proxy-protocol v1|v2] route remove route list + check [name] Check local targets and the public TLS path import --profile NAME --api URL Take over an existing official opentunnel profile up Provision if needed and run in the background @@ -335,6 +338,55 @@ func runImport(configPath string, args []string) int { 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 { cfg, err := client.Load(configPath) if err != nil { diff --git a/internal/client/check.go b/internal/client/check.go new file mode 100644 index 0000000..2281b01 --- /dev/null +++ b/internal/client/check.go @@ -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)) +} diff --git a/internal/client/check_test.go b/internal/client/check_test.go new file mode 100644 index 0000000..2ba2e01 --- /dev/null +++ b/internal/client/check_test.go @@ -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) + } +} diff --git a/internal/desktop/app.go b/internal/desktop/app.go index 036994c..1c48e53 100644 --- a/internal/desktop/app.go +++ b/internal/desktop/app.go @@ -64,8 +64,20 @@ type App struct { configLabel *widget.Label startButton *widget.Button routeBox *fyne.Container + routeRows map[string]*routeRow + checkSummary *widget.Label logText *widget.TextGrid 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. @@ -167,9 +179,16 @@ func (a *App) buildUI() { routeHeader := container.NewHBox( widget.NewLabelWithStyle("Routes", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), layout.NewSpacer(), + widget.NewButtonWithIcon("Check now", theme.ViewRefreshIcon(), a.checkRoutes), widget.NewButtonWithIcon("Add route", theme.ContentAddIcon(), a.addRouteDialog), ) 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() logScroll := container.NewScroll(a.logText) logScroll.SetMinSize(fyne.NewSize(0, 220)) @@ -190,6 +209,7 @@ func (a *App) buildUI() { a.banner, routeHeader, a.routeBox, + checkBar, widget.NewLabelWithStyle("Log", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), logScroll, ) @@ -230,10 +250,20 @@ func (a *App) refresh() { } a.refreshRoutes() a.updateStatus() + if a.svc != nil && a.svc.CurrentStatus().Connected { + a.maybeCheck() + } } func (a *App) refreshRoutes() { 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() { route := a.cfg.Routes[name] linkURL := a.cfg.RouteURL(name) @@ -241,25 +271,169 @@ func (a *App) refreshRoutes() { if parsed, err := url.Parse(linkURL); err == nil { 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() { a.app.Clipboard().SetContent(linkURL) }) deleteButton := widget.NewButtonWithIcon("", theme.DeleteIcon(), func() { a.removeRoute(name) }) - row := container.NewHBox( - container.NewGridWrap(fyne.NewSize(130, 32), widget.NewLabel(name)), - container.NewGridWrap(fyne.NewSize(170, 32), widget.NewLabel(route.Target)), + line := container.NewHBox( + container.NewGridWrap(fyne.NewSize(120, 32), widget.NewLabel(name)), + container.NewGridWrap(fyne.NewSize(150, 32), widget.NewLabel(route.Target)), link, layout.NewSpacer(), + local, + public, copyButton, deleteButton, ) - a.routeBox.Add(row) + a.routeBox.Add(line) } 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() { a.logText.SetText(a.logWriter.text(300)) } @@ -307,6 +481,9 @@ func (a *App) updateStatus() { a.dot.Refresh() a.startButton.Enable() a.refreshRoutes() + if status.Connected { + a.maybeCheck() + } } func (a *App) isRunning() bool { @@ -471,6 +648,7 @@ func (a *App) addRouteDialog() { a.showError(err) return } + a.resetChecks() a.refresh() if !a.isRunning() && a.cfg.API != "" { a.start() @@ -487,6 +665,7 @@ func (a *App) removeRoute(name string) { a.showError(err) return } + a.resetChecks() a.refresh() }