Files
tunnel/AGENTS.md
T
TZGyn 4dc3d14a06 Add Dockerfile for Coolify deployment
Static non-root alpine image; tunneld must own host ports 443/80
directly since visitor TLS is SNI-routed and never proxy-terminated.
2026-10-25 13:53:58 +08:00

3.7 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/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

  • 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). Visitors must reach tunneld directly on 443/80 — never behind a TLS-terminating proxy (Coolify's Traefik included).

  • 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.