Files
tunnel/AGENTS.md
T

6.5 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 by internal/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 handling
  • internal/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 frames
  • internal/server/clienthello.go — TLS ClientHello parser (SNI + ALPN) that reads exact bytes only, so the connection can be replayed or forwarded
  • internal/server/localca.go — the built-in insecure CA (the reference's ISSUER=local equivalent); signs tunnel CSRs after checking CN == hostname and SANs ⊆ {hostname, *.hostname}
  • internal/server/store.go — JSON-file store (0600, atomic rename); single writer, self-hosted scale
  • internal/server/api.go — HTTP API per the contract; tunnel existence is checked before auth (404 before 401), body JSON syntax before everything
  • internal/server/bridge.go — WebSocket bridge sessions: attach, route claiming (one owner per route), heartbeats, idle retirement, visitor pumps
  • internal/server/admin.go + dashboard.html — the admin dashboard served at / (embedded HTML, no build step) and GET /api/admin/overview, both behind the ADMIN_TOKEN bearer token (503 when unset, 401 when wrong)
  • internal/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 clean
  • go test ./...
  • 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 gitignored; prefer -o .local/.

Gotchas

  • Coolify deployment (verified 2026-10-25): app nokzgyrlhuuzduozr7yts4z3 in project Personal, https://tunnel.tzgyn.dev, deploy-key source on the shared gitea key, push to main auto-deploys via the Gitea webhook. Redeploy with POST /api/v1/deploy?uuid=<app> (the GET form is gone). Webhook details when debugging: Coolify's manual Gitea webhook verifies X-Hub-Signature-256 (HMAC-SHA256 hex of the raw body with the app's manual_webhook_secret_gitea; Gitea sends this header natively) and matches apps by repository.full_name against the app's git_repository after canonicalization — the :22222 port in the scp-style URL is ignored, so it matches fine. A curl replay needs X-Gitea-Event: push and the signature header, or it answers "No matching application or invalid signature." Coolify's audit log (MCP list_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 current manual_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_filter main). 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.allowReachableSHA1InWant is 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 stores custom_labels base64-encoded and does not merge its generated Traefik labels for API-created apps: the full label set must live in custom_labels (mirror a working app's generated format — http/https routers with certresolver=letsencrypt — plus the TCP passthrough lines). Deployment JSON from the API can break jq (invalid escapes in the logs field) — parse with python instead.

  • Deployment: the multi-stage Dockerfile builds a static non-root alpine image; verify CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" ./cmd/tunneld after 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 with HTTP_MODE=serve; tunnel traffic = custom Traefik TCP labels (HostSNIRegexp + tls.passthrough=true) → container :8443. See README "Deploy on Coolify".

  • macOS system curl (LibreSSL) does not send SNI for *.localhost names, so curl https://api.<id>.localhost:8443/ gets closed (the relay requires SNI). Test with openssl s_client -servername ... or Homebrew curl.

  • JSON key order of API bodies is contractual — always json.Marshal structs with declared field order, never maps.

  • Bridge writes go through one ordered outbox channel per session. Control messages therefore can queue behind data — a known M1 deviation from the reference's deadlock-avoidance; a stalled visitor self-heals via the client's idle timeout. Revisit with per-connection flow control.

  • 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; keep it that way unless a milestone calls for it.

  • Milestones: M1 core data path (done); M2 real ACME DNS-01 + renewals; M3 rate limits/admin/hardening; M4 maybe a Go client. Don't pull M2+ scope into M1 fixes.