Files
DodoSSH/docs/adr/0004-relay-authorization.md
jaap-jan ce43f397a6 Add ADRs 0001-0006 and README (M0)
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.
2026-07-28 12:28:44 +02:00

5.6 KiB

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: 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.