M1: single-node OpenTunnel-compatible server
HTTP API per the recorded contract, bridge WebSocket, SNI relay with pass-through tunnel TLS, local CA issuance, JSON store. Verified end-to-end with the official opentunnel CLI.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# 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
|
||||
|
||||
- **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.
|
||||
Reference in New Issue
Block a user