Add a cross-platform desktop app (Fyne)

The tunnel client now has a desktop UI over the same internal/client
service: status with live connection state, routes with add/remove/copy,
live logs, settings, profile import, and a start/stop button that runs
the bridge in-process. It uses the existing ~/.config/tunnel/config.json
and shows a warning banner with a stop button when the CLI service is
already running and claiming the routes.
This commit is contained in:
2026-10-29 21:32:21 +08:00
parent e4ed31f161
commit ddcea99f37
8 changed files with 799 additions and 12 deletions
+16 -6
View File
@@ -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,10 @@ 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.
- 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,8 +180,9 @@ 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.
- **CLI release**: `v0.1.0` (tag on `ebae420`) is published on Gitea with - **CLI release**: `v0.1.0` (tag on `ebae420`) is published on Gitea with
darwin/linux arm64+amd64 tarballs and `checksums.txt`. The Homebrew formula darwin/linux arm64+amd64 tarballs and `checksums.txt`. The Homebrew formula
is `tzgyn-tunnel` in `TZGyn/homebrew-tap` (it installs the `tunnel` binary; is `tzgyn-tunnel` in `TZGyn/homebrew-tap` (it installs the `tunnel` binary;
@@ -183,7 +192,8 @@ what we take vs. skip). Never vendor it into the build.
detached process, not a launchd agent (the official CLI's agent was stopped detached process, not a launchd agent (the official CLI's agent was stopped
when the live tunnel was switched to this client). when the live tunnel was switched to this client).
- 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).
+21 -3
View File
@@ -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)
@@ -91,6 +91,22 @@ 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
```
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. If the CLI service is already running, a banner offers to stop it
first.
## Configuration ## Configuration
Every flag also reads an environment variable (`tunneld -h`): Every flag also reads an environment variable (`tunneld -h`):
@@ -251,6 +267,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)
+11
View File
@@ -0,0 +1,11 @@
package main
import (
"gitea.tzgyn.dev/TZGyn/tunnel/internal/desktop"
)
var version = "dev"
func main() {
desktop.Run(version)
}
+38
View File
@@ -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
)
+79
View File
@@ -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=
+9 -2
View File
@@ -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
} }
+51
View File
@@ -24,6 +24,51 @@ 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})
} }
} }
} }
+573
View File
@@ -0,0 +1,573 @@
// 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"
"syscall"
"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
routeList *widget.List
logList *widget.List
banner fyne.CanvasObject
}
// Run creates and runs the desktop app.
func Run(version string) {
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.ShowAndRun()
}
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
stopCLI := widget.NewButtonWithIcon("Stop CLI", theme.MediaStopIcon(), func() {
if pid := readCLIpid(a.paths.PID); pid > 0 && processAlive(pid) {
_ = syscall.Kill(pid, syscall.SIGTERM)
}
a.banner.Hide()
})
a.banner = container.NewHBox(bannerLabel, stopCLI)
a.banner.Hide()
routeHeader := container.NewHBox(
widget.NewLabelWithStyle("Routes", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
layout.NewSpacer(),
widget.NewButtonWithIcon("Add route", theme.ContentAddIcon(), a.addRouteDialog),
)
a.routeList = widget.NewList(
func() int { return len(a.routeNames()) },
func() fyne.CanvasObject {
return container.NewHBox(
widget.NewLabel("name"),
widget.NewLabel("target"),
widget.NewHyperlink("url", nil),
layout.NewSpacer(),
widget.NewButtonWithIcon("", theme.ContentCopyIcon(), nil),
widget.NewButtonWithIcon("", theme.DeleteIcon(), nil),
)
},
func(i widget.ListItemID, item fyne.CanvasObject) {
names := a.routeNames()
name := names[i]
route := a.cfg.Routes[name]
linkURL := a.cfg.RouteURL(name)
box := item.(*fyne.Container)
box.Objects[0].(*widget.Label).SetText(name)
box.Objects[1].(*widget.Label).SetText(route.Target)
link := box.Objects[2].(*widget.Hyperlink)
link.SetText(linkURL)
if parsed, err := url.Parse(linkURL); err == nil {
link.SetURL(parsed)
}
box.Objects[4].(*widget.Button).OnTapped = func() { a.app.Clipboard().SetContent(linkURL) }
box.Objects[5].(*widget.Button).OnTapped = func() { a.removeRoute(name) }
},
)
a.logList = widget.NewList(
func() int { return a.logWriter.count() },
func() fyne.CanvasObject { return widget.NewLabel("") },
func(i widget.ListItemID, item fyne.CanvasObject) { item.(*widget.Label).SetText(a.logWriter.line(i)) },
)
a.logWriter.onAppend = func() { fyne.Do(a.logList.Refresh) }
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.routeList,
widget.NewLabelWithStyle("Log", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
a.logList,
)
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
a.svc.SetConfig(cfg)
a.apiLabel.SetText(valueOr(cfg.API, "(not set)"))
a.tunnelLabel.SetText(valueOr(cfg.Hostname, "(no tunnel yet)"))
a.certLabel.SetText(valueOr(cfg.CertificateExpiry, "(no certificate)"))
if pid := readCLIpid(a.paths.PID); pid > 0 && processAlive(pid) {
a.banner.Show()
} else {
a.banner.Hide()
}
a.routeList.Refresh()
a.updateStatus()
}
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.routeList.Refresh()
}
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.startButton.Disable()
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")
dialog.NewForm("Settings", "Save", "Cancel",
[]*widget.FormItem{widget.NewFormItem("API", api)},
func(ok bool) {
if !ok {
return
}
normalized, err := client.NormalizeAPI(api.Text)
if err != nil {
a.showError(err)
return
}
a.cfg.API = normalized
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.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.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
}
for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") {
a.logWriter.append(line)
}
}
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
}
func processAlive(pid int) bool {
err := syscall.Kill(pid, 0)
return err == nil || errors.Is(err, syscall.EPERM)
}
// 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) {
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 w.file != nil {
_, _ = io.WriteString(w.file, line+"\n")
}
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 valueOr(value, fallback string) string {
if value == "" {
return fallback
}
return value
}