Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
146a709358 | ||
|
|
5f98c2834f | ||
|
|
baff9449bd | ||
|
|
d5689ce70d | ||
|
|
87f82e2c47 | ||
|
|
ddcea99f37 | ||
|
|
e4ed31f161 |
@@ -1,5 +1,10 @@
|
|||||||
/tunneld
|
/tunneld
|
||||||
|
/tunnel
|
||||||
|
/tunnel-desktop
|
||||||
|
/Tunnel.app
|
||||||
cmd/tunneld/tunneld
|
cmd/tunneld/tunneld
|
||||||
|
cmd/tunnel/tunnel
|
||||||
|
cmd/tunnel-desktop/tunnel-desktop
|
||||||
.local/
|
.local/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ what we take vs. skip). Never vendor it into the build.
|
|||||||
- `cmd/tunneld` — flags/env wiring, listeners, signal handling
|
- `cmd/tunneld` — flags/env wiring, listeners, signal handling
|
||||||
- `cmd/tunnel` — the native Go client/CLI: JSON config, provisioning, routes,
|
- `cmd/tunnel` — the native Go client/CLI: JSON config, provisioning, routes,
|
||||||
background service
|
background service
|
||||||
|
- `cmd/tunnel-desktop` — Fyne desktop UI over the same client service (macOS,
|
||||||
|
Linux, Windows)
|
||||||
- `internal/client` — client implementation: config/API, key/CSR/certificate
|
- `internal/client` — client implementation: config/API, key/CSR/certificate
|
||||||
storage, bridge WebSocket, TLS termination and local proxying
|
storage, bridge WebSocket, TLS termination and local proxying
|
||||||
|
- `internal/desktop` — Fyne UI: status, routes, start/stop, logs, settings,
|
||||||
|
import
|
||||||
- `internal/server/limits.go`, `cooldown.go` — per-IP/per-tunnel rate limits
|
- `internal/server/limits.go`, `cooldown.go` — per-IP/per-tunnel rate limits
|
||||||
and the ACME CA cool-down
|
and the ACME CA cool-down
|
||||||
- `internal/server/proto.go` — wire types (pinned JSON key order), IDs/tokens
|
- `internal/server/proto.go` — wire types (pinned JSON key order), IDs/tokens
|
||||||
@@ -68,6 +72,16 @@ what we take vs. skip). Never vendor it into the build.
|
|||||||
`up`/`down` manage the background service, `status` shows local + server
|
`up`/`down` manage the background service, `status` shows local + server
|
||||||
state. `import --profile <name> --api <url>` takes over an official CLI
|
state. `import --profile <name> --api <url>` takes over an official CLI
|
||||||
profile (same tunnel/key/cert).
|
profile (same tunnel/key/cert).
|
||||||
|
- Desktop app: `go build -o .local/tunnel-desktop ./cmd/tunnel-desktop` (Fyne;
|
||||||
|
needs CGO). It runs the same `internal/client` service in-process and uses
|
||||||
|
`~/.config/tunnel/config.json`. If the CLI service is already running, stop
|
||||||
|
it first — the app shows a banner and offers to stop it. `--autostart`
|
||||||
|
connects on launch; Settings has **Connect on launch** for the same behavior
|
||||||
|
when opened normally. Add the app to macOS Login Items for start-at-login.
|
||||||
|
On macOS, `scripts/build-desktop-app.sh <version>` builds
|
||||||
|
and ad-hoc signs `Tunnel.app`; the installed copy is
|
||||||
|
`~/Applications/Tunnel.app`. Cross-OS builds need that OS's CGO toolchain;
|
||||||
|
`CGO_ENABLED=0` does not work for Fyne.
|
||||||
- Dev loop: `go build -o .local/tunneld ./cmd/tunneld && .local/tunneld -domain localhost`,
|
- Dev loop: `go build -o .local/tunneld ./cmd/tunneld && .local/tunneld -domain localhost`,
|
||||||
then `OPENTUNNEL_API=http://localhost:8080 opentunnel route add 4096 --name api`
|
then `OPENTUNNEL_API=http://localhost:8080 opentunnel route add 4096 --name api`
|
||||||
with the official CLI. `go build` drops a binary next to the source — it is
|
with the official CLI. `go build` drops a binary next to the source — it is
|
||||||
@@ -172,10 +186,30 @@ what we take vs. skip). Never vendor it into the build.
|
|||||||
- The bridge WebSocket must use `coder/websocket` with compression disabled
|
- The bridge WebSocket must use `coder/websocket` with compression disabled
|
||||||
and the `opentunnel` subprotocol; read limits are 64 KiB before attach and
|
and the `opentunnel` subprotocol; read limits are 64 KiB before attach and
|
||||||
4+32 KiB after.
|
4+32 KiB after.
|
||||||
- No third-party deps beyond `github.com/coder/websocket` and
|
- No third-party deps beyond `github.com/coder/websocket`,
|
||||||
`golang.org/x/crypto` (ACME); keep it that way unless a milestone calls for it.
|
`golang.org/x/crypto` (ACME), and `fyne.io/fyne/v2` (desktop UI). Keep it
|
||||||
|
that way unless a milestone calls for it.
|
||||||
|
- **Releases**: `v0.2.1` (tag on `5f98c28`) is published on Gitea with
|
||||||
|
darwin/linux CLI tarballs and a **notarized** `Tunnel-0.2.1.zip`
|
||||||
|
(`spctl` = accepted / `Notarized Developer ID`). Homebrew: formula
|
||||||
|
`tzgyn-tunnel` (CLI, installs `tunnel`) and cask `tzgyn-tunnel-app` (desktop,
|
||||||
|
installs `Tunnel.app`), both in `TZGyn/homebrew-tap`. Rebuild the app with
|
||||||
|
`scripts/build-desktop-app.sh <version>` for a local ad-hoc build, or
|
||||||
|
`scripts/release-desktop-app.sh <version>` for the signed+notarized zip;
|
||||||
|
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). Remaining optional
|
M3 rate limits/admin/hardening (done); M4 Go client (done); M5 desktop app
|
||||||
work: export/import admin endpoints, token rotation, multi-region mesh,
|
(done). Remaining optional work: launchd/systemd agents for the Go client,
|
||||||
inbound PROXY protocol, per-connection wire flow control (the protocol has
|
release packaging for the desktop app, export/import admin endpoints, token
|
||||||
none; queues are bounded).
|
rotation, multi-region mesh, inbound PROXY protocol, per-connection wire
|
||||||
|
flow control (the protocol has none; queues are bounded).
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ plaintext or private keys. It speaks the exact wire protocol from
|
|||||||
`docs/protocol.md`, so the official `opentunnel` CLI and SDK work against it
|
`docs/protocol.md`, so the official `opentunnel` CLI and SDK work against it
|
||||||
unchanged.
|
unchanged.
|
||||||
|
|
||||||
Status: **M4** — the data path, real ACME DNS-01 certificates with renewal,
|
Status: **M5** — the data path, real ACME DNS-01 certificates with renewal,
|
||||||
rate limits/hardening, and a native Go client are implemented. The official
|
rate limits/hardening, a native Go client, and a cross-platform desktop UI are
|
||||||
`opentunnel` CLI still works unchanged.
|
implemented. The official `opentunnel` CLI still works unchanged.
|
||||||
|
|
||||||
## Quick start (local development)
|
## Quick start (local development)
|
||||||
|
|
||||||
@@ -52,6 +52,13 @@ go build -o .local/tunnel ./cmd/tunnel
|
|||||||
.local/tunnel status
|
.local/tunnel status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or install the released binary (installs a `tunnel` command):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap TZGyn/tap https://gitea.tzgyn.dev/TZGyn/homebrew-tap.git
|
||||||
|
brew install tzgyn-tunnel
|
||||||
|
```
|
||||||
|
|
||||||
The first `route add` creates a tunnel, generates the P-256 key/CSR, waits for
|
The first `route add` creates a tunnel, generates the P-256 key/CSR, waits for
|
||||||
the certificate, starts the background service, and prints the public URL.
|
the certificate, starts the background service, and prints the public URL.
|
||||||
Routes reload without restarting the service.
|
Routes reload without restarting the service.
|
||||||
@@ -62,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 |
|
||||||
@@ -84,6 +92,47 @@ tunnel up
|
|||||||
the official TOML routes to JSON. Both clients cannot claim the same routes at
|
the official TOML routes to JSON. Both clients cannot claim the same routes at
|
||||||
the same time — stop one before starting the other.
|
the same time — stop one before starting the other.
|
||||||
|
|
||||||
|
## Desktop app
|
||||||
|
|
||||||
|
A cross-platform desktop UI (Fyne, macOS/Linux/Windows) is built into the same
|
||||||
|
client service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o .local/tunnel-desktop ./cmd/tunnel-desktop
|
||||||
|
.local/tunnel-desktop # click Start
|
||||||
|
.local/tunnel-desktop --autostart
|
||||||
|
```
|
||||||
|
|
||||||
|
On macOS, build and ad-hoc sign a launchable bundle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build-desktop-app.sh 0.2.0
|
||||||
|
ditto Tunnel.app "$HOME/Applications/Tunnel.app"
|
||||||
|
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
|
||||||
|
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. **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
|
||||||
|
`CGO_ENABLED=0`. Distribution of the macOS app still needs Developer ID signing
|
||||||
|
and notarization (the local script only ad-hoc signs).
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Every flag also reads an environment variable (`tunneld -h`):
|
Every flag also reads an environment variable (`tunneld -h`):
|
||||||
@@ -244,6 +293,8 @@ setup is needed.
|
|||||||
tunnels
|
tunnels
|
||||||
- Native Go client (`cmd/tunnel`): JSON config, routes with live reload,
|
- Native Go client (`cmd/tunnel`): JSON config, routes with live reload,
|
||||||
PROXY protocol, background service, and import of the official CLI profile
|
PROXY protocol, background service, and import of the official CLI profile
|
||||||
|
- Desktop UI (`cmd/tunnel-desktop`): Fyne app with status, routes, logs,
|
||||||
|
settings, and start/stop
|
||||||
- Graceful shutdown: `drain` + close to every bridge
|
- Graceful shutdown: `drain` + close to every bridge
|
||||||
|
|
||||||
## Not yet (later)
|
## Not yet (later)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
|
||||||
|
"gitea.tzgyn.dev/TZGyn/tunnel/internal/desktop"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
autostart := flag.Bool("autostart", false, "start the tunnel service when the app launches")
|
||||||
|
flag.Parse()
|
||||||
|
desktop.Run(version, *autostart)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -3,6 +3,44 @@ module gitea.tzgyn.dev/TZGyn/tunnel
|
|||||||
go 1.27.3
|
go 1.27.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
fyne.io/fyne/v2 v2.8.2
|
||||||
github.com/coder/websocket v1.8.15
|
github.com/coder/websocket v1.8.15
|
||||||
golang.org/x/crypto v0.58.0
|
golang.org/x/crypto v0.58.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 // indirect
|
||||||
|
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||||
|
github.com/FyshOS/fancyfs v0.0.1 // indirect
|
||||||
|
github.com/anthonynsimon/bild v0.14.0 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect
|
||||||
|
github.com/fyne-io/glfw-js v0.4.0 // indirect
|
||||||
|
github.com/fyne-io/image v0.1.1 // indirect
|
||||||
|
github.com/fyne-io/oksvg v0.2.1-0.20260918172519-a9af55fa95d9 // indirect
|
||||||
|
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect
|
||||||
|
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.2 // indirect
|
||||||
|
github.com/go-text/render v0.2.1 // indirect
|
||||||
|
github.com/go-text/typesetting v0.3.5 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
||||||
|
github.com/hack-pad/safejs v0.1.0 // indirect
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/rymdport/portal v0.4.2 // indirect
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||||
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
|
golang.org/x/image v0.24.0 // indirect
|
||||||
|
golang.org/x/net v0.60.0 // indirect
|
||||||
|
golang.org/x/sys v0.49.0 // indirect
|
||||||
|
golang.org/x/text v0.43.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,4 +1,83 @@
|
|||||||
|
fyne.io/fyne/v2 v2.8.2 h1:gYNJj+ZEPd2wF3M8SlF5iL6febpZkZ1csheRFkuEfHU=
|
||||||
|
fyne.io/fyne/v2 v2.8.2/go.mod h1:eXHJ/D9Y58UcDw7SkguadCRI64M4flXyjoMn827Q5SI=
|
||||||
|
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 h1:149/+Wa5EsLLXfyj2pdTmvnQf2VIlgCIwSjcCTHYhIo=
|
||||||
|
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||||
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg=
|
||||||
|
github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A=
|
||||||
|
github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync=
|
||||||
|
github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8=
|
||||||
|
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
|
||||||
|
github.com/fyne-io/glfw-js v0.4.0 h1:I9hREBeFyI10cNIqbMKYb1PRidyPDgwob8o2la9SfQo=
|
||||||
|
github.com/fyne-io/glfw-js v0.4.0/go.mod h1:SDchsFZh4n7nVuBoiowOhOgIBdz+qUQVeC1w9fe2yVU=
|
||||||
|
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
|
||||||
|
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
|
||||||
|
github.com/fyne-io/oksvg v0.2.1-0.20260918172519-a9af55fa95d9 h1:owsdpq6a2KhaAA/hoHGQmFM7kOgKo3srXZVGnbEUzr0=
|
||||||
|
github.com/fyne-io/oksvg v0.2.1-0.20260918172519-a9af55fa95d9/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
|
||||||
|
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI=
|
||||||
|
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
||||||
|
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.2 h1:ez76TQInhkDIMobWbOFcPJw6FeB7coXRAVZpKwF8NBg=
|
||||||
|
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.2/go.mod h1:T5Dn0JwIJOX1euPZ/iT4tq6nFYtmukjcYa7937HuYK8=
|
||||||
|
github.com/go-text/render v0.2.1 h1:qwHhxqGUjjg4L0XyJWj7M7bpY75NZM+kBpv2Yfw5mcg=
|
||||||
|
github.com/go-text/render v0.2.1/go.mod h1:HCCAq8MUlm/WRcXshBb4K/n+IkjeXQ1c2Ba+yICSm0A=
|
||||||
|
github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
|
||||||
|
github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
|
||||||
|
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
|
||||||
|
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||||
|
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
|
||||||
|
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||||
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
|
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||||
|
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||||
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||||
|
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
|
|||||||
})
|
})
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.report(Status{Connected: false, LastError: err.Error()})
|
||||||
return nil, fmt.Errorf("dial bridge: %w", err)
|
return nil, fmt.Errorf("dial bridge: %w", err)
|
||||||
}
|
}
|
||||||
ws.SetReadLimit(64 * 1024)
|
ws.SetReadLimit(64 * 1024)
|
||||||
@@ -106,8 +107,10 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
|
|||||||
}
|
}
|
||||||
sort.Strings(routes)
|
sort.Strings(routes)
|
||||||
if len(routes) > 128 {
|
if len(routes) > 128 {
|
||||||
|
err := fmt.Errorf("too many routes (%d, max 128)", len(routes))
|
||||||
|
s.report(Status{Connected: false, LastError: err.Error()})
|
||||||
ws.Close(websocket.StatusPolicyViolation, "too many routes")
|
ws.Close(websocket.StatusPolicyViolation, "too many routes")
|
||||||
return nil, fmt.Errorf("too many routes (%d, max 128)", len(routes))
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var attach attachOut
|
var attach attachOut
|
||||||
@@ -141,6 +144,7 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
|
|||||||
switch answer.Type {
|
switch answer.Type {
|
||||||
case "attached":
|
case "attached":
|
||||||
case "attach_error":
|
case "attach_error":
|
||||||
|
s.report(Status{Connected: false, LastError: "attach: " + answer.Code})
|
||||||
ws.Close(websocket.StatusPolicyViolation, answer.Code)
|
ws.Close(websocket.StatusPolicyViolation, answer.Code)
|
||||||
if answer.Code == "bad_token" || answer.Code == "cert_not_ready" {
|
if answer.Code == "bad_token" || answer.Code == "cert_not_ready" {
|
||||||
return nil, fmt.Errorf("%w: %s", errFatalAttach, answer.Code)
|
return nil, fmt.Errorf("%w: %s", errFatalAttach, answer.Code)
|
||||||
@@ -148,7 +152,9 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
|
|||||||
return nil, fmt.Errorf("attach: %s", answer.Code)
|
return nil, fmt.Errorf("attach: %s", answer.Code)
|
||||||
default:
|
default:
|
||||||
ws.Close(websocket.StatusProtocolError, "expected attached")
|
ws.Close(websocket.StatusProtocolError, "expected attached")
|
||||||
return nil, fmt.Errorf("attach: unexpected message %q", answer.Type)
|
err := fmt.Errorf("attach: unexpected message %q", answer.Type)
|
||||||
|
s.report(Status{Connected: false, LastError: err.Error()})
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
ws.SetReadLimit(4 + maxPayload)
|
ws.SetReadLimit(4 + maxPayload)
|
||||||
|
|
||||||
@@ -177,6 +183,7 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
|
|||||||
go sess.writeLoop()
|
go sess.writeLoop()
|
||||||
go sess.heartbeatLoop()
|
go sess.heartbeatLoop()
|
||||||
go sess.readLoop()
|
go sess.readLoop()
|
||||||
|
s.report(Status{Connected: true, Session: answer.Session, Routes: routes, LastConnectedAt: time.Now()})
|
||||||
s.Log.Printf("attached: session %s, %d route(s), heartbeat %s", answer.Session, len(routes), heartbeat)
|
s.Log.Printf("attached: session %s, %d route(s), heartbeat %s", answer.Session, len(routes), heartbeat)
|
||||||
return sess, nil
|
return sess, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ func TestNormalizeTarget(t *testing.T) {
|
|||||||
func TestConfigRoundTrip(t *testing.T) {
|
func TestConfigRoundTrip(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "config.json")
|
path := filepath.Join(dir, "config.json")
|
||||||
cfg := &Config{API: "https://tunnel.example", Routes: map[string]Route{}}
|
cfg := &Config{API: "https://tunnel.example", AutoConnect: true, Routes: map[string]Route{}}
|
||||||
if err := cfg.AddRoute("api", "4096", ""); err != nil {
|
if err := cfg.AddRoute("api", "4096", ""); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ func TestConfigRoundTrip(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if loaded.API != cfg.API || loaded.Routes["api"].Target != "127.0.0.1:4096" {
|
if loaded.API != cfg.API || !loaded.AutoConnect || loaded.Routes["api"].Target != "127.0.0.1:4096" {
|
||||||
t.Errorf("round trip: %+v", loaded)
|
t.Errorf("round trip: %+v", loaded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type Config struct {
|
|||||||
Hostname string `json:"hostname,omitempty"`
|
Hostname string `json:"hostname,omitempty"`
|
||||||
Token string `json:"token,omitempty"`
|
Token string `json:"token,omitempty"`
|
||||||
CertificateExpiry string `json:"certificate_expiry,omitempty"`
|
CertificateExpiry string `json:"certificate_expiry,omitempty"`
|
||||||
|
AutoConnect bool `json:"auto_connect,omitempty"`
|
||||||
Routes map[string]Route `json:"routes,omitempty"`
|
Routes map[string]Route `json:"routes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,52 @@ type Service struct {
|
|||||||
|
|
||||||
cert atomic.Pointer[tls.Certificate]
|
cert atomic.Pointer[tls.Certificate]
|
||||||
|
|
||||||
mu sync.RWMutex // guards Config and API during the renewal poller
|
mu sync.RWMutex // guards Config and API during the renewal poller
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
handler func(Status)
|
||||||
|
status atomic.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is a snapshot of the bridge connection for UIs.
|
||||||
|
type Status struct {
|
||||||
|
Connected bool
|
||||||
|
Session string
|
||||||
|
Routes []string
|
||||||
|
LastError string
|
||||||
|
LastConnectedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStatusHandler registers a callback invoked whenever the status changes.
|
||||||
|
func (s *Service) SetStatusHandler(handler func(Status)) {
|
||||||
|
s.statusMu.Lock()
|
||||||
|
s.handler = handler
|
||||||
|
s.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetConfig replaces the loaded config and API client, used by the desktop UI.
|
||||||
|
func (s *Service) SetConfig(cfg *Config) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.Config = cfg
|
||||||
|
s.API = NewAPIClient(cfg.API)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentStatus returns the latest bridge status.
|
||||||
|
func (s *Service) CurrentStatus() Status {
|
||||||
|
if value := s.status.Load(); value != nil {
|
||||||
|
return value.(Status)
|
||||||
|
}
|
||||||
|
return Status{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) report(status Status) {
|
||||||
|
s.status.Store(status)
|
||||||
|
s.statusMu.RLock()
|
||||||
|
handler := s.handler
|
||||||
|
s.statusMu.RUnlock()
|
||||||
|
if handler != nil {
|
||||||
|
handler(status)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService loads a config (which may not exist yet).
|
// NewService loads a config (which may not exist yet).
|
||||||
@@ -289,7 +334,9 @@ func (s *Service) refreshLoop(ctx context.Context) {
|
|||||||
|
|
||||||
// Run keeps the bridge connected, reloading routes when the config changes.
|
// Run keeps the bridge connected, reloading routes when the config changes.
|
||||||
func (s *Service) Run(ctx context.Context) error {
|
func (s *Service) Run(ctx context.Context) error {
|
||||||
|
s.report(Status{Connected: false})
|
||||||
if err := s.Ensure(ctx); err != nil {
|
if err := s.Ensure(ctx); err != nil {
|
||||||
|
s.report(Status{Connected: false, LastError: err.Error()})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
go s.refreshLoop(ctx)
|
go s.refreshLoop(ctx)
|
||||||
@@ -302,6 +349,7 @@ func (s *Service) Run(ctx context.Context) error {
|
|||||||
cfg, err := s.reload(ctx)
|
cfg, err := s.reload(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.Log.Printf("config: %v", err)
|
s.Log.Printf("config: %v", err)
|
||||||
|
s.report(Status{Connected: false, LastError: err.Error()})
|
||||||
if !wait(ctx, delay) {
|
if !wait(ctx, delay) {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
@@ -324,11 +372,14 @@ func (s *Service) Run(ctx context.Context) error {
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
sess.close("shutdown")
|
sess.close("shutdown")
|
||||||
|
s.report(Status{Connected: false})
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case <-reload:
|
case <-reload:
|
||||||
s.Log.Printf("config changed; reconnecting")
|
s.Log.Printf("config changed; reconnecting")
|
||||||
sess.close("config changed")
|
sess.close("config changed")
|
||||||
|
s.report(Status{Connected: false})
|
||||||
case <-sess.done:
|
case <-sess.done:
|
||||||
|
s.report(Status{Connected: false})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,797 @@
|
|||||||
|
// Package desktop is the Fyne desktop UI for the tunnel client.
|
||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base32"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image/color"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/app"
|
||||||
|
"fyne.io/fyne/v2/canvas"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
|
||||||
|
"gitea.tzgyn.dev/TZGyn/tunnel/internal/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
green = color.NRGBA{0x3f, 0xb9, 0x50, 0xff}
|
||||||
|
red = color.NRGBA{0xf8, 0x51, 0x49, 0xff}
|
||||||
|
gray = color.NRGBA{0x8b, 0x94, 0x9e, 0xff}
|
||||||
|
)
|
||||||
|
|
||||||
|
// App is the desktop application.
|
||||||
|
type App struct {
|
||||||
|
version string
|
||||||
|
configPath string
|
||||||
|
paths client.Paths
|
||||||
|
|
||||||
|
app fyne.App
|
||||||
|
window fyne.Window
|
||||||
|
|
||||||
|
cfg *client.Config
|
||||||
|
svc *client.Service
|
||||||
|
|
||||||
|
logWriter *uiLogWriter
|
||||||
|
|
||||||
|
runMu sync.Mutex
|
||||||
|
runCancel context.CancelFunc
|
||||||
|
|
||||||
|
dot *canvas.Circle
|
||||||
|
stateLabel *widget.Label
|
||||||
|
lastError *widget.Label
|
||||||
|
sessionLabel *widget.Label
|
||||||
|
apiLabel *widget.Label
|
||||||
|
tunnelLabel *widget.Label
|
||||||
|
certLabel *widget.Label
|
||||||
|
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.
|
||||||
|
func Run(version string, autostart bool) {
|
||||||
|
a := &App{version: version, configPath: client.DefaultPath()}
|
||||||
|
a.paths = client.ConfigPaths(a.configPath)
|
||||||
|
|
||||||
|
a.app = app.NewWithID("dev.tzgyn.tunnel")
|
||||||
|
a.window = a.app.NewWindow("Tunnel")
|
||||||
|
a.window.Resize(fyne.NewSize(920, 680))
|
||||||
|
|
||||||
|
a.loadService()
|
||||||
|
a.buildUI()
|
||||||
|
a.refresh()
|
||||||
|
a.readExistingLog()
|
||||||
|
|
||||||
|
a.window.SetOnClosed(func() { a.stop() })
|
||||||
|
a.window.Show()
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
fyne.Do(a.refresh)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if autostart || a.cfg.AutoConnect {
|
||||||
|
a.start()
|
||||||
|
}
|
||||||
|
a.app.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) loadService() {
|
||||||
|
cfg, err := client.Load(a.configPath)
|
||||||
|
if err != nil {
|
||||||
|
a.showError(err)
|
||||||
|
cfg = &client.Config{Routes: map[string]client.Route{}}
|
||||||
|
}
|
||||||
|
a.cfg = cfg
|
||||||
|
a.logWriter = newUILogWriter(a.paths.Dir)
|
||||||
|
logger := log.New(a.logWriter, "", log.LstdFlags)
|
||||||
|
svc, err := client.NewService(a.configPath, a.version, logger)
|
||||||
|
if err != nil {
|
||||||
|
a.showError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.svc = svc
|
||||||
|
a.svc.SetStatusHandler(func(client.Status) {
|
||||||
|
fyne.Do(a.updateStatus)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) buildUI() {
|
||||||
|
a.dot = canvas.NewCircle(gray)
|
||||||
|
a.dot.Resize(fyne.NewSize(12, 12))
|
||||||
|
a.stateLabel = widget.NewLabel("Stopped")
|
||||||
|
a.stateLabel.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
|
a.lastError = widget.NewLabel("")
|
||||||
|
a.lastError.Wrapping = fyne.TextWrapWord
|
||||||
|
a.lastError.Hide()
|
||||||
|
a.sessionLabel = widget.NewLabel("")
|
||||||
|
a.apiLabel = widget.NewLabel("")
|
||||||
|
a.tunnelLabel = widget.NewLabel("")
|
||||||
|
a.certLabel = widget.NewLabel("")
|
||||||
|
a.configLabel = widget.NewLabel(a.configPath)
|
||||||
|
a.configLabel.Wrapping = fyne.TextWrapBreak
|
||||||
|
|
||||||
|
statusCard := widget.NewCard("Status", "", container.NewVBox(
|
||||||
|
container.NewHBox(container.NewGridWrap(fyne.NewSize(14, 14), a.dot), a.stateLabel),
|
||||||
|
formRow("API", a.apiLabel),
|
||||||
|
formRow("Tunnel", a.tunnelLabel),
|
||||||
|
formRow("Certificate", a.certLabel),
|
||||||
|
formRow("Session", a.sessionLabel),
|
||||||
|
formRow("Config", a.configLabel),
|
||||||
|
a.lastError,
|
||||||
|
))
|
||||||
|
|
||||||
|
a.startButton = widget.NewButtonWithIcon("Start", theme.MediaPlayIcon(), a.toggleService)
|
||||||
|
refreshButton := widget.NewButtonWithIcon("Refresh", theme.ViewRefreshIcon(), a.refresh)
|
||||||
|
settingsButton := widget.NewButtonWithIcon("Settings", theme.SettingsIcon(), a.settingsDialog)
|
||||||
|
importButton := widget.NewButtonWithIcon("Import official profile", theme.DownloadIcon(), a.importDialog)
|
||||||
|
openLogButton := widget.NewButtonWithIcon("Open log", theme.DocumentIcon(), func() {
|
||||||
|
if parsed, err := url.Parse("file://" + a.desktopLogPath()); err == nil {
|
||||||
|
_ = a.app.OpenURL(parsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
bannerLabel := widget.NewLabel("The tunnel CLI service is running and claims these routes. Stop it to use the app.")
|
||||||
|
bannerLabel.Wrapping = fyne.TextWrapWord
|
||||||
|
bannerLabel.Importance = widget.WarningImportance
|
||||||
|
stopCLI := widget.NewButtonWithIcon("Stop CLI", theme.MediaStopIcon(), func() {
|
||||||
|
if pid := readCLIpid(a.paths.PID); pid > 0 && processAlive(pid) {
|
||||||
|
terminateProcess(pid)
|
||||||
|
}
|
||||||
|
a.banner.Hide()
|
||||||
|
})
|
||||||
|
a.banner = container.NewBorder(nil, nil, nil, stopCLI, bannerLabel)
|
||||||
|
a.banner.Hide()
|
||||||
|
|
||||||
|
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))
|
||||||
|
a.logWriter.onAppend = func() { fyne.Do(a.refreshLog) }
|
||||||
|
|
||||||
|
content := container.NewVBox(
|
||||||
|
container.NewHBox(
|
||||||
|
widget.NewLabelWithStyle("Tunnel", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
widget.NewLabel("client "+a.version),
|
||||||
|
layout.NewSpacer(),
|
||||||
|
a.startButton,
|
||||||
|
refreshButton,
|
||||||
|
settingsButton,
|
||||||
|
importButton,
|
||||||
|
openLogButton,
|
||||||
|
),
|
||||||
|
statusCard,
|
||||||
|
a.banner,
|
||||||
|
routeHeader,
|
||||||
|
a.routeBox,
|
||||||
|
checkBar,
|
||||||
|
widget.NewLabelWithStyle("Log", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
logScroll,
|
||||||
|
)
|
||||||
|
a.window.SetContent(container.NewPadded(container.NewScroll(content)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formRow(label string, value fyne.CanvasObject) *fyne.Container {
|
||||||
|
return container.NewGridWithColumns(2, widget.NewLabel(label), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) routeNames() []string {
|
||||||
|
names := make([]string, 0, len(a.cfg.Routes))
|
||||||
|
for name := range a.cfg.Routes {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) refresh() {
|
||||||
|
cfg, err := client.Load(a.configPath)
|
||||||
|
if err != nil {
|
||||||
|
a.showError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.cfg = cfg
|
||||||
|
if a.svc != nil {
|
||||||
|
a.svc.SetConfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.apiLabel.SetText(valueOr(cfg.API, "(not set)"))
|
||||||
|
a.tunnelLabel.SetText(valueOr(cfg.Hostname, "(no tunnel yet)"))
|
||||||
|
a.certLabel.SetText(formatCert(cfg.CertificateExpiry))
|
||||||
|
if pid := readCLIpid(a.paths.PID); pid > 0 && processAlive(pid) {
|
||||||
|
a.banner.Show()
|
||||||
|
} else {
|
||||||
|
a.banner.Hide()
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
link := widget.NewHyperlink(linkURL, nil)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
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(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))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCert(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return "(no certificate)"
|
||||||
|
}
|
||||||
|
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
||||||
|
return parsed.Local().Format("2006-01-02 15:04 MST")
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) updateStatus() {
|
||||||
|
status := a.svc.CurrentStatus()
|
||||||
|
running := a.isRunning()
|
||||||
|
switch {
|
||||||
|
case status.Connected:
|
||||||
|
a.dot.FillColor = green
|
||||||
|
a.stateLabel.SetText("Connected")
|
||||||
|
a.sessionLabel.SetText(status.Session + " since " + status.LastConnectedAt.Format("15:04:05"))
|
||||||
|
a.startButton.SetText("Stop")
|
||||||
|
a.startButton.SetIcon(theme.MediaStopIcon())
|
||||||
|
case running:
|
||||||
|
a.dot.FillColor = gray
|
||||||
|
a.stateLabel.SetText("Connecting…")
|
||||||
|
a.sessionLabel.SetText("")
|
||||||
|
a.startButton.SetText("Stop")
|
||||||
|
a.startButton.SetIcon(theme.MediaStopIcon())
|
||||||
|
default:
|
||||||
|
a.dot.FillColor = gray
|
||||||
|
a.stateLabel.SetText("Stopped")
|
||||||
|
a.sessionLabel.SetText("")
|
||||||
|
a.startButton.SetText("Start")
|
||||||
|
a.startButton.SetIcon(theme.MediaPlayIcon())
|
||||||
|
}
|
||||||
|
if status.LastError != "" {
|
||||||
|
a.lastError.SetText(status.LastError)
|
||||||
|
a.lastError.Show()
|
||||||
|
} else {
|
||||||
|
a.lastError.SetText("")
|
||||||
|
a.lastError.Hide()
|
||||||
|
}
|
||||||
|
a.dot.Refresh()
|
||||||
|
a.startButton.Enable()
|
||||||
|
a.refreshRoutes()
|
||||||
|
if status.Connected {
|
||||||
|
a.maybeCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) isRunning() bool {
|
||||||
|
a.runMu.Lock()
|
||||||
|
defer a.runMu.Unlock()
|
||||||
|
return a.runCancel != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) toggleService() {
|
||||||
|
if a.isRunning() {
|
||||||
|
a.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) start() {
|
||||||
|
if a.cfg.API == "" {
|
||||||
|
a.settingsDialog()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.runMu.Lock()
|
||||||
|
if a.runCancel != nil {
|
||||||
|
a.runMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
a.runCancel = cancel
|
||||||
|
svc := a.svc
|
||||||
|
a.runMu.Unlock()
|
||||||
|
|
||||||
|
a.stateLabel.SetText("Provisioning…")
|
||||||
|
a.dot.Refresh()
|
||||||
|
go func() {
|
||||||
|
err := svc.Run(ctx)
|
||||||
|
fyne.Do(func() {
|
||||||
|
a.runMu.Lock()
|
||||||
|
a.runCancel = nil
|
||||||
|
a.runMu.Unlock()
|
||||||
|
a.startButton.Enable()
|
||||||
|
a.updateStatus()
|
||||||
|
if err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
a.showError(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) stop() {
|
||||||
|
a.runMu.Lock()
|
||||||
|
cancel := a.runCancel
|
||||||
|
a.runCancel = nil
|
||||||
|
a.runMu.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
a.updateStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) settingsDialog() {
|
||||||
|
api := widget.NewEntry()
|
||||||
|
api.SetText(a.cfg.API)
|
||||||
|
api.SetPlaceHolder("https://tunnel.example.com")
|
||||||
|
autoConnect := widget.NewCheck("Connect on launch", nil)
|
||||||
|
autoConnect.SetChecked(a.cfg.AutoConnect)
|
||||||
|
dialog.NewForm("Settings", "Save", "Cancel",
|
||||||
|
[]*widget.FormItem{
|
||||||
|
widget.NewFormItem("API", api),
|
||||||
|
widget.NewFormItem("Startup", autoConnect),
|
||||||
|
},
|
||||||
|
func(ok bool) {
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
normalized, err := client.NormalizeAPI(api.Text)
|
||||||
|
if err != nil {
|
||||||
|
a.showError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.cfg.API = normalized
|
||||||
|
a.cfg.AutoConnect = autoConnect.Checked
|
||||||
|
if err := a.cfg.Save(a.configPath); err != nil {
|
||||||
|
a.showError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wasRunning := a.isRunning()
|
||||||
|
if wasRunning {
|
||||||
|
a.stop()
|
||||||
|
}
|
||||||
|
a.refresh()
|
||||||
|
if wasRunning {
|
||||||
|
a.start()
|
||||||
|
}
|
||||||
|
}, a.window).Show()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) importDialog() {
|
||||||
|
profile := widget.NewEntry()
|
||||||
|
profile.SetText("coolify")
|
||||||
|
api := widget.NewEntry()
|
||||||
|
api.SetText(a.cfg.API)
|
||||||
|
api.SetPlaceHolder("https://tunnel.example.com")
|
||||||
|
dialog.NewForm("Import official profile", "Import", "Cancel",
|
||||||
|
[]*widget.FormItem{
|
||||||
|
widget.NewFormItem("Profile", profile),
|
||||||
|
widget.NewFormItem("API", api),
|
||||||
|
},
|
||||||
|
func(ok bool) {
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.isRunning() {
|
||||||
|
a.stop()
|
||||||
|
}
|
||||||
|
result, err := client.Import(client.ImportOptions{
|
||||||
|
Profile: strings.TrimSpace(profile.Text),
|
||||||
|
API: strings.TrimSpace(api.Text),
|
||||||
|
ConfigPath: a.configPath,
|
||||||
|
Force: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.showError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.showInfo(fmt.Sprintf("Imported %s (%s) with routes: %s", result.TunnelID, result.Hostname, strings.Join(result.Routes, ", ")))
|
||||||
|
a.refresh()
|
||||||
|
}, a.window).Show()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) addRouteDialog() {
|
||||||
|
target := widget.NewEntry()
|
||||||
|
target.SetPlaceHolder("3000 or 127.0.0.1:3000")
|
||||||
|
name := widget.NewEntry()
|
||||||
|
name.SetPlaceHolder("optional; random if empty")
|
||||||
|
proxy := widget.NewSelect([]string{"", "v1", "v2"}, nil)
|
||||||
|
proxy.SetSelected("")
|
||||||
|
dialog.NewForm("Add route", "Add", "Cancel",
|
||||||
|
[]*widget.FormItem{
|
||||||
|
widget.NewFormItem("Target", target),
|
||||||
|
widget.NewFormItem("Name", name),
|
||||||
|
widget.NewFormItem("PROXY protocol", proxy),
|
||||||
|
},
|
||||||
|
func(ok bool) {
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetValue := strings.TrimSpace(target.Text)
|
||||||
|
nameValue := strings.TrimSpace(name.Text)
|
||||||
|
if nameValue == "" {
|
||||||
|
nameValue = randomRouteName()
|
||||||
|
}
|
||||||
|
normalized, err := client.NormalizeTarget(targetValue)
|
||||||
|
if err != nil {
|
||||||
|
a.showError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.cfg.AddRoute(nameValue, normalized, proxy.Selected); err != nil {
|
||||||
|
a.showError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.cfg.Save(a.configPath); err != nil {
|
||||||
|
a.showError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.resetChecks()
|
||||||
|
a.refresh()
|
||||||
|
if !a.isRunning() && a.cfg.API != "" {
|
||||||
|
a.start()
|
||||||
|
}
|
||||||
|
a.showInfo("Route " + nameValue + " added: " + a.cfg.RouteURL(nameValue))
|
||||||
|
}, a.window).Show()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) removeRoute(name string) {
|
||||||
|
if !a.cfg.RemoveRoute(name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.cfg.Save(a.configPath); err != nil {
|
||||||
|
a.showError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.resetChecks()
|
||||||
|
a.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) showError(err error) {
|
||||||
|
dialog.ShowError(err, a.window)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) showInfo(message string) {
|
||||||
|
dialog.ShowInformation("Tunnel", message, a.window)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) desktopLogPath() string {
|
||||||
|
return filepath.Join(a.paths.Dir, "tunnel-desktop.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) readExistingLog() {
|
||||||
|
data, err := os.ReadFile(a.desktopLogPath())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onAppend := a.logWriter.onAppend
|
||||||
|
a.logWriter.onAppend = nil
|
||||||
|
for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") {
|
||||||
|
a.logWriter.remember(line)
|
||||||
|
}
|
||||||
|
a.logWriter.onAppend = onAppend
|
||||||
|
a.refreshLog()
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomRouteName() string {
|
||||||
|
buf := make([]byte, 10)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "route"
|
||||||
|
}
|
||||||
|
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCLIpid returns the pid of a running CLI service, or 0.
|
||||||
|
func readCLIpid(path string) int {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return pid
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiLogWriter mirrors service logs into a bounded in-memory list and a file.
|
||||||
|
type uiLogWriter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
lines []string
|
||||||
|
file *os.File
|
||||||
|
onAppend func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUILogWriter(dir string) *uiLogWriter {
|
||||||
|
w := &uiLogWriter{}
|
||||||
|
_ = os.MkdirAll(dir, 0o700)
|
||||||
|
logPath := filepath.Join(dir, "tunnel-desktop.log")
|
||||||
|
if file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
|
||||||
|
w.file = file
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *uiLogWriter) Write(p []byte) (int, error) {
|
||||||
|
line := strings.TrimRight(string(p), "\n")
|
||||||
|
w.append(line)
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *uiLogWriter) append(line string) {
|
||||||
|
w.remember(line)
|
||||||
|
if w.file != nil {
|
||||||
|
_, _ = io.WriteString(w.file, line+"\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remember adds a line to the in-memory list without writing it to the file.
|
||||||
|
func (w *uiLogWriter) remember(line string) {
|
||||||
|
if line == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.mu.Lock()
|
||||||
|
w.lines = append(w.lines, line)
|
||||||
|
if len(w.lines) > 400 {
|
||||||
|
w.lines = w.lines[len(w.lines)-400:]
|
||||||
|
}
|
||||||
|
onAppend := w.onAppend
|
||||||
|
w.mu.Unlock()
|
||||||
|
if onAppend != nil {
|
||||||
|
onAppend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *uiLogWriter) count() int {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
return len(w.lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *uiLogWriter) line(i int) string {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if i < 0 || i >= len(w.lines) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return w.lines[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *uiLogWriter) text(limit int) string {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
lines := w.lines
|
||||||
|
if len(lines) > limit {
|
||||||
|
lines = lines[len(lines)-limit:]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func valueOr(value, fallback string) string {
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func processAlive(pid int) bool {
|
||||||
|
err := syscall.Kill(pid, 0)
|
||||||
|
return err == nil || errors.Is(err, syscall.EPERM)
|
||||||
|
}
|
||||||
|
|
||||||
|
func terminateProcess(pid int) {
|
||||||
|
_ = syscall.Kill(pid, syscall.SIGTERM)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package desktop
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// The CLI background daemon is not supported on Windows; the desktop app
|
||||||
|
// runs the service in-process, so there is no CLI pid to show or stop.
|
||||||
|
func processAlive(int) bool { return false }
|
||||||
|
|
||||||
|
func terminateProcess(pid int) {
|
||||||
|
if process, err := os.FindProcess(pid); err == nil {
|
||||||
|
_ = process.Kill()
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build and ad-hoc sign a local macOS Tunnel.app.
|
||||||
|
# Distribution releases still need Developer ID signing + notarization.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
VERSION="${1:-0.2.0}"
|
||||||
|
BUILD="${BUILD:-1}"
|
||||||
|
FYNE="$(command -v fyne || true)"
|
||||||
|
if [ -z "$FYNE" ]; then
|
||||||
|
FYNE="$(go env GOPATH)/bin/fyne"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$FYNE" ]; then
|
||||||
|
echo "installing fyne CLI..."
|
||||||
|
go install fyne.io/tools/cmd/fyne@latest
|
||||||
|
FYNE="$(go env GOPATH)/bin/fyne"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p .local/desktop
|
||||||
|
go build -o .local/desktop/Tunnel -ldflags "-X main.version=$VERSION" ./cmd/tunnel-desktop
|
||||||
|
rm -rf Tunnel.app
|
||||||
|
"$FYNE" package -os darwin \
|
||||||
|
-executable .local/desktop/Tunnel \
|
||||||
|
-name Tunnel -app-id dev.tzgyn.tunnel \
|
||||||
|
-app-version "$VERSION" -app-build "$BUILD" \
|
||||||
|
-icon assets/icon.png -release
|
||||||
|
|
||||||
|
codesign --force --deep --sign - Tunnel.app
|
||||||
|
echo "Built and ad-hoc signed Tunnel.app (version $VERSION)."
|
||||||
|
echo "Install locally with:"
|
||||||
|
echo " rm -rf \"\$HOME/Applications/Tunnel.app\" && ditto Tunnel.app \"\$HOME/Applications/Tunnel.app\""
|
||||||
Executable
+51
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build, Developer ID sign, notarize, staple and zip the macOS app.
|
||||||
|
# Distribution must not skip this step; an ad-hoc build is not a release.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
VERSION="${1:-0.2.0}"
|
||||||
|
BUILD="${BUILD:-1}"
|
||||||
|
APP="Tunnel.app"
|
||||||
|
OUT=".local/release"
|
||||||
|
ZIP="$OUT/Tunnel-$VERSION.zip"
|
||||||
|
KEY="$HOME/.config/kapture/AuthKey_K5KXMMSB28.p8"
|
||||||
|
KEY_ID="K5KXMMSB28"
|
||||||
|
ISSUER="3e25e5dc-8b75-453f-a71c-c7f3feaac628"
|
||||||
|
|
||||||
|
FYNE="$(command -v fyne || true)"
|
||||||
|
[ -n "$FYNE" ] || FYNE="$(go env GOPATH)/bin/fyne"
|
||||||
|
[ -x "$FYNE" ] || { echo "install fyne: go install fyne.io/tools/cmd/fyne@latest"; exit 1; }
|
||||||
|
[ -f "$KEY" ] || { echo "missing notary key: $KEY"; exit 1; }
|
||||||
|
|
||||||
|
IDENT="$(security find-identity -v -p codesigning | awk '/Developer ID Application:/{print $2; exit}')"
|
||||||
|
[ -n "$IDENT" ] || { echo "no Developer ID Application identity"; exit 1; }
|
||||||
|
|
||||||
|
mkdir -p "$OUT" .local/desktop
|
||||||
|
go build -o .local/desktop/Tunnel -ldflags "-X main.version=$VERSION" ./cmd/tunnel-desktop
|
||||||
|
rm -rf "$APP"
|
||||||
|
"$FYNE" package -os darwin \
|
||||||
|
-executable .local/desktop/Tunnel \
|
||||||
|
-name Tunnel -app-id dev.tzgyn.tunnel \
|
||||||
|
-app-version "$VERSION" -app-build "$BUILD" \
|
||||||
|
-icon assets/icon.png -release
|
||||||
|
|
||||||
|
codesign --force --deep --options runtime --timestamp --sign "$IDENT" "$APP"
|
||||||
|
codesign --verify --deep --strict --verbose=2 "$APP"
|
||||||
|
|
||||||
|
ditto -c -k --keepParent "$APP" "$ZIP"
|
||||||
|
xcrun notarytool submit "$ZIP" --key "$KEY" --key-id "$KEY_ID" --issuer "$ISSUER" \
|
||||||
|
--wait --output-format json
|
||||||
|
|
||||||
|
xcrun stapler staple "$APP"
|
||||||
|
xcrun stapler validate "$APP"
|
||||||
|
spctl -a -vv "$APP" 2>&1 | tee "$OUT/spctl.txt"
|
||||||
|
grep -q "source=Notarized Developer ID" "$OUT/spctl.txt" || {
|
||||||
|
echo "notarization did not produce a Notarized Developer ID app; not releasing"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
rm -f "$ZIP"
|
||||||
|
ditto -c -k --keepParent "$APP" "$ZIP"
|
||||||
|
shasum -a 256 "$ZIP" | tee "$OUT/Tunnel-$VERSION.zip.sha256"
|
||||||
|
echo "Release app: $ZIP"
|
||||||
Reference in New Issue
Block a user