diff --git a/AGENTS.md b/AGENTS.md index 645e4df..3a494bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 --api ` 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). diff --git a/README.md b/README.md index 7e55210..736c180 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/cmd/tunnel-desktop/main.go b/cmd/tunnel-desktop/main.go new file mode 100644 index 0000000..538a75c --- /dev/null +++ b/cmd/tunnel-desktop/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "gitea.tzgyn.dev/TZGyn/tunnel/internal/desktop" +) + +var version = "dev" + +func main() { + desktop.Run(version) +} diff --git a/go.mod b/go.mod index 6b9ef95..2e2e57b 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum index e9237ed..feef36a 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/client/bridge.go b/internal/client/bridge.go index eae958b..04952a1 100644 --- a/internal/client/bridge.go +++ b/internal/client/bridge.go @@ -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 } diff --git a/internal/client/service.go b/internal/client/service.go index d23478d..10b96e5 100644 --- a/internal/client/service.go +++ b/internal/client/service.go @@ -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}) } } } diff --git a/internal/desktop/app.go b/internal/desktop/app.go new file mode 100644 index 0000000..ff9d29d --- /dev/null +++ b/internal/desktop/app.go @@ -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 +}