3 Commits
8 changed files with 439 additions and 22 deletions
+18 -8
View File
@@ -189,14 +189,24 @@ 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.1` (tag on `5f98c28`) 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.1.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.
- **macOS is case-insensitive**: `.local/tunnel` (CLI) and `.local/Tunnel`
(desktop) are the same file. The desktop scripts build to
`.local/desktop/Tunnel`; use `.local/tunnel-cli` when testing both
side-by-side. Symptom of a collision: `tunnel check` opens a GUI window and
`check` hangs/does nothing.
- 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,
+15 -4
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 |
@@ -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
+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()
} }
+3 -3
View File
@@ -16,11 +16,11 @@ if [ ! -x "$FYNE" ]; then
FYNE="$(go env GOPATH)/bin/fyne" FYNE="$(go env GOPATH)/bin/fyne"
fi fi
mkdir -p .local mkdir -p .local/desktop
go build -o .local/Tunnel -ldflags "-X main.version=$VERSION" ./cmd/tunnel-desktop go build -o .local/desktop/Tunnel -ldflags "-X main.version=$VERSION" ./cmd/tunnel-desktop
rm -rf Tunnel.app rm -rf Tunnel.app
"$FYNE" package -os darwin \ "$FYNE" package -os darwin \
-executable .local/Tunnel \ -executable .local/desktop/Tunnel \
-name Tunnel -app-id dev.tzgyn.tunnel \ -name Tunnel -app-id dev.tzgyn.tunnel \
-app-version "$VERSION" -app-build "$BUILD" \ -app-version "$VERSION" -app-build "$BUILD" \
-icon assets/icon.png -release -icon assets/icon.png -release
+3 -3
View File
@@ -21,11 +21,11 @@ FYNE="$(command -v fyne || true)"
IDENT="$(security find-identity -v -p codesigning | awk '/Developer ID Application:/{print $2; exit}')" IDENT="$(security find-identity -v -p codesigning | awk '/Developer ID Application:/{print $2; exit}')"
[ -n "$IDENT" ] || { echo "no Developer ID Application identity"; exit 1; } [ -n "$IDENT" ] || { echo "no Developer ID Application identity"; exit 1; }
mkdir -p "$OUT" mkdir -p "$OUT" .local/desktop
go build -o .local/Tunnel -ldflags "-X main.version=$VERSION" ./cmd/tunnel-desktop go build -o .local/desktop/Tunnel -ldflags "-X main.version=$VERSION" ./cmd/tunnel-desktop
rm -rf "$APP" rm -rf "$APP"
"$FYNE" package -os darwin \ "$FYNE" package -os darwin \
-executable .local/Tunnel \ -executable .local/desktop/Tunnel \
-name Tunnel -app-id dev.tzgyn.tunnel \ -name Tunnel -app-id dev.tzgyn.tunnel \
-app-version "$VERSION" -app-build "$BUILD" \ -app-version "$VERSION" -app-build "$BUILD" \
-icon assets/icon.png -release -icon assets/icon.png -release