Public Access
Records the decisions the milestone plan already made, with their costs stated rather than only their benefits: - 0001 e2ee-trust-model: key hierarchy, the AAD-to-row binding that stops the server moving ciphertext between rows, and the four-layer public-key trust story. States plainly that revocation is not retroactive, that Connect cannot be a security boundary, and that the IdP becomes a key-distribution trust root. - 0002 minimal-apis: feature modules with explicit registration; capability negotiation instead of Asp.Versioning, since client and server upgrade independently when self-hosted. - 0003 sync-protocol: single write path, revision cursors, and the bigserial pre-commit sequence gap that silently corrupts sync — plus the per-vault advisory lock that fixes it and the test that must prove it. - 0004 relay-authorization: relay forwards bytes rather than terminating SSH, so zero-knowledge survives; server-resolved target IPs in the ticket to defeat DNS rebinding; why host addresses must be plaintext when relay is enabled. - 0005 no-application-layer: why the usual Application/mediator layer earns nothing here, with the trigger that would make us revisit it. - 0006 observability-stack: OTel plus built-in ILogger; liveness excludes dependencies so a database blip cannot restart the container and kill live SSH sessions. Also adds a README covering layout, build, enforced conventions and milestones.
102 lines
5.6 KiB
Markdown
102 lines
5.6 KiB
Markdown
# 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.<token>`, 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.
|