A cross-platform desktop UI over the same client service: status, routes with add/remove/copy/open, live logs, settings, official-profile import, and start/stop. It uses the existing JSON config and offers to stop a running CLI daemon so the two do not fight over routes. The app can autostart the service, refreshes status/config every few seconds, and builds as a macOS .app via scripts/build-desktop-app.sh.
13 KiB
AGENTS.md
Single-node, self-hosted OpenTunnel-compatible server in Go. The wire protocol and API contract are the reference's, not ours to invent:
- Source of truth:
.reference/opentunnel/docs/protocol.md - API contract:
.reference/opentunnel/crates/opentunnel-server/tests/fixtures/worker-contract.json(paths, status codes, bodies, JSON key order — installed clients depend on it) - Conformance vectors:
.reference/opentunnel/spec/vectors/*.json, run byinternal/server/*_test.go(the reference checkout must exist; tests skip otherwise)
.reference/ is untracked research input (see .reference/opentunnel.md for
what we take vs. skip). Never vendor it into the build.
Layout
cmd/tunneld— flags/env wiring, listeners, signal handlingcmd/tunnel— the native Go client/CLI: JSON config, provisioning, routes, background servicecmd/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 proxyinginternal/desktop— Fyne UI: status, routes, start/stop, logs, settings, importinternal/server/limits.go,cooldown.go— per-IP/per-tunnel rate limits and the ACME CA cool-downinternal/server/proto.go— wire types (pinned JSON key order), IDs/tokens (same generation as the reference: 12-char base32 slugs,rly_tokens, hex SHA-256 token hashes), route validation, data framesinternal/server/clienthello.go— TLS ClientHello parser (SNI + ALPN) that reads exact bytes only, so the connection can be replayed or forwardedinternal/server/localca.go— the built-in insecure CA (the reference'sISSUER=localequivalent); signs tunnel CSRs after checking CN == hostname and SANs ⊆ {hostname,*.hostname}internal/server/issuer.go— the issuer interface plus the local-CA and ACME DNS-01 implementations (account persistence, order/challenge flow, propagation check, certificate for the client's CSR)internal/server/dns.go,dns_porkbun.go,dns_challtestsrv.go— the challenge DNS providers: Porkbun's API for production, pebble-challtestsrv for local ACME testsinternal/server/renew.go— the renewal loop: 30-day window, 6-hour checks, retries, restart recovery, and local-CA → ACME migrationinternal/server/store.go— JSON-file store (0600, atomic rename); single writer, self-hosted scaleinternal/server/api.go— HTTP API per the contract; tunnel existence is checked before auth (404 before 401), body JSON syntax before everythinginternal/server/bridge.go— WebSocket bridge sessions: attach, route claiming (one owner per route), heartbeats, idle retirement, visitor pumpsinternal/server/admin.go— the admin dashboard handlers:GET /api/admin/overview(bearerADMIN_TOKEN, 503 when unset, 401 when wrong) and the page/assets served from the embedded frontendweb/— the dashboard frontend: Svelte 5 (runes) + TypeScript, Vite via bun, pure CSS, no component libraries.bun run buildemits tointernal/dashboard/dist(gitignored; the committed.gitkeepkeepsgo:embed all:distvalid, and the handler answers 503 until built).internal/dashboardembeds the output; the Dockerfile builds it in a bun stage before the Go buildinternal/server/relay.go— public TLS listener: parses the ClientHello, terminates the domain itself, passes tunnel hostnames to bridges untouched
Commands
go build ./.../go vet ./.../gofmt -l .— must stay cleango test ./...- Frontend:
cd web && bun install && bun run check && bun run build— the dashboard test skips wheninternal/dashboard/disthas no build (only the committed.gitkeep) - Go client:
go build -o .local/tunnel ./cmd/tunnel, then.local/tunnel config set-api <url>and.local/tunnel route add <port> --name <name>;up/downmanage the background service,statusshows 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 sameinternal/clientservice 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.--autostartconnects on launch. On macOS,scripts/build-desktop-app.sh <version>builds and ad-hoc signsTunnel.app; the installed copy is~/Applications/Tunnel.app. Cross-OS builds need that OS's CGO toolchain;CGO_ENABLED=0does not work for Fyne. - Dev loop:
go build -o .local/tunneld ./cmd/tunneld && .local/tunneld -domain localhost, thenOPENTUNNEL_API=http://localhost:8080 opentunnel route add 4096 --name apiwith the official CLI.go builddrops a binary next to the source — it is gitignored; prefer-o .local/.
Gotchas
-
Coolify deployment (verified 2026-10-25): app
nokzgyrlhuuzduozr7yts4z3in project Personal,https://tunnel.tzgyn.dev, deploy-key source on the sharedgiteakey, push tomainauto-deploys via the Gitea webhook. Redeploy withPOST /api/v1/deploy?uuid=<app>(the GET form is gone). Webhook details when debugging: Coolify's manual Gitea webhook verifiesX-Hub-Signature-256(HMAC-SHA256 hex of the raw body with the app'smanual_webhook_secret_gitea; Gitea sends this header natively) and matches apps byrepository.full_nameagainst the app'sgit_repositoryafter canonicalization — the:22222port in the scp-style URL is ignored, so it matches fine. A curl replay needsX-Gitea-Event: pushand the signature header, or it answers "No matching application or invalid signature." Coolify's audit log (MCPlist_audit_events,source=webhook) is the debugging oracle:webhook.deployment.queued= authenticated and queued,webhook.gitea.signature_failed= the Gitea hook's secret does not match the app's currentmanual_webhook_secret_gitea. Gitea's hook edit API does not update the stored secret — PATCHing the hook returns 200 but deliveries keep failing signature checks; delete the hook and create a fresh one with the secret instead (current hook: id 6, branch_filtermain). The app's secret is regenerated whenever the app is recreated, so recreate the hook after any app recreation. Coolify's webhook deploy pins the pushed commit (git fetch origin <sha>), and this Gitea only serves the current tip by SHA (uploadpack.allowReachableSHA1InWantis off; other SHAs fail with "not our ref"). Deploys fail if a later push lands before the fetch — re-trigger the deploy. Proper fix, inside the Gitea container:git -C /data/git/repositories/TZGyn/tunnel.git config uploadpack.allowReachableSHA1InWant true. This Coolify version storescustom_labelsbase64-encoded and does not merge its generated Traefik labels for API-created apps: the full label set must live incustom_labels(mirror a working app's generated format — http/https routers withcertresolver=letsencrypt— plus the TCP passthrough lines). On 2026-10-25 the TCP lines were found missing after a deploy (custom_labels held only HTTP/Caddy labels), so Traefik terminated tunnel SNI with its default certificate. Restored withPATCH /api/v1/applications/{uuid}(custom_labelsonly, current HTTP labels preserved) and verified they survive a redeploy; check them after any app update. Deployment JSON from the API can breakjq(invalid escapes in the logs field) — parse with python instead. -
Deployment: the multi-stage
Dockerfilebuilds a static non-root alpine image; verifyCGO_ENABLED=0 go build -trimpath -ldflags="-s -w" ./cmd/tunneldafter changing build-affecting code (no Docker runtime on the dev machine). Tunnel visitor traffic must reach the SNI listener without TLS termination (raw TCP, or a Traefik TCP passthrough router); the API domain may sit behind a TLS-terminating proxy. On a Coolify-managed host (Traefik owns 443/80): API = app domain → container :8080 withHTTP_MODE=serve; tunnel traffic = custom Traefik TCP labels (HostSNIRegexp+tls.passthrough=true) → container :8443. See README "Deploy on Coolify". -
ACME / Porkbun (M2): with
PORKBUN_API_KEYandPORKBUN_SECRET_API_KEYset, the issuer is Let's Encrypt production and every tunnel is reissued (the old local-CA cert keeps serving until the new one is ready). The key used here is restricted totzgyn.dev, but Porkbun also requires API access opt-in per domain; without it every DNS call fails withAPI_ACCESS_DISABLED. Challenge records are_acme-challenge.<id>.<domain>(one name for hostname + wildcard, up to two TXT values) and are deleted when the attempt ends. The ACME account key and URL are stored in the data file, so the persistent volume matters. Failed renewals keep the old certificate serving (state=failed, stillreadyon the wire) and retry every 6 hours. UseACME_DIRECTORY_URL=https://acme-staging-v02.api.letsencrypt.org/directoryfor tests; staging certs are untrusted. Local ACME E2E is Pebble + pebble-challtestsrv withDNS_PROVIDER=challtestsrvandACME_CA_BUNDLE=.../test/certs/pebble.minica.pem. The official CLI caches its certificate and pollsGET /api/tunnel/{id}/certificateevery 12 hours (every minute within 30 days of expiry), applying a renewed certificate to new connections without disconnecting; after switching the issuer, restart the service (opentunnel down/up) to fetch it immediately. An issuance killed mid-flight can leave_acme-challengeTXT records behind; delete them withdns/deleteByNameType(production and test tunnels share the zone). The server sweeps leftover_acme-challenge.*records at startup, which is safe only for a single server instance sharing the zone. Five consecutive CA-side failures pause new orders for 1 minute (doubling to 1 hour); DNS and validation errors do not trigger it. Rate limits default to 60 creates/hour per client IP and 30 binds/hour per IP / 10 per tunnel (0disables); the API is behind Traefik, soX-Forwarded-Foris trusted for this. -
macOS system curl (LibreSSL) does not send SNI for
*.localhostnames, socurl https://api.<id>.localhost:8443/gets closed (the relay requires SNI). Test withopenssl s_client -servername ...or Homebrew curl. -
JSON key order of API bodies is contractual — always
json.Marshalstructs with declared field order, never maps. -
Bridge writes use separate prioritized control and ordered data queues per session.
open,reset, pings and pongs cut ahead of queued visitor data;endtravels in the data queue so it cannot overtake the final response bytes. Visitor data backpressures the socket when the client is slow. Do not moveendback to the control queue (that truncates responses). -
The native Go client's config is
~/.config/tunnel/config.json(0600; it contains the tunnel token), withprivate-key.pem,csr.pem,certificate.pemandchain.pemnext to it.--configselects another tunnel.endin its own bridge client is likewise ordered with data. -
Both clients claim routes on the same tunnel: stop the official
opentunnelservice before startingtunnel, or the second attach getsroute_conflict. -
The bridge WebSocket must use
coder/websocketwith compression disabled and theopentunnelsubprotocol; read limits are 64 KiB before attach and 4+32 KiB after. -
No third-party deps beyond
github.com/coder/websocket,golang.org/x/crypto(ACME), andfyne.io/fyne/v2(desktop UI). Keep it that way unless a milestone calls for it. -
CLI release:
v0.1.0(tag onebae420) is published on Gitea with darwin/linux arm64+amd64 tarballs andchecksums.txt. The Homebrew formula istzgyn-tunnelinTZGyn/homebrew-tap(it installs thetunnelbinary; the name avoids the deprecated coretunnelformula). Rebuild with-X main.version=<version>, attach all four assets, and update the four URLs/SHAs in the formula on each release.tunnel upcurrently starts a 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); 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).