Frontend in web/ (Vite via bun, pure CSS, no component libraries), built to internal/dashboard/dist and embedded into the binary; the Dockerfile builds it in a bun stage. Old static dashboard.html removed.
121 lines
7.0 KiB
Markdown
121 lines
7.0 KiB
Markdown
# 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` — the admin dashboard handlers: `GET
|
|
/api/admin/overview` (bearer `ADMIN_TOKEN`, 503 when unset, 401 when
|
|
wrong) and the page/assets served from the embedded frontend
|
|
- `web/` — the dashboard frontend: Svelte 5 (runes) + TypeScript, Vite via
|
|
bun, pure CSS, no component libraries. `bun run build` emits to
|
|
`internal/dashboard/dist` (gitignored; the committed `.gitkeep` keeps
|
|
`go:embed all:dist` valid, and the handler answers 503 until built).
|
|
`internal/dashboard` embeds the output; the Dockerfile builds it in a bun
|
|
stage before the Go build
|
|
- `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 ./...`
|
|
- Frontend: `cd web && bun install && bun run check && bun run build` — the
|
|
dashboard test skips when `internal/dashboard/dist` has no build (only the
|
|
committed `.gitkeep`)
|
|
- 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.
|