Add local-port and public-tunnel reachability checks
This commit is contained in:
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user