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/tunnel` — the native Go client/CLI: JSON config, provisioning, routes,
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
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
and the ACME CA cool-down
- `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
state. `import --profile <name> --api <url>` takes over an official CLI
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`,
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
@@ -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
and the `opentunnel` subprotocol; read limits are 64 KiB before attach and
4+32 KiB after.
- No third-party deps beyond `github.com/coder/websocket` and
`golang.org/x/crypto` (ACME); keep it that way unless a milestone calls for it.
- No third-party deps beyond `github.com/coder/websocket`,
`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
darwin/linux arm64+amd64 tarballs and `checksums.txt`. The Homebrew formula
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
when the live tunnel was switched to this client).
- 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
work: export/import admin endpoints, token rotation, multi-region mesh,
inbound PROXY protocol, per-connection wire flow control (the protocol has
none; queues are bounded).
M3 rate limits/admin/hardening (done); M4 Go client (done); M5 desktop app
(done). Remaining optional work: launchd/systemd agents for the Go client,
release packaging for the desktop app, export/import admin endpoints, token
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
unchanged.
Status: **M4** — the data path, real ACME DNS-01 certificates with renewal,
rate limits/hardening, and a native Go client are implemented. The official
`opentunnel` CLI still works unchanged.
Status: **M5** — the data path, real ACME DNS-01 certificates with renewal,
rate limits/hardening, a native Go client, and a cross-platform desktop UI are
implemented. The official `opentunnel` CLI still works unchanged.
## 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 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
Every flag also reads an environment variable (`tunneld -h`):
@@ -251,6 +267,8 @@ setup is needed.
tunnels
- Native Go client (`cmd/tunnel`): JSON config, routes with live reload,
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
## 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
require (
fyne.io/fyne/v2 v2.8.2
github.com/coder/websocket v1.8.15
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/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/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()
if err != nil {
s.report(Status{Connected: false, LastError: err.Error()})
return nil, fmt.Errorf("dial bridge: %w", err)
}
ws.SetReadLimit(64 * 1024)
@@ -106,8 +107,10 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
}
sort.Strings(routes)
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")
return nil, fmt.Errorf("too many routes (%d, max 128)", len(routes))
return nil, err
}
var attach attachOut
@@ -141,6 +144,7 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
switch answer.Type {
case "attached":
case "attach_error":
s.report(Status{Connected: false, LastError: "attach: " + answer.Code})
ws.Close(websocket.StatusPolicyViolation, answer.Code)
if answer.Code == "bad_token" || answer.Code == "cert_not_ready" {
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)
default:
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)
@@ -177,6 +183,7 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
go sess.writeLoop()
go sess.heartbeatLoop()
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)
return sess, nil
}
+52 -1
View File
@@ -23,7 +23,52 @@ type Service struct {
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).
@@ -289,7 +334,9 @@ func (s *Service) refreshLoop(ctx context.Context) {
// Run keeps the bridge connected, reloading routes when the config changes.
func (s *Service) Run(ctx context.Context) error {
s.report(Status{Connected: false})
if err := s.Ensure(ctx); err != nil {
s.report(Status{Connected: false, LastError: err.Error()})
return err
}
go s.refreshLoop(ctx)
@@ -302,6 +349,7 @@ func (s *Service) Run(ctx context.Context) error {
cfg, err := s.reload(ctx)
if err != nil {
s.Log.Printf("config: %v", err)
s.report(Status{Connected: false, LastError: err.Error()})
if !wait(ctx, delay) {
return ctx.Err()
}
@@ -324,11 +372,14 @@ func (s *Service) Run(ctx context.Context) error {
select {
case <-ctx.Done():
sess.close("shutdown")
s.report(Status{Connected: false})
return ctx.Err()
case <-reload:
s.Log.Printf("config changed; reconnecting")
sess.close("config changed")
s.report(Status{Connected: false})
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
}