# Deploying the rendezvous-relay service — build & ops plan > **Status: PRE-PRODUCTION — not runnable as-is.** The 7 relay packages (P1–P6 + contracts) are > libraries wired through **injection seams that currently hold fail-closed stubs**. There is no > server process, no TLS/subdomain ingress, no Postgres/Redis/KMS wiring, and no orchestration. > "Deploying" the relay means **building the integration + infra layer** the packages were designed > to receive. This doc is the buildable spec: architecture, the seam-by-seam gaps, a phased order, > the env/config reference, and the ops runbook. Security posture of the packages themselves is > covered in [REVIEW_RELAY_SECURITY.md](./REVIEW_RELAY_SECURITY.md) (findings F1–F6 fixed). --- ## 1. Target architecture A **rendezvous relay**: the agent runs on the user's own host (no inbound ports there) and **dials out** to the relay; the browser connects to a per-tenant subdomain; the relay muxes the two and does an **opaque byte splice** — it only ever sees ciphertext (INV2). End-to-end crypto (P4) is between browser and agent; auth/tenant-isolation (P5) gates every upgrade; the control-plane (P3) is the registry + CA + revocation publisher. ``` Browser (P6, xterm) User's host machine │ WSS .term. │ │ capability token (subprotocol/cookie) + DPoP │ shell / node-pty ▼ ▲ loopback (opaque, INV11) ┌──────────────────────────┐ agent mux (mTLS, ┌────┴───────────────┐ │ RELAY DATA PLANE (P1) │◀──SPIFFE cert)─────────│ AGENT (P2) │ │ term-relay/data-plane │ dial-OUT │ dist/cli.js, ws │ │ • TLS + WSS ingress │ │ E2E host endpoint│ │ • onUpgrade → P5 authz │ │ replay sealer │ │ • subdomain → hostId │ └────────────────────┘ │ • OPAQUE splice (INV2) │ ▲ enroll/pair │ • Redis sub relay:revoc. │ │ └───────┬──────────────────┬┘ │ │ P5 authz (lib) │ Redis pub/sub │ ▼ ▼ │ ┌──────────────────┐ ┌─────────┐ ┌──────────────────────────┴─┐ │ relay-auth (P5) │ │ Redis │◀──│ CONTROL PLANE (P3) │ │ tokens+DPoP, │ │ relay: │ │ control-plane, Fastify │ │ step-up, mTLS/ │ │ revoc. │ │ • accounts/hosts/sessions │ │ SPIFFE, revoke │ └─────────┘ │ • pairing + /enroll (CA) │──▶ Postgres └──────────────────┘ │ • issues agent SPIFFE certs│──▶ KMS (CA key) │ • publishes KillSignals │ └─────────────────────────────┘ ``` **Trust boundaries:** the relay is UNTRUSTED for payload confidentiality (E2E, INV2); it IS trusted for availability + authz enforcement. The control-plane holds the CA (must be KMS-custodied). Agents authenticate to the relay with SPIFFE mTLS; browsers authenticate with short-lived capability tokens (minted by P5 after human auth) bound to a DPoP key. Never expose any port without TLS. --- ## 2. Components & runtime | Pkg | Role | Runtime today | External deps | |---|---|---|---| | **relay-contracts** | frozen schemas/codecs | library | — | | **relay-e2e (P4)** | browser↔agent E2E crypto | library (isomorphic) | @noble/* | | **relay-auth (P5)** | authz, tokens+DPoP, step-up, mTLS, revoke | library ✅ audited | — | | **term-relay (P1)** | stateless relay data-plane + mux | **library — no server** | (needs `ws`, `tls`) | | **control-plane (P3)** | accounts/hosts/pairing/CA/revocation | Fastify app, **in-memory default** | fastify, **pg**, **ioredis**, KMS | | **agent (P2)** | host agent: dial, pair, mTLS, E2E, replay | CLI `web-terminal-agent`→`dist/cli.js` | ws | | **relay-web (P6)** | browser bundle (xterm, preview) | needs `build` + static serve | @xterm/* | --- ## 3. The wiring gaps (what must be BUILT to deploy) Each is an injected seam currently holding a safe stub. In rough dependency order: 1. **P1 socket server (biggest gap).** `term-relay/data-plane/relay-node.ts` takes `WebSocketLike` + `AgentListener` as **injected seams** — there is no actual listener. Build a `relay-run/` entry that: (a) terminates browser **WSS** on `BIND_PORT` and adapts each socket to `WebSocketLike`; (b) accepts agent **mTLS** connections on `AGENT_BIND_PORT` (verify SPIFFE client certs against `AGENT_CA_CHAIN_PATH`) and adapts them to `AgentListener`/mux; (c) calls `authorizeUpgrade` (→ P5 `onUpgrade`) and splices. Wire the Redis `relay:revocations` subscriber to `closeStream`. 2. **Inject P5 into P1 + P3.** P1's `Authorizer` must call relay-auth `onUpgrade`/`onReattach` with a real `EnforceDeps` (registries backed by P3, revocation store, buckets, audit). P3's `refuseAllVerifier` must be replaced with relay-auth `verifyCapabilityToken` + the real `CAPABILITY_SIGN_PUBKEY_B64`. **Close F2 here:** the P1 upgrade adapter currently hardcodes `principal: null` — for any host with a step-up policy it must resolve + pass the authenticated principal, else those hosts (correctly) 403. See [[relay-stepup-needs-principal-wiring]]. 3. **P3 durable stores.** Swap `createMemoryStores` for the Postgres adapter (accounts/hosts/sessions registries) + run migrations; point the revocation bus at real Redis (`ioredis`). 4. **Real KMS for the CA key.** `boot/ca-wiring.ts` uses a dev in-process Ed25519 signer ("NOT a real KMS"). Provide a `KmsResolver` backed by cloud KMS/HSM; the intermediate key must be non-exportable (§3.1). `CA_INTERMEDIATE_KMS_KEY_REF` is the ref. 5. **Agent build + config.** Build `dist/cli.js`; configure `RELAY_URL`/`ENROLL_URL`/`SUBDOMAIN`/ `HOST_ID`/`LOCAL_TARGET_URL`/`STATE_DIR`; enroll (redeem pairing code → SPIFFE cert + `hostContentSecret`). 6. **relay-web build + serve.** `npm --prefix relay-web run build`; serve the bundle from each tenant subdomain (same origin as the WSS endpoint so Origin/CSWSH checks pass). 7. **F6 replay transport (residual).** The recoverable-replay ring buffer must **persist each generation's `epoch` and serve the epoch matching those exact frames** (`relay-web/.../manage-page.ts` `loadReplay` is still a throwing stub). Until wired, the preview/replay feature is inert (fail-closed). --- ## 4. Phased build order - **Phase 0 — single-host dev run (one process, self-signed).** Reuse the `e2e/harness` wiring: a `ws` server hosting the relay-node, control-plane with in-memory stores, P4/P5 wired for real, self-signed TLS, agent dialing `localhost`, relay-web served locally. Goal: an actually-running end-to-end relay to click through. No pg/redis/KMS. - **Phase 1 — single-tenant staging.** Real Postgres + Redis; real (staging) TLS cert for one subdomain; close F2 principal wiring; agent on a second machine dialing in; behind Tailscale. - **Phase 2 — multi-tenant production.** Wildcard TLS `*.term.` + subdomain routing; real KMS CA custody; horizontal relay nodes (stateless, share Redis); migrations/rollback; metering; F6 replay transport; monitoring/alerting on the audit sink + revocation latency budget. --- ## 5. Environment / config reference (accurate to the code) **control-plane (P3)** — `control-plane/src/env.ts`, all required unless defaulted: `PG_URL`, `REDIS_URL`, `CAPABILITY_SIGN_PUBKEY_B64` (P5 verify pubkey), `CA_INTERMEDIATE_KMS_KEY_REF`, `CA_INTERMEDIATE_CERT_PATH`, `NODE_MTLS_TRUST_BUNDLE_PATH`, `BASE_DOMAIN`, `HEARTBEAT_TTL_SEC`=15, `PAIRING_TTL_SEC`=600, `PAIRING_MAX_REDEEM_ATTEMPTS`=5. Routes: `POST /accounts`, `POST /accounts/:id/pairing-codes`, `POST /accounts/:id/status`, `GET /accounts/:id/hosts`, `DELETE /hosts/:hostId`, `POST /enroll`. (No `start` script / listen entry exists yet — add one.) **term-relay data-plane (P1)** — `term-relay/data-plane/config.ts`: `BASE_DOMAIN`, `BIND_HOST`, `BIND_PORT` (browser WSS), `TLS_CERT_PATH`, `TLS_KEY_PATH`, `AGENT_BIND_PORT`, `AGENT_CA_CERT_PATH`, `AGENT_CA_CHAIN_PATH` (verify agent mTLS), `RELAY_NODE_ID`, `HEARTBEAT_INTERVAL_MS`, `INITIAL_WINDOW_BYTES`, `MAX_FRAME_BYTES`, `ROUTE_TTL_MS`. (Library — the process that reads this config and listens must be built, gap #1.) **relay-auth (P5)** — `RELAY_AUTH_VERIFY_PUBKEY` (base64url raw Ed25519) via `loadVerifyKeyFromEnv`; `RELAY_TRUST_DOMAIN` for SPIFFE. **agent (P2)** — `RELAY_URL`, `ENROLL_URL`, `HOST_ID`, `SUBDOMAIN`, `LOCAL_TARGET_URL`, `STATE_DIR`. --- ## 6. Infra checklist - [ ] **TLS**: browser-facing wildcard cert `*.term.` (Phase 2) or per-subdomain (Phase 1); separate CA + trust bundle for **agent mTLS** (`AGENT_CA_*` / `NODE_MTLS_TRUST_BUNDLE_PATH`). - [ ] **DNS**: wildcard `*.term.` → relay node(s); ``. - [ ] **Postgres**: accounts/hosts/sessions schema + migrations (adapter unbuilt). - [ ] **Redis**: `relay:revocations` pub/sub (P3 publishes, P1 subscribes) — the INV12 teardown path. - [ ] **KMS/HSM**: non-exportable CA intermediate signing key (`CA_INTERMEDIATE_KMS_KEY_REF`). - [ ] **Ingress for agents**: agents dial OUT (mTLS) — expose only `AGENT_BIND_PORT` + browser `BIND_PORT`. - [ ] **Orchestration**: Dockerfiles + compose/k8s; relay nodes are stateless (crash-safe, INV7). ## 7. Security/ops runbook (deploy invariants) - **Origin/CSWSH**: the browser WSS handshake MUST validate Origin against the tenant subdomain (relay-auth `onUpgrade` step 1 enforces it — pass the correct `allowedOrigins`). Never disable. - **Never expose without TLS**; the relay hands a shell to whoever authenticates. Prefer Tailscale for the agent path. - **Key custody (INV9)**: P5 signing key and the CA key never in process memory / logs; KMS refs only. - **Revocation latency (INV12)**: monitor time from `revoke()` → stream teardown; alert on budget miss. - **mTLS trust anchors**: agent certs verified against a **pinned** CA bundle — never a peer-supplied chain (relay-auth `verifyAgentCert` gates on the registry too). - **Audit sink (INV10)**: zero-payload; ship to append-only storage; alert on `deny` spikes / `cross-tenant-attempt`. - **Close before prod**: F2 principal wiring (gap #2) and F6 replay-epoch transport (gap #7). --- ## 8. TL;DR There is no one-command deploy today. Minimum to get a *running* relay = **Phase 0** (gaps #1, #2, #5, #6 with in-memory stores + self-signed TLS). Production = **all gaps** + Postgres/Redis/KMS/wildcard-TLS + orchestration. The security-critical *logic* is done and audited; what remains is integration + infra.