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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user