# 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/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=` (the GET form is gone). 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..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.