# ADR 0004 — Relay as a raw TCP tunnel, and how its targets are authorized - Status: accepted - Date: 2026-07-28 ## Context Some hosts are not reachable from a user's laptop. The obvious answer is a server-side jump host — but that conflicts directly with [ADR 0001](0001-e2ee-trust-model.md): a server that terminates SSH needs plaintext credentials for the target. A server that dials arbitrary addresses on an authenticated user's behalf is also a textbook SSRF and lateral-movement primitive, aimed at the operator's own network. ## Decision ### The relay forwards bytes, it does not terminate SSH The client opens a WebSocket to the backend; the backend pipes raw bytes to `host:port`. The SSH handshake still terminates on the client, so the relay sees only SSH ciphertext. Zero-knowledge survives. Subprotocol `dodossh.relay.v1`; no framing of our own, because SSH is already a byte stream. ### Two steps, so the WebSocket carries no API authority 1. `POST /relay/tickets` — full bearer JWT, full ACL context. Takes **`{ hostId }`, never a client-supplied address.** 2. `GET /relay/connect` — WebSocket upgrade with the ticket only, zero API access. Tickets are `IDataProtectionProvider` payloads (key management and rotation for free) carrying `jti, userId, deviceId, hostId, targetIps[], targetPort, exp ≤ 30s`, single-use via a unique insert into `relay_ticket_use`. The ticket travels in `Sec-WebSocket-Protocol: dodossh.relay.v1, ticket.`, since constrained WebSocket clients cannot set arbitrary headers; a `?ticket=` fallback exists and **requires query scrubbing in access logs and OTel spans**. ### Anti-SSRF, in order of importance 1. **The server resolves the target from `host_id`.** `relay_enabled = true` and non-null `hostname`/`port` are enforced by a database CHECK constraint. Arbitrary targets are not supported in v1 at all. 2. **DNS resolves at ticket-issue time and the resolved IPs go into the ticket; the relay dials the IP, never the name.** This is what defeats DNS rebinding, which otherwise breaks naive allow-listing. 3. **A non-overridable deny list with no configuration escape:** loopback, link-local, `0.0.0.0/8`, multicast, cloud metadata (`169.254.169.254`, `100.100.100.200`, `fd00:ec2::254`), and **IPv4-mapped IPv6 normalised and then re-checked** — a classic bypass. 4. **A configurable layer:** `Relay:AllowPrivateNetworks` defaults **true**, because RFC1918 is the primary use case for a self-hosted SSH tool, plus allow/deny CIDRs. Ports allow everything except SMTP. Database ports stay open, because forwarding to 5432 is a headline feature — **the real control is the ACL**: you can only dial hosts in a vault you hold `Connect` on. 5. **Limits:** 10 concurrent sessions per user, 200 per node, 12 h maximum, 10 min idle, 5 s connect. ### Why host addresses are plaintext Encrypting hostnames sounds strictly better and is not. The relay must resolve its target server-side or point 1 above collapses and the relay becomes an authenticated open TCP proxy — a worse risk than the metadata it would protect. Plaintext address and port are therefore stored **only when the user opts that host into relay**, enforced by the CHECK constraint. Everything else about a host — username, notes, jump chain, options — is always ciphertext, and there is no plaintext host label at all, because ACL administration runs on the client, which can decrypt names. Searchability is explicitly *not* the argument: vaults hold thousands of items, not millions, so the client syncs everything and searches in memory. The relay and audit arguments are the real ones. ## Consequences - **No session recording or command auditing is possible in relay mode.** The relay sees ciphertext. Do not promise otherwise. Recording would need a separate, explicitly non-zero-knowledge "recorded bastion" mode opted into per host. - Useful audit remains: `target_host:port`, duration, byte counts, close reason, client IP. - Backpressure comes from `System.IO.Pipelines` with `pauseWriterThreshold: 1 MiB`. When the WebSocket peer is slow, `FlushAsync` stops completing, which stops reading the socket, which lets TCP's own receive window throttle the origin end-to-end. No custom flow control, ~2 MiB bounded per session — which is what makes 200 sessions per node viable. - **The relay must not hold a `DbContext` for the session lifetime.** Insert the row, dispose the scope, relay, then open a fresh scope to write the close row. Otherwise the connection pool dies around 100 concurrent sessions. - Tickets are stateless-verifiable and `relay_ticket_use` is the only shared state, so any node accepts any ticket: plain round-robin, no session affinity. That table is also the extraction seam — a standalone relay process needs the Data Protection key ring and one table, no ACL code. Extraction is worthwhile eventually because long-lived connections and short requests have opposite scaling and rollout profiles. - Graceful shutdown sends close 1001 and drains for 30 s, so a `docker compose up -d` does not guillotine live shells. - On the client, SSH.NET cannot be handed a pre-connected stream, so the relay is reached via a loopback TCP bridge. The same bridge provides ProxyJump via a SOCKS5 dynamic forward — one mechanism, two features. ### Rejected - **Server terminates SSH (a true bastion).** Enables recording and central credential control; destroys zero-knowledge. Out of scope, possibly a separate product line. - **Client-supplied target address.** One line of convenience, and the relay becomes an open proxy into the operator's network. - **Allow-listing by hostname.** Defeated by DNS rebinding.