5.2 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 handlinginternal/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/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/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 ./...- 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." 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). 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". -
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 go through one ordered
outboxchannel 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/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; 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.