Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f98c2834f | ||
|
|
baff9449bd |
@@ -189,14 +189,19 @@ what we take vs. skip). Never vendor it into the build.
|
|||||||
- No third-party deps beyond `github.com/coder/websocket`,
|
- No third-party deps beyond `github.com/coder/websocket`,
|
||||||
`golang.org/x/crypto` (ACME), and `fyne.io/fyne/v2` (desktop UI). Keep it
|
`golang.org/x/crypto` (ACME), and `fyne.io/fyne/v2` (desktop UI). Keep it
|
||||||
that way unless a milestone calls for it.
|
that way unless a milestone calls for it.
|
||||||
- **CLI release**: `v0.1.0` (tag on `ebae420`) is published on Gitea with
|
- **Releases**: `v0.2.0` (tag on `d5689ce`) is published on Gitea with
|
||||||
darwin/linux arm64+amd64 tarballs and `checksums.txt`. The Homebrew formula
|
darwin/linux CLI tarballs and a **notarized** `Tunnel-0.2.0.zip`
|
||||||
is `tzgyn-tunnel` in `TZGyn/homebrew-tap` (it installs the `tunnel` binary;
|
(`spctl` = accepted / `Notarized Developer ID`). Homebrew: formula
|
||||||
the name avoids the deprecated core `tunnel` formula). Rebuild with
|
`tzgyn-tunnel` (CLI, installs `tunnel`) and cask `tzgyn-tunnel-app` (desktop,
|
||||||
`-X main.version=<version>`, attach all four assets, and update the four
|
installs `Tunnel.app`), both in `TZGyn/homebrew-tap`. Rebuild the app with
|
||||||
URLs/SHAs in the formula on each release. `tunnel up` currently starts a
|
`scripts/build-desktop-app.sh <version>` for a local ad-hoc build, or
|
||||||
detached process, not a launchd agent (the official CLI's agent was stopped
|
`scripts/release-desktop-app.sh <version>` for the signed+notarized zip;
|
||||||
when the live tunnel was switched to this client).
|
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. `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,
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -107,15 +108,25 @@ On macOS, build and ad-hoc sign a launchable bundle:
|
|||||||
```bash
|
```bash
|
||||||
./scripts/build-desktop-app.sh 0.2.0
|
./scripts/build-desktop-app.sh 0.2.0
|
||||||
ditto Tunnel.app "$HOME/Applications/Tunnel.app"
|
ditto Tunnel.app "$HOME/Applications/Tunnel.app"
|
||||||
open "$HOME/Applications/Tunnel.app" --args --autostart
|
open "$HOME/Applications/Tunnel.app"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install the notarized release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user