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
**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,
+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 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 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
+52
View File
@@ -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 <port|host:port> [--name NAME] [--proxy-protocol v1|v2]
route remove <name>
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 {
+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
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()
}