Add the native Go client and finish M3 hardening

The new cmd/tunnel client keeps the API URL and tunnel identity in
~/.config/tunnel/config.json (0600), provisions the key/CSR and
certificate, terminates TLS, proxies to local routes, reloads routes
without a restart, and runs as a background service. `tunnel import`
takes over an existing official CLI profile, preserving its tunnel ID,
token, key and certificate.

Server hardening: per-IP and per-tunnel rate limits for creates and
binds, a CA cool-down after repeated ACME failures, a startup sweep for
stale _acme-challenge records, admin metrics, a 60-second half-close
timeout, and separate prioritized control / ordered data bridge queues
(with `end` ordered after the final bytes). The dashboard shows the new
metrics.
This commit is contained in:
2026-10-26 00:32:31 +08:00
parent cba7d2366b
commit ebae420ab1
27 changed files with 3745 additions and 56 deletions
+35 -8
View File
@@ -16,6 +16,12 @@ what we take vs. skip). Never vendor it into the build.
## Layout
- `cmd/tunneld` — flags/env wiring, listeners, signal handling
- `cmd/tunnel` — the native Go client/CLI: JSON config, provisioning, routes,
background service
- `internal/client` — client implementation: config/API, key/CSR/certificate
storage, bridge WebSocket, TLS termination and local proxying
- `internal/server/limits.go`, `cooldown.go` — per-IP/per-tunnel rate limits
and the ACME CA cool-down
- `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
@@ -57,6 +63,11 @@ what we take vs. skip). Never vendor it into the build.
- 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`)
- Go client: `go build -o .local/tunnel ./cmd/tunnel`, then
`.local/tunnel config set-api <url>` and `.local/tunnel route add <port> --name <name>`;
`up`/`down` manage the background service, `status` shows local + server
state. `import --profile <name> --api <url>` takes over an official CLI
profile (same tunnel/key/cert).
- 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
@@ -135,20 +146,36 @@ what we take vs. skip). Never vendor it into the build.
the service (`opentunnel down` / `up`) to fetch it immediately. An issuance
killed mid-flight can leave `_acme-challenge` TXT records behind; delete them
with `dns/deleteByNameType` (production and test tunnels share the zone).
The server sweeps leftover `_acme-challenge.*` records at startup, which is
safe only for a single server instance sharing the zone. Five consecutive
CA-side failures pause new orders for 1 minute (doubling to 1 hour); DNS and
validation errors do not trigger it. Rate limits default to 60 creates/hour
per client IP and 30 binds/hour per IP / 10 per tunnel (`0` disables); the
API is behind Traefik, so `X-Forwarded-For` is trusted for this.
- **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.
- Bridge writes use separate prioritized **control** and ordered **data**
queues per session. `open`, `reset`, pings and pongs cut ahead of queued
visitor data; `end` travels in the data queue so it cannot overtake the final
response bytes. Visitor data backpressures the socket when the client is
slow. Do not move `end` back to the control queue (that truncates responses).
- The native Go client's config is `~/.config/tunnel/config.json` (0600; it
contains the tunnel token), with `private-key.pem`, `csr.pem`,
`certificate.pem` and `chain.pem` next to it. `--config` selects another
tunnel. `end` in its own bridge client is likewise ordered with data.
- Both clients claim routes on the same tunnel: stop the official
`opentunnel` service before starting `tunnel`, or the second attach gets
`route_conflict`.
- 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.
- No third-party deps beyond `github.com/coder/websocket` and
`golang.org/x/crypto` (ACME); keep it that way unless a milestone calls for it.
- Milestones: M1 core data path (done); M2 real ACME DNS-01 + renewals (done);
M3 rate limits/admin/hardening; M4 maybe a Go client. Don't pull M2+ scope
into M1 fixes, or M3 scope into M2 fixes.
M3 rate limits/admin/hardening (done); M4 Go client (done). Remaining optional
work: export/import admin endpoints, token rotation, multi-region mesh,
inbound PROXY protocol, per-connection wire flow control (the protocol has
none; queues are bounded).