feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)
Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.
Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay: native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent: host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e: browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth: Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web: browser login + Web Crypto E2E + client-side preview rendering
Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.
NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
This commit is contained in:
848
docs/PLAN_RELAY_TRANSPORT.md
Normal file
848
docs/PLAN_RELAY_TRANSPORT.md
Normal file
@@ -0,0 +1,848 @@
|
||||
# P1 — Tunnel & Mux Protocol + Relay Data Plane — Implementation Plan
|
||||
|
||||
> **Plan of record for the byte hot-loop.** Owns the native WS multiplexing protocol
|
||||
> (frame codec, stream lifecycle, per-stream credit flow-control, 15s heartbeat, reconnection,
|
||||
> graceful drain) between the host-agent and the **stateless** relay data plane; TLS termination;
|
||||
> per-tenant **subdomain** routing (route by *authenticated session*, never the Host header);
|
||||
> **opaque CIPHERTEXT forwarding** (parses zero terminal semantics); and the **v0.8 frp-based MVP
|
||||
> scaffold** as an explicit stepping-stone toward the native mux.
|
||||
>
|
||||
> **Authoritative inputs** (do not reopen): [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md)
|
||||
> §0 LOCKED DECISIONS, §3 (control/data split, multiplexing, routing, drain), §5 (MVP shortcuts);
|
||||
> [`PLAN_RELAY_INDEX.md`](./PLAN_RELAY_INDEX.md) §3 (security invariants), §4 (**FROZEN SHARED
|
||||
> CONTRACTS** — cited verbatim below, never redefined), §5 (P1 charter).
|
||||
> Conventions follow [`PLAN.md`](./PLAN.md) / [`PLAN_VOICE_COMMANDS.md`](./PLAN_VOICE_COMMANDS.md):
|
||||
> stable task IDs in dependency waves, disjoint `Owns:` per task, function-signature-level contracts,
|
||||
> TDD (tests FIRST), explicit test cases incl. security/negative, per-task security notes.
|
||||
> `PROGRESS_LOG.md` is **orchestrator-only** (subagents return a ready-to-paste entry, G1).
|
||||
|
||||
---
|
||||
|
||||
## 0. Scope & non-scope
|
||||
|
||||
**In scope (this plan, nothing else):**
|
||||
- The **native WS mux** substrate: binary frame codec for the §4.1 frame format, per-stream state
|
||||
machine (`OPEN → (DATA | WINDOW_UPDATE)* → CLOSE`), **credit-based per-stream flow-control /
|
||||
backpressure** (WINDOW_UPDATE), 15s **PING/PONG heartbeat**, and **GOAWAY** graceful drain.
|
||||
- The **stateless relay data-plane node**: TLS termination for inbound browser `wss://`, holding
|
||||
outbound agent mux tunnels, the WS-upgrade authorization edge (Origin **retained** + capability
|
||||
token **required**), **per-tenant subdomain routing** resolved from the authenticated principal,
|
||||
and the **opaque byte splice** browser-WS ↔ mux-stream ↔ (agent's) `ws://127.0.0.1:3000`.
|
||||
- The **v0.8 frp scaffold**: `frps` config (wildcard vhost/subdomain routing) as the transport
|
||||
stepping-stone, contract-compatible with the native mux so the v0.9 swap reshapes nothing.
|
||||
|
||||
**Explicitly NOT in scope (owned by other plans — depend on their interfaces, never their files):**
|
||||
- Accounts / host & routing registries / pairing issuance / cert authority / metering → **P3 CONTROL PLANE**.
|
||||
- Ed25519 enrollment, agent dial-out client, `frpc` wrap, loopback forwarder, `npx …-agent pair` → **P2 AGENT**.
|
||||
- Capability-token issuance/signing, mTLS cert verify, deny-by-default authz model, CI cross-tenant
|
||||
tripwire, revocation, audit log, rate-limits → **P5 AUTH & ISOLATION**. (P1 *enforces* these at the
|
||||
edge via injected interfaces; it does not *own* their logic.)
|
||||
- E2E handshake + AEAD envelope → **P4 E2E** (rides P1 DATA frames as opaque ciphertext).
|
||||
- Login / dashboard / browser-side crypto / client-side preview → **P6 FRONTEND**.
|
||||
|
||||
**Base app unchanged:** `src/`, `public/`, `protocol.ts`, the session model — **byte-for-byte
|
||||
untouched**. P1 terminates at `ws://127.0.0.1:3000` where the agent (P2) is *just another local WS
|
||||
client*. The single base-app touch-point (`ALLOWED_ORIGINS` gaining the subdomain) is applied at
|
||||
**agent-install** time (P2), not by P1.
|
||||
|
||||
---
|
||||
|
||||
## 1. Security invariants this plan enforces
|
||||
|
||||
Per INDEX §3, P1 is a **primary owner** of **INV1, INV2, INV5, INV6, INV7, INV11, INV15** and
|
||||
contributes to **INV9, INV12, INV13** (transport carries them). Mapping to enforcement points:
|
||||
|
||||
| INV | Where P1 enforces it | Task |
|
||||
|---|---|---|
|
||||
| **INV1** cross-tenant isolation — A can never reach B | `authorizeUpgrade` (thin adapter, FIX 6a) resolves the subdomain → `requestedHostId` and **delegates the `token.host == hostId` gate to P5's `onUpgrade`/`onReattach`**; Host header is a hint only, never the authority. No code path resolves a host by raw addr/port/user hostname. | T8 (→P5), T9, T13 |
|
||||
| **INV2** relay handles only ciphertext | DATA payloads are copied **opaque**; the splice never decodes them; relay imports **no** xterm/ANSI parser. | T2, T10, T13 |
|
||||
| **INV5** no plaintext/secret at rest | Data plane holds **no** durable buffers; per-connection allocation only, **no global mutable buffer pool**; nothing written to disk/Redis by P1 except metadata via P3. | T10, T13 |
|
||||
| **INV6** deny-by-default authz on connect AND reattach | The deny-by-default **decision** is P5's (`onUpgrade`/`onReattach`, FIX 6a); `authorizeUpgrade` is a thin adapter that opens a stream **only** on P5's `{ok:true}` and passes every 401/403 through verbatim. Reattach flows the same P5 gate via `onReattach` (base-app `sessionId` re-authorized as a claim, not trusted). | T8 (→P5) |
|
||||
| **INV7** stateless data plane | Node holds only in-RAM tunnel/stream maps; a crash loses nothing (agent reconnects + re-registers via P3). No DB-of-record on the node. | T9, T10, T11, T13 |
|
||||
| **INV11** no terminal parsing in relay/agent | Static tripwire: relay/mux code has zero dependency on any terminal/ANSI parser; frames pass through opaque. | T2, T10, T13 |
|
||||
| **INV15** capability token required on WS upgrade | The adapter extracts the token via the **frozen §4.3 FIX-5 subprotocol/cookie carriers only** (never the query string) and forwards it to P5, which enforces Origin/CSWSH + token validity + `aud`/`rights`. The relay echoes only `APP_SUBPROTOCOL` (`term.relay.v1`), never the token entry. | T8 (→P5) |
|
||||
| **INV9** secrets validated at startup, never logged | `loadDataPlaneConfig` fails fast on missing TLS/CA/secret material; no secret ever logged. **Data-plane edge access logs MUST NOT include the raw upgrade `req.url`, the `Authorization` header, or the `Sec-WebSocket-Protocol` header value** (any of which can carry the capability token); log only `{subdomain, hostId, jti, decision, ts}`. | T1, T8, T10 |
|
||||
| **INV12** fast revocation | **T14 subscribes to the frozen §4.2 `relay:revocations` bus** (FIX 4) and maps a `KillSignal` → immediate §4.1 teardown: host/account scope → `drainHost(reason=revoked)` RST+close (grace forced to 0, ≤ `REVOCATION_PUSH_BUDGET_MS`), global → node-wide GOAWAY+RST. Three-tier levers: `closeStream(hostId, {jti})` (single-device, see OQ6), `closeTunnel`/`drainHost(revoked)` (whole host, immediate), `operatorDrain`/`shutdown` (grace only for clean migration). P5 **publishes**; every P1 node **subscribes** (P1 never imports P5). | T6, T9, T10, T11, **T14** |
|
||||
| **INV13** anti-replay/injection | P1 preserves per-message ordering and never reorders/dedups DATA (so P4's `seq`/nonce guarantees hold end-to-end); illegal frame transitions RST the stream. | T3, T6 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Files & ownership (new package `term-relay/`, data-plane subtree only)
|
||||
|
||||
P1 and P3 co-inhabit `term-relay/` but own **disjoint subtrees** (INDEX §0). P1 owns `term-relay/mux/`
|
||||
(the reusable protocol library, also imported by P2), `term-relay/data-plane/`, and
|
||||
`term-relay/frp-scaffold/`. **Types** for §4.1 live in the INDEX-frozen `relay-contracts/`; P1 imports
|
||||
them read-only and never redefines them. Files ≤ 400 lines typical, 800 hard max (`coding-style.md`).
|
||||
|
||||
| File | Action | Task | Phase |
|
||||
|---|---|---|---|
|
||||
| `term-relay/package.json`, `term-relay/tsconfig.json`, `term-relay/vitest.config.ts` | create — package skeleton (P1 seeds; P3 extends its own subtree) | T1 | v0.8 |
|
||||
| `term-relay/data-plane/config.ts` | create — env → `DataPlaneConfig` (Zod, fail-fast, INV9) | T1 | v0.8 |
|
||||
| `term-relay/mux/frame-codec.ts` | create — `encodeMuxFrame`/`decodeMuxFrame`/`decodeHeader` (§4.1) | T2 | spec v0.8 / live v0.9 |
|
||||
| `term-relay/mux/type-bytes.ts` | create — `MuxFrameType ⇄ byte` + `flags` bit maps (§4.1) | T2 | v0.9 |
|
||||
| `term-relay/mux/frame-guards.ts` | create — Zod boundary validation of OPEN/WINDOW_UPDATE/GOAWAY payloads (CBOR decode + `relay-contracts` schema) | T2 | v0.9 |
|
||||
| `term-relay/mux/stream.ts` | create — pure stream state machine (`nextStreamState`, transitions) | T3 | v0.9 |
|
||||
| `term-relay/mux/flow-control.ts` | create — credit-based `FlowController` (per-stream + link) | T4 | v0.9 |
|
||||
| `term-relay/mux/heartbeat.ts` | create — 15s PING/PONG liveness (injectable timer) | T5 | v0.9 |
|
||||
| `term-relay/mux/mux-session.ts` | create — multiplexer over one WS: demux, streams, flow, heartbeat, GOAWAY | T6 | v0.9 |
|
||||
| `term-relay/data-plane/subdomain-router.ts` | create — `extractSubdomain` + `RouteResolver` interface (impl = P3) | T7 | v0.9 |
|
||||
| `term-relay/data-plane/upgrade.ts` | create — `authorizeUpgrade` edge (Origin + capability token, INV1/6/15) | T8 | v0.9 |
|
||||
| `term-relay/data-plane/agent-listener.ts` | create — accept agent dial-out, mTLS-verify (P5 iface), build `MuxSession`, register route (P3 iface) | T9 | v0.9 |
|
||||
| `term-relay/data-plane/relay-node.ts` | create — stateless node: browser upgrade → openStream → **opaque splice** | T10 | v0.9 |
|
||||
| `term-relay/data-plane/drain.ts` | create — GOAWAY graceful drain + `closeTunnel` for revocation (INV7/INV12) | T11 | v0.9 |
|
||||
| `term-relay/data-plane/revocation-subscriber.ts` | create — subscribe `relay:revocations` (§4.2, FIX 4) → `KillSignal`→§4.1 CLOSE+RST/GOAWAY via T10/T11 (INV12) | T14 | v0.9 |
|
||||
| `term-relay/frp-scaffold/frps.toml` | create — wildcard-subdomain vhost config (v0.8 substrate) | T12 | v0.8 |
|
||||
| `term-relay/frp-scaffold/README.md` | create — VPS + wildcard DNS/TLS + `frps` runbook; retirement note | T12 | v0.8 |
|
||||
| `term-relay/frp-scaffold/plugin-hook.ts` | create — thin frp server-plugin HTTP shim → delegates authz to P3 (no tenancy logic here) | T12 | v0.8 |
|
||||
| `term-relay/test/**` (mux + data-plane + tripwire) | create — vitest unit/integration + INV tripwires | T2–T14 | per task |
|
||||
| `relay-contracts/**` | **read-only** (INDEX-frozen §4.1 types) — P1 imports, never edits | — | — |
|
||||
|
||||
**Freeze rule:** T2's codec + T3–T5 pure modules export a stable surface consumed by T6, then T6 by
|
||||
T9/T10; freeze each layer before the next consumes it (same discipline as PLAN_VOICE_COMMANDS §2).
|
||||
|
||||
---
|
||||
|
||||
## 3. Dependency waves
|
||||
|
||||
```
|
||||
W0 package skeleton + config T1
|
||||
│
|
||||
▼
|
||||
W1 pure protocol leaves (all parallel) T2 codec · T3 stream · T4 flow · T5 heartbeat
|
||||
│ (also parallel, separate lane) T12 frp scaffold ── v0.8, no dep on W1
|
||||
▼
|
||||
W2 mux session (assembles T2–T5) T6
|
||||
│
|
||||
▼
|
||||
W3 data-plane wiring (parallel where file-disjoint) T7 router · T8 upgrade(→P5) · T9 agent-listener
|
||||
│ T10 relay-node (dep T6,T8,T9) · T11 drain (dep T6,T9)
|
||||
│ T14 revocation-subscriber (dep T10,T11) ── FIX 4
|
||||
▼
|
||||
W4 invariant tripwires + integration T13
|
||||
```
|
||||
|
||||
**Cross-plan dependencies (interfaces, not files):**
|
||||
- **T8** (FIX 6a) **delegates the authorization decision to P5** → injected `Authorizer` (`onUpgrade`/`onReattach`,
|
||||
§6a) consuming P5's frozen `UpgradeContext`/`AuthzOutcome`/`DpopContext`. T8 no longer imports a
|
||||
`CapabilityVerifier`/`OriginCheck` — P5 owns Origin/CSWSH + token verify + DPoP + rate + burn + audit.
|
||||
- **T7/T8** depend on **P3**'s host registry read → injected `RouteResolver` (subdomain → `{hostId, accountId}`);
|
||||
T8 uses it only to name `requestedHostId` for the `UpgradeContext` (P5 makes the cross-tenant call).
|
||||
- **T9** depends on **P5**'s mTLS peer-cert verify → injected `MtlsVerifier`; and **P3**'s Redis routing
|
||||
table write → injected `RouteRegistrar` (`route:{host_id}` heartbeat-TTL, §4.2).
|
||||
- **T10** DATA path is contract-shared with **P2** (agent side of §4.1) and carries **P4**'s `E2EEnvelope`
|
||||
opaquely (§4.4) — P1 needs no P4 code (INV2).
|
||||
- **T11** GOAWAY drain is invoked by **P3** node-drain coordination (`operatorDrain`/`shutdown`, grace honored); whole-host `closeTunnel`/`drainHost(reason=revoked)` (immediate, grace forced to 0) and single-device `closeStream(hostId, {jti})` are the levers.
|
||||
- **T14** (FIX 4) **subscribes to the frozen §4.2 `relay:revocations` bus** — **P3/P5 publish** a `KillSignal`,
|
||||
every P1 node consumes it and drives T10/T11 teardown. The `KillSignal`/`RevocationScope`/channel constants
|
||||
live in `relay-contracts` (promoted out of `relay-auth`) so **P1 never imports P5** (DAG stays acyclic). P3
|
||||
OQ4's "control→node drain channel" reconciles to this exact channel.
|
||||
|
||||
In **v0.8**, the frp scaffold (T12) lets P2/P3 integrate against a real transport while T2–T14 are
|
||||
built; the v0.9 native-mux swap is contract-compatible by construction (both speak §4.1 routing +
|
||||
opaque byte forwarding), so nothing downstream reshapes.
|
||||
|
||||
---
|
||||
|
||||
## 4. Frozen shared contracts consumed (cited verbatim from INDEX §4 — NOT redefined)
|
||||
|
||||
P1 **imports** these from `relay-contracts/`; it must never re-declare them locally.
|
||||
|
||||
**§4.1 Mux frame (15-byte header)** — `version(1B)=0x01 · type(1B) · flags(1B: bit0 FIN, bit1 RST) ·
|
||||
streamId(4B uint32, 0=link-level) · payloadLen(8B uint64 ≤ maxFrameBytes) · payload(var)`. Types:
|
||||
`0x01 OPEN · 0x02 DATA · 0x03 CLOSE · 0x04 PING · 0x05 PONG · 0x06 WINDOW_UPDATE · 0x07 GOAWAY`.
|
||||
Lifecycle `OPEN → (DATA | WINDOW_UPDATE)* → CLOSE`; illegal transition ⇒ **RST that stream, never the
|
||||
tunnel**; `streamId` allocated by the **relay**, monotonic per tunnel, **never reused**.
|
||||
|
||||
```ts
|
||||
// from relay-contracts (§4.1) — imported, frozen:
|
||||
export type MuxFrameType = 'open' | 'data' | 'close' | 'ping' | 'pong' | 'windowUpdate' | 'goaway'
|
||||
export interface MuxFrameHeader {
|
||||
readonly version: 1; readonly type: MuxFrameType; readonly fin: boolean; readonly rst: boolean
|
||||
readonly streamId: number; readonly payloadLen: number
|
||||
}
|
||||
export interface MuxOpen {
|
||||
readonly streamId: number; readonly subdomain: string; readonly requestPath: string
|
||||
readonly originHeader: string; readonly remoteAddrHash: string; readonly capabilityTokenRef: string
|
||||
}
|
||||
export function encodeMuxFrame(h: MuxFrameHeader, payload: Uint8Array): Uint8Array
|
||||
export function decodeMuxFrame(buf: Uint8Array): { header: MuxFrameHeader; payload: Uint8Array }
|
||||
```
|
||||
|
||||
- **§4.3 CapabilityToken** — the token **shape** T8 references (but does **not** itself verify — verification
|
||||
is delegated to P5, FIX 6a): `sub` (principal, from authenticated session, **never client**), `aud`
|
||||
(subdomain, Host-confusion guard, INV1), `host` (exact `host_id`, single host, never wildcard),
|
||||
`rights: ('attach'|'manage'|'kill')[]`, `iat`/`exp` (short TTL), `jti` (revocation lookup). P1 extracts the
|
||||
raw token at the edge and hands it to P5's `onUpgrade`/`onReattach`, which calls `verifyCapabilityToken(raw,
|
||||
expectedAud, now)` internally — T8 never calls it directly.
|
||||
- **§4.3 WS-upgrade subprotocol wire format (FIX 5) — FROZEN, cited verbatim; T8's `extractCapabilityToken`
|
||||
MUST use these constants, never local literals**:
|
||||
```ts
|
||||
// from relay-contracts (§4.3) — imported, frozen. Client opens:
|
||||
// new WebSocket(url, [APP_SUBPROTOCOL, TOKEN_SUBPROTOCOL_PREFIX + base64url(rawToken)])
|
||||
export const APP_SUBPROTOCOL = 'term.relay.v1' as const // the app subprotocol — the ONLY value ever accepted/echoed
|
||||
export const TOKEN_SUBPROTOCOL_PREFIX = 'term.token.' as const // the token entry = this prefix + base64url(token)
|
||||
export function encodeTokenSubprotocol(rawToken: string): string // => 'term.token.' + base64url(rawToken)
|
||||
export function extractTokenFromSubprotocols(values: readonly string[]): string | null // strips prefix, base64url-decodes; null if absent
|
||||
```
|
||||
**Echo rule (MUST):** read the token from the `term.token.<b64u>` entry via `extractTokenFromSubprotocols`,
|
||||
strip it before handing to P5, and the accepted/echoed `Sec-WebSocket-Protocol` in the handshake response
|
||||
**MUST be `APP_SUBPROTOCOL` (`'term.relay.v1'`), NEVER the token entry**. Query-string token transport is
|
||||
forbidden. (The earlier local literals `'term.relay.v1'`/`'term.token.'` in this plan are now these frozen
|
||||
constants — no local redefinition.)
|
||||
- **§4.2 control→data-plane revocation teardown (FIX 4) — FROZEN, cited verbatim; consumed by T14**:
|
||||
```ts
|
||||
// from relay-contracts (§4.2) — imported, frozen. P3/P5 PUBLISH; every P1 node SUBSCRIBES.
|
||||
export type RevocationScope =
|
||||
| { readonly kind: 'host'; readonly hostId: string }
|
||||
| { readonly kind: 'account'; readonly accountId: string }
|
||||
| { readonly kind: 'global' }
|
||||
export interface KillSignal {
|
||||
readonly scope: RevocationScope
|
||||
readonly at: number // epoch seconds the revocation was issued
|
||||
readonly reason: string // metadata only, ZERO payload (INV10)
|
||||
}
|
||||
export interface RevocationBus { publish(signal: KillSignal): Promise<void> }
|
||||
export const RELAY_REVOCATIONS_CHANNEL = 'relay:revocations' as const
|
||||
export const REVOCATION_PUSH_BUDGET_MS = 2000 as const
|
||||
```
|
||||
P1 **subscribes** to `RELAY_REVOCATIONS_CHANNEL` and, per affected stream, injects a §4.1 `CLOSE`+`flags.RST`
|
||||
(host/account scope) or a link-level `GOAWAY` (global scope) using the **existing frozen §4.1 wire — no new
|
||||
frame type** — within `REVOCATION_PUSH_BUDGET_MS`. P5 stays socket-free (publishes only).
|
||||
- **§4.2 data model** — consumed read-only via `RouteResolver`/`RouteRegistrar`: `hosts.host_id`
|
||||
(unguessable UUIDv4, never recycled), `hosts.subdomain` (stable), `route:{host_id} → {relayNodeId,
|
||||
updatedAt}` (Redis heartbeat-TTL, INV7). P1 **reads** `hostId`/`accountId`; it **writes** only the
|
||||
routing entry through P3's registrar.
|
||||
- **§4.4 E2EEnvelope** — carried **inside** each DATA payload once E2E ships; P1 forwards it opaque and
|
||||
**never** derives keys or inspects `seq`/`nonce`/`ciphertext` (INV2). No P1 change at v0.10.
|
||||
|
||||
---
|
||||
|
||||
## 5. Task specifications
|
||||
|
||||
### W0
|
||||
|
||||
#### T1 · Package skeleton + data-plane config `[ ]` — v0.8
|
||||
- **Owns**: `term-relay/package.json`, `term-relay/tsconfig.json`, `term-relay/vitest.config.ts`,
|
||||
`term-relay/data-plane/config.ts`, `term-relay/test/config.test.ts`
|
||||
- **Depends**: `relay-contracts/` exists (INDEX W0). · **Parallel-safe**: with T12.
|
||||
- **Contract**:
|
||||
```ts
|
||||
export interface DataPlaneConfig {
|
||||
readonly baseDomain: string // e.g. 'term.example.com' — for extractSubdomain
|
||||
readonly bindHost: string // default '0.0.0.0'
|
||||
readonly bindPort: number // browser wss listener
|
||||
readonly agentBindPort: number // agent dial-out listener
|
||||
readonly tlsCertPath: string; readonly tlsKeyPath: string // relay's OWN server cert (browser-facing wss:)
|
||||
readonly agentCaCertPath: string // CA/trust-anchor that MUST sign the agent's client cert on agentBindPort (mTLS root)
|
||||
readonly agentCaChainPath: string // intermediate chain for agent-cert validation (rotation-safe; may equal agentCaCertPath if single-tier)
|
||||
readonly relayNodeId: string // stable node id written into route:{host_id}
|
||||
readonly maxFrameBytes: number // §4.1 payloadLen ceiling; default 1 MiB
|
||||
readonly initialWindowBytes: number // §4.1 credit initial window; default 256 KiB
|
||||
readonly heartbeatIntervalMs: number // default 15_000
|
||||
readonly routeTtlMs: number // Redis route TTL; default 45_000
|
||||
}
|
||||
export function loadDataPlaneConfig(env: NodeJS.ProcessEnv): DataPlaneConfig
|
||||
```
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `config.test.ts`: missing `TLS_CERT_PATH`/`TLS_KEY_PATH`/`BASE_DOMAIN` → `loadDataPlaneConfig` throws with a clear message (fail-fast, INV9); **missing `AGENT_CA_CERT_PATH` (the mTLS trust-anchor) → throws** — the agent-facing listener MUST NOT start without a CA to validate client certs against (else "mTLS" silently degrades to accept-any-cert, Finding-4 footgun); non-numeric `MAX_FRAME_BYTES` → throws; happy path returns typed frozen object; defaults applied.
|
||||
2. **GREEN** implement with a Zod schema (`z.coerce.number()` for ints, `z.string().min(1)` for paths). Return `Object.freeze(...)` (immutability). **Never log** cert/CA paths' contents; never log secrets.
|
||||
- **Security note (INV9)**: startup MUST fail if TLS server material, the `AGENT_CA_CERT_PATH` mTLS trust-anchor, or `BASE_DOMAIN` is absent; no secret value is ever emitted to logs. The CA-path presence check is the config-level guarantee that T9 can wire TLS-layer `requestCert: true`/`rejectUnauthorized: true` (no accept-any-cert path exists).
|
||||
- **Accept**: `npx vitest run config` green; `tsc --noEmit` clean.
|
||||
|
||||
### W1 — pure protocol leaves (all parallel, DOM-free, no `ws`/`pg`)
|
||||
|
||||
#### T2 · Mux frame codec `[ ]` — spec written v0.8, becomes substrate v0.9
|
||||
- **Owns**: `term-relay/mux/frame-codec.ts`, `term-relay/mux/type-bytes.ts`, `term-relay/mux/frame-guards.ts`, `term-relay/test/frame-codec.test.ts`
|
||||
- **Depends**: `relay-contracts` §4.1 types. · **Parallel-safe**: T3–T5, T12.
|
||||
- **Contract**:
|
||||
```ts
|
||||
export const MUX_HEADER_BYTES = 15
|
||||
export const MUX_VERSION = 1 as const
|
||||
export const TYPE_TO_BYTE: Readonly<Record<MuxFrameType, number>> // open→0x01 … goaway→0x07
|
||||
export const BYTE_TO_TYPE: ReadonlyMap<number, MuxFrameType>
|
||||
export function encodeMuxFrame(h: MuxFrameHeader, payload: Uint8Array): Uint8Array
|
||||
export function decodeHeader(buf: Uint8Array): MuxFrameHeader // header-only peek (15B)
|
||||
export function decodeMuxFrame(buf: Uint8Array): { header: MuxFrameHeader; payload: Uint8Array }
|
||||
// CBOR-encoded typed payloads, validated at the boundary (coding-style: validate untrusted data):
|
||||
export function encodeOpen(open: MuxOpen): Uint8Array
|
||||
export function decodeOpen(payload: Uint8Array): MuxOpen // Zod-guarded; throws on bad shape
|
||||
export function encodeWindowUpdate(credit: number): Uint8Array // uint32
|
||||
export function decodeWindowUpdate(payload: Uint8Array): number
|
||||
export function encodeGoaway(lastStreamId: number, reason: number): Uint8Array
|
||||
export function decodeGoaway(payload: Uint8Array): { lastStreamId: number; reason: number }
|
||||
```
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `frame-codec.test.ts` (round-trip + adversarial):
|
||||
- round-trip each type; `encode∘decode === identity` for header fields incl. `fin`/`rst` bit packing.
|
||||
- `streamId=0` link-level frames decode with `streamId===0`.
|
||||
- `payloadLen` uses uint64 big-endian; a value > `Number.MAX_SAFE_INTEGER` → **throws** (safe-int guard, §4.1).
|
||||
- **negative**: truncated buffer (< 15B) → throws; `payloadLen` ≠ actual payload length → throws; unknown `type` byte (0x00, 0x08) → throws; `version ≠ 0x01` → throws; `payloadLen > maxFrameBytes` caller-check hook (codec exposes the raw length; the ceiling is enforced by T6 with config).
|
||||
- `decodeOpen` rejects a CBOR payload missing `subdomain`/`host`-scope fields (Zod) — **fuzz malformed CBOR → throws, never returns partial** (INV boundary validation).
|
||||
2. **GREEN** implement with `DataView` for the fixed header (big-endian) and `cbor-x` (or a tiny CBOR) for typed payloads; Zod-validate decoded OPEN/WINDOW_UPDATE/GOAWAY.
|
||||
- **Security note (INV2/INV11)**: this module imports **no** terminal/ANSI parser; DATA payloads are handled as opaque `Uint8Array` and never inspected. `frame-guards.ts` validates *control* frames only (OPEN/WU/GOAWAY), never DATA content.
|
||||
- **Accept**: `npx vitest run frame-codec` green; static check (T13) confirms no xterm/ansi import.
|
||||
|
||||
#### T3 · Stream state machine `[ ]` — v0.9
|
||||
- **Owns**: `term-relay/mux/stream.ts`, `term-relay/test/stream.test.ts`
|
||||
- **Depends**: `relay-contracts` types. · **Parallel-safe**: T2/T4/T5.
|
||||
- **Contract** (pure reducer — no I/O, no mutation of inputs):
|
||||
```ts
|
||||
export type StreamState = 'idle' | 'open' | 'halfClosedLocal' | 'halfClosedRemote' | 'closed'
|
||||
export type StreamTransition = { readonly next: StreamState } | { readonly illegal: true }
|
||||
export function initialStreamState(): StreamState // 'idle'
|
||||
export function nextStreamState(
|
||||
current: StreamState, type: MuxFrameType, fin: boolean, dir: 'inbound' | 'outbound'
|
||||
): StreamTransition
|
||||
export function isTerminal(s: StreamState): boolean
|
||||
```
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `stream.test.ts`: legal path `idle --OPEN--> open --DATA*--> open --CLOSE--> closed`; `DATA` with `fin` → half-closed in the sending direction; both-side FIN → `closed`. **Illegal (→ `{illegal:true}`, caller RSTs the stream):** DATA before OPEN; DATA after CLOSE; OPEN on an already-open streamId; any frame after `closed`. Direction matters (inbound FIN vs outbound FIN produce different half-closed states).
|
||||
2. **GREEN** implement as a total function returning a **new** state (never mutate).
|
||||
- **Security note (INV13)**: illegal transitions are *rejected at the stream level* (RST one stream), never silently coerced — this is what lets P4's `seq` monotonicity mean something end-to-end.
|
||||
- **Accept**: `npx vitest run stream` green; function < 50 lines.
|
||||
|
||||
#### T4 · Credit-based flow control `[ ]` — v0.9
|
||||
- **Owns**: `term-relay/mux/flow-control.ts`, `term-relay/test/flow-control.test.ts`
|
||||
- **Depends**: none (pure). · **Parallel-safe**: T2/T3/T5.
|
||||
- **Contract**:
|
||||
```ts
|
||||
export const DEFAULT_INITIAL_WINDOW = 256 * 1024
|
||||
export const WINDOW_REPLENISH_THRESHOLD = 0.5 // emit WINDOW_UPDATE when half consumed
|
||||
export interface FlowController {
|
||||
registerStream(streamId: number, initialWindow: number): void
|
||||
releaseStream(streamId: number): void
|
||||
canSend(streamId: number, bytes: number): boolean // false ⇒ backpressure (sender blocks)
|
||||
consumeSendCredit(streamId: number, bytes: number): void // deduct on outbound DATA
|
||||
grantCredit(streamId: number, credit: number): void // apply inbound WINDOW_UPDATE
|
||||
creditFor(streamId: number): number
|
||||
onDelivered(streamId: number, bytes: number): number // returns credit to replenish (0 if < threshold)
|
||||
}
|
||||
export function createFlowController(): FlowController
|
||||
```
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `flow-control.test.ts`: `canSend` true until credit exhausted, then false; `consumeSendCredit` past window is rejected by `canSend` (never negative credit); `grantCredit` unblocks; `onDelivered` returns a replenish amount only once the threshold is crossed, else 0; unknown streamId → `canSend` false (deny-by-default), `creditFor` 0. Per-stream isolation: exhausting stream A's window does **not** affect stream B (**heavy `vim`/`top` redraw can't starve another stream**, §4.1).
|
||||
2. **GREEN** implement with a per-stream `Map<number, {window, sent, delivered}>`; immutable-style updates (replace the record).
|
||||
- **Security note**: unknown/released streams are deny-by-default (no credit) — prevents send-after-close write amplification.
|
||||
- **Accept**: `npx vitest run flow-control` green.
|
||||
|
||||
#### T5 · Heartbeat / liveness `[ ]` — v0.9
|
||||
- **Owns**: `term-relay/mux/heartbeat.ts`, `term-relay/test/heartbeat.test.ts`
|
||||
- **Depends**: none. · **Parallel-safe**: T2–T4.
|
||||
- **Contract** (timer injectable, per PLAN_VOICE_COMMANDS confirm-window pattern):
|
||||
```ts
|
||||
export const HEARTBEAT_INTERVAL_MS = 15_000 // §4.1: PING every 15s
|
||||
export const HEARTBEAT_MISS_LIMIT = 3 // 3 missed PONGs ⇒ dead (~45s)
|
||||
export interface HeartbeatDeps {
|
||||
sendPing(token: Uint8Array): void
|
||||
onDead(): void
|
||||
now?: () => number
|
||||
schedule?: (fn: () => void, ms: number) => { cancel(): void } // default setInterval-backed
|
||||
}
|
||||
export interface Heartbeat { start(): void; stop(): void; onPong(token: Uint8Array): void }
|
||||
export function createHeartbeat(deps: HeartbeatDeps): Heartbeat
|
||||
```
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** with a fake scheduler: after `start`, a PING is sent every interval with a fresh 8-byte token; a matching `onPong` resets the miss counter; **3 intervals with no PONG → `onDead` fires exactly once** and no further PINGs; a PONG with a stale/unknown token does **not** reset the counter (prevents a spoofed/duplicated PONG from masking a dead link); `stop` cancels cleanly (no `onDead` after stop).
|
||||
2. **GREEN** implement with the injected scheduler; token = random 8 bytes (crypto RNG).
|
||||
- **Security note (INV13-adjacent)**: PONG must echo the exact outstanding token; an unmatched token is ignored, so a replayed PONG can't keep a dead/hijacked tunnel "alive".
|
||||
- **Accept**: `npx vitest run heartbeat` green.
|
||||
|
||||
### W2
|
||||
|
||||
#### T6 · Mux session (multiplexer over one WS) `[ ]` — v0.9
|
||||
- **Owns**: `term-relay/mux/mux-session.ts`, `term-relay/test/mux-session.test.ts`
|
||||
- **Depends**: T2, T3, T4, T5. · **Parallel-safe**: with T7, T12.
|
||||
- **Contract**:
|
||||
```ts
|
||||
export interface MuxStreamHandle {
|
||||
readonly streamId: number
|
||||
writeData(payload: Uint8Array): boolean // false ⇒ backpressured (buffered until WINDOW_UPDATE)
|
||||
close(rst?: boolean): void
|
||||
}
|
||||
export interface MuxSessionDeps {
|
||||
role: 'relay' | 'agent'
|
||||
sendWire(frame: Uint8Array): void // write encoded frame to the underlying ws
|
||||
onOpen(open: MuxOpen, stream: MuxStreamHandle): void // relay: n/a (relay opens); agent: dial :3000
|
||||
onData(streamId: number, payload: Uint8Array): void // OPAQUE bytes (INV2) — never parsed
|
||||
onClose(streamId: number, rst: boolean): void
|
||||
onDead(): void
|
||||
maxFrameBytes: number
|
||||
initialWindowBytes: number
|
||||
}
|
||||
export interface MuxSession {
|
||||
openStream(open: MuxOpen): MuxStreamHandle // relay-side: allocates monotonic streamId
|
||||
onWire(buf: Uint8Array): void // feed inbound bytes: decode → dispatch
|
||||
drain(lastStreamId: number, reason: number): void // send GOAWAY (T11)
|
||||
close(): void
|
||||
}
|
||||
export function createMuxSession(deps: MuxSessionDeps): MuxSession
|
||||
```
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `mux-session.test.ts` (two `MuxSession`s wired mouth-to-mouth via each other's `sendWire`):
|
||||
- relay `openStream(open)` → agent's `onOpen` fires with a fresh monotonic `streamId`; `streamId`s never reused within the session.
|
||||
- `writeData` on relay → agent `onData` receives **byte-identical** payload; a known plaintext marker in the payload is delivered verbatim and **never** inspected by the session (INV2).
|
||||
- **flow control**: fill a stream's window → `writeData` returns `false`; a WINDOW_UPDATE from the peer resumes flushing buffered data; stream A backpressure does not block stream B.
|
||||
- **heartbeat**: no PONG for `HEARTBEAT_MISS_LIMIT` intervals → `onDead`.
|
||||
- **lifecycle enforcement**: an inbound DATA for an unknown/closed streamId → session emits a **CLOSE with RST for that stream only**, the tunnel stays up (T3 illegal transition).
|
||||
- **frame ceiling**: an inbound frame with `payloadLen > maxFrameBytes` → RST that stream (or GOAWAY if link-level), never OOM.
|
||||
2. **GREEN** implement: incremental wire parser (buffer until a full 15B header + payload), `Map<streamId, streamCtx>` with T3 state + T4 credit, T5 heartbeat on streamId 0, GOAWAY handling.
|
||||
- **Security note (INV2/INV5/INV11)**: DATA is copied opaque; buffers are **per-stream, per-session** (no global pool), freed on CLOSE — no cross-tenant buffer bleed possible. The session imports no terminal parser.
|
||||
- **Accept**: `npx vitest run mux-session` green; file ≤ 400 lines (extract helpers if larger).
|
||||
|
||||
### W3 — data-plane wiring
|
||||
|
||||
#### T7 · Subdomain router `[ ]` — v0.9
|
||||
- **Owns**: `term-relay/data-plane/subdomain-router.ts`, `term-relay/test/subdomain-router.test.ts`
|
||||
- **Depends**: T1 (config). · **Parallel-safe**: T8, T9, T11, T12.
|
||||
- **Contract**:
|
||||
```ts
|
||||
// RouteResolver is IMPLEMENTED BY P3 (control plane); P1 owns only the interface it depends on.
|
||||
export interface ResolvedHost { readonly hostId: string; readonly accountId: string; readonly subdomain: string }
|
||||
export interface RouteResolver { resolveSubdomain(subdomain: string): Promise<ResolvedHost | null> }
|
||||
export function extractSubdomain(hostHeader: string, baseDomain: string): string | null
|
||||
```
|
||||
- **Steps (TDD)**:
|
||||
1. **RED**: `extractSubdomain('alice.term.example.com', 'term.example.com')` → `'alice'`; a bare `baseDomain` → `null`; a **wildcard/confusion attempt** `'alice.term.example.com.evil.com'` → `null`; multi-label subdomain `'a.b.term.example.com'` → `null` (reject nested; single-label tenant only, INV1 Host-confusion guard); case-insensitive host; trailing dot tolerated.
|
||||
2. **GREEN** implement a strict suffix match returning the single leftmost label only.
|
||||
- **Security note (INV1)**: `extractSubdomain` is a **hint** for candidate lookup; it is **never** the authority — T8 requires the capability token's `aud` to equal this subdomain and `token.host` to equal the resolver's `hostId`. Nested/confusing Host headers resolve to `null` (reject).
|
||||
- **Accept**: `npx vitest run subdomain-router` green.
|
||||
|
||||
#### T8 · Upgrade authorization edge — **THIN ADAPTER over P5** `[ ]` — v0.9 **(INV1/INV6/INV15 surface)**
|
||||
> **FIX 6a — single-owner authorization.** The authorization **decision** (Origin/CSWSH + capability-token
|
||||
> verify + DPoP proof-of-possession + single-use `jti` burn + pre-auth throttle + step-up + per-tenant rate +
|
||||
> deny-by-default cross-tenant gate + audit) has **exactly one owner: P5** (`relay-auth/src/enforce/onUpgrade.ts`
|
||||
> / `onReattach.ts`). **`authorizeUpgrade` holds NO independent authz logic** — it only (a) parses the upgrade
|
||||
> (subdomain + FIX-5 token extraction + remoteAddr hash), (b) resolves the subdomain → `requestedHostId` via P3's
|
||||
> `RouteResolver`, (c) builds P5's frozen `UpgradeContext` (incl. `dpop: DpopContext` + `activeSessionCount`),
|
||||
> (d) calls the injected P5 `Authorizer`, and (e) maps the returned `AuthzOutcome` → a §4.1 `MuxOpen` (or a
|
||||
> 401/403 close). Reattach flows the same gate via `onReattach` (base-app `sessionId` re-authorized as a claim).
|
||||
- **Owns**: `term-relay/data-plane/upgrade.ts`, `term-relay/test/upgrade.test.ts`
|
||||
- **Depends**: T2 (MuxOpen), T7 (resolver). **Cross-plan**: **P5 `Authorizer` = `onUpgrade`/`onReattach` + `UpgradeContext`/`AuthzOutcome`/`DpopContext` (§6a, imported read-only)**, P3 `RouteResolver`. · **Parallel-safe**: T9, T11.
|
||||
- **Contract** (all P5 types imported from `relay-auth`, never redefined here):
|
||||
```ts
|
||||
import { APP_SUBPROTOCOL, extractTokenFromSubprotocols } from 'relay-contracts' // §4.3 FIX 5, frozen
|
||||
import type { UpgradeContext, AuthzOutcome, DpopContext } from 'relay-auth' // §6a, P5-owned, frozen
|
||||
|
||||
// P5 is the sole authorizer. P1 injects it; P1 supplies the ctx, P5 makes the decision.
|
||||
export interface Authorizer {
|
||||
onUpgrade(ctx: UpgradeContext, now: number): Promise<AuthzOutcome>
|
||||
onReattach(ctx: UpgradeContext & { sessionId: string }, now: number): Promise<AuthzOutcome>
|
||||
}
|
||||
export interface UpgradeRequest {
|
||||
readonly host: string; readonly origin: string | undefined
|
||||
readonly url: string // requestPath ONLY (opaque passthrough) — NEVER a token source
|
||||
readonly subprotocols: readonly string[] // parsed Sec-WebSocket-Protocol values (FIX-5 token transport)
|
||||
readonly cookies: Readonly<Record<string, string>> // parsed Cookie header (fallback token transport)
|
||||
readonly remoteAddr: string
|
||||
readonly dpop: DpopContext // FIX 6a: proof-of-possession material P5 needs (passed through, not inspected)
|
||||
readonly activeSessionCount: number // FIX 6a: supplied for P5's per-tenant/step-up policy
|
||||
readonly sessionId?: string // present ⇒ reattach path (→ onReattach); absent ⇒ onUpgrade
|
||||
}
|
||||
export type UpgradeDecision =
|
||||
| { readonly ok: true; readonly open: MuxOpen; readonly hostId: string; readonly acceptedSubprotocol: string }
|
||||
| { readonly ok: false; readonly status: 401 | 403 }
|
||||
// Token transport is FIXED, not free-form (FIX 5). Extract ONLY from the two allowed carriers via the
|
||||
// frozen §4.3 helper, NEVER from req.url query. Returns the opaque token string, or null if absent.
|
||||
export function extractCapabilityToken(req: UpgradeRequest): { token: string; via: 'subprotocol' | 'cookie' } | null
|
||||
export function authorizeUpgrade(req: UpgradeRequest, deps: {
|
||||
authorizer: Authorizer; resolver: RouteResolver
|
||||
baseDomain: string; now: () => number; remoteAddrSalt: string
|
||||
requiredRight: CapabilityRight // 'attach' for a session upgrade
|
||||
}): Promise<UpgradeDecision>
|
||||
```
|
||||
- **Capability-token transport (FIX 5 — frozen §4.3, cited verbatim, INV15).** The browser's native `WebSocket`
|
||||
API cannot set arbitrary request headers, so the token rides one of exactly **two** carriers, in this order:
|
||||
1. **`Sec-WebSocket-Protocol` subprotocol (PREFERRED).** The client opens
|
||||
`new WebSocket(url, [APP_SUBPROTOCOL, encodeTokenSubprotocol(rawToken)])` — i.e.
|
||||
`['term.relay.v1', 'term.token.' + base64url(token)]`. `extractCapabilityToken` reads the token via the
|
||||
frozen `extractTokenFromSubprotocols(req.subprotocols)` (strips `TOKEN_SUBPROTOCOL_PREFIX`, base64url-decodes).
|
||||
The relay **MUST echo `acceptedSubprotocol = APP_SUBPROTOCOL` (`'term.relay.v1'`)** in the handshake response
|
||||
— **never** the `term.token.<b64u>` entry (echoing the token leaks the bearer secret). `UpgradeDecision.ok`
|
||||
therefore fixes `acceptedSubprotocol: 'term.relay.v1'` (non-nullable — the app subprotocol is always echoed).
|
||||
2. **Short-lived `HttpOnly; Secure; SameSite=Strict` cookie (FALLBACK).** Set by a prior authenticated
|
||||
HTTPS call (P6), scoped to the tenant subdomain, read from `req.cookies`. `HttpOnly` keeps it out of
|
||||
JS/`Referer`; `Secure` keeps it off plaintext; `SameSite=Strict` complements the retained Origin/CSWSH check.
|
||||
- **FORBIDDEN: the query string.** The token is a bearer-equivalent secret; `authorizeUpgrade` and
|
||||
`extractCapabilityToken` MUST NOT read any token from `req.url`. `req.url` carries **only** `requestPath`
|
||||
(opaque `?join=<id>` passthrough, never parsed for authz). Query-string tokens leak verbatim into
|
||||
reverse-proxy/CDN/frp access logs, browser history, and `Referer` headers.
|
||||
- **Adapter logic (NO local authz decision — parse, delegate, map; INV6 deny-by-default lives in P5)**:
|
||||
```
|
||||
subdomain = extractSubdomain(req.host, baseDomain); if !subdomain → 401 (unparseable Host, local)
|
||||
extracted = extractCapabilityToken(req); // FIX-5 carriers only; NEVER req.url query
|
||||
if !extracted → 401 (no token carrier present, local)
|
||||
resolved = await resolver.resolveSubdomain(subdomain); if !resolved → 403 (unknown subdomain, local)
|
||||
ctx: UpgradeContext = {
|
||||
capabilityRaw: extracted.token, originHeader: req.origin ?? '', expectedAud: subdomain,
|
||||
requestedHostId: resolved.hostId, // P1 NAMES the host it resolved; P5 gates token.host against it (INV1)
|
||||
requiredRight, remoteAddrHash: hash(remoteAddrSalt, req.remoteAddr),
|
||||
activeSessionCount: req.activeSessionCount, dpop: req.dpop, principal: null /* P5 resolves from token */,
|
||||
}
|
||||
outcome = req.sessionId
|
||||
? await authorizer.onReattach({ ...ctx, sessionId: req.sessionId }, now()) // P5 owns the decision
|
||||
: await authorizer.onUpgrade(ctx, now())
|
||||
if !outcome.ok → { ok:false, status: outcome.status }
|
||||
open = { streamId: 0 /*allocated by MuxSession*/, subdomain, requestPath: pathOf(req.url),
|
||||
originHeader: req.origin ?? '', remoteAddrHash: ctx.remoteAddrHash,
|
||||
capabilityTokenRef: outcome.jti /* OQ5 RESOLVED — P5 §6a AuthzOutcome.ok now carries the verified jti */ }
|
||||
return { ok:true, open, hostId: outcome.hostId, acceptedSubprotocol: APP_SUBPROTOCOL }
|
||||
```
|
||||
**`account_id`/`host_id` are derived by P5 from the signed token + registry — NEVER from client query/body/header (INV3).** The adapter passes `requestedHostId` (resolved from the subdomain), the raw token, Origin, and DPoP material to P5; **every accept/reject verdict is P5's**. `requestPath` is opaque passthrough the relay never parses for authz.
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `upgrade.test.ts` (mock **P5 `authorizer`** + `resolver` — assert delegation, not local re-decision):
|
||||
- **delegation:** a happy `onUpgrade` (returns `{ok:true, hostId, principal, jti}`) → `{ok:true}`, `open.hostId === outcome.hostId`, `open.subdomain === 'alice'`, `acceptedSubprotocol === APP_SUBPROTOCOL`; assert `authorizer.onUpgrade` was called **exactly once** with a `UpgradeContext` carrying `expectedAud==='alice'`, `requestedHostId===resolved.hostId`, `requiredRight`, `dpop`, and `activeSessionCount` (proves ctx is fully populated).
|
||||
- **no local authz:** when P5 returns `{ok:false, status:401}` (foreign Origin / bad token / expired / aud-mismatch — all decided in P5), the adapter returns that status **verbatim** and opens **no** stream; the adapter contains no Origin/token/rights/cross-tenant branch of its own (assert by construction + coverage: every reject status originates from the `authorizer` mock).
|
||||
- **reattach:** `req.sessionId` present → `authorizer.onReattach` is called (not `onUpgrade`) with `{...ctx, sessionId}`; its `{ok:false}` maps straight through.
|
||||
- **token transport (FIX 5/INV15):** a token in `Sec-WebSocket-Protocol` (`['term.relay.v1','term.token.<b64u>']`) is extracted via `extractTokenFromSubprotocols` and forwarded as `ctx.capabilityRaw`, and `acceptedSubprotocol === 'term.relay.v1'` (the `term.token.` entry is **never** echoed); a token in `cookies['term_cap']` is accepted; **a token placed ONLY in the query string (`req.url = '/term?cap=<token>'`) → 401 and `authorizer.onUpgrade` is NEVER called** (spy count 0) — `extractCapabilityToken` never reads `req.url`, so no ctx is even built.
|
||||
- **cross-tenant is P5's (INV1):** the adapter forwards `requestedHostId = resolved.hostId` and the raw token; the 403 for `token.host !== hostId` is asserted at P5's `onUpgrade` (its own test), **not** re-implemented here — T8 only asserts the status is passed through.
|
||||
- **local parse failures only:** unparseable Host → 401 without calling the authorizer; unknown subdomain (`resolver` null) → 403 without calling the authorizer (nothing to authorize).
|
||||
2. **GREEN** implement thin: parse → resolve → build ctx → `await authorizer.onUpgrade/onReattach` → map. `hash` = HMAC-SHA256(salt, ip) truncated (audit-only, INV10). No decision branches beyond the three local parse guards above.
|
||||
- **Security note**: FIX 6a makes this the **adapter**, not the gate — the "single hardest gate in P1" is now P5's `onUpgrade`/`onReattach`, so the decision cannot drift between two implementations. The adapter still enforces the FIX-5 transport (token only via subprotocol/cookie, never query; echo only `APP_SUBPROTOCOL`) and derives identity only from the signed token + registry via P5 (INV3). **Logging discipline (INV9):** the edge MUST NOT log `req.url`, the `Authorization` header, or the `Sec-WebSocket-Protocol` value (each can carry the bearer-equivalent token); it logs only `{subdomain, hostId, jti, decision, ts}`. P5 owns the *permanent CI cross-tenant tripwire*; T13 ships P1's data-plane-level assertion that the adapter never overrides a P5 deny.
|
||||
- **Accept**: `npx vitest run upgrade` green; adapter has **zero** authz branches (delegation-only), verified by the "every reject originates from the authorizer mock" assertion.
|
||||
|
||||
#### T9 · Agent tunnel listener `[ ]` — v0.9 **(INV4/INV7/INV12/INV14 surface)**
|
||||
- **Owns**: `term-relay/data-plane/agent-listener.ts`, `term-relay/test/agent-listener.test.ts`
|
||||
- **Depends**: T6 (MuxSession), T1 (config). **Cross-plan**: P5 `MtlsVerifier` (§ INV14), P3 `RouteRegistrar` (§4.2 `route:{host_id}`). · **Parallel-safe**: T8, T11.
|
||||
- **Contract**:
|
||||
```ts
|
||||
export interface MtlsVerifier { // P5 impl — verifies the peer cert against the host registry pubkey
|
||||
verifyPeer(peerCert: Uint8Array): { hostId: string; accountId: string } | null
|
||||
}
|
||||
export interface RouteRegistrar { // P3 impl — Redis route:{host_id} with heartbeat TTL (INV7)
|
||||
register(hostId: string, relayNodeId: string, ttlMs: number): Promise<void>
|
||||
heartbeat(hostId: string): Promise<void>
|
||||
deregister(hostId: string): Promise<void>
|
||||
}
|
||||
export interface AgentTunnel { readonly hostId: string; readonly session: MuxSession; closeTunnel(): void }
|
||||
export function createAgentListener(deps: {
|
||||
config: DataPlaneConfig; mtls: MtlsVerifier; registrar: RouteRegistrar
|
||||
onTunnel(t: AgentTunnel): void
|
||||
}): { attach(ws: WebSocketLike, peerCert: Uint8Array): void; tunnels(): ReadonlyMap<string, AgentTunnel> }
|
||||
```
|
||||
- **TLS-layer contract (two-tier defense, Finding-4):** the agent-facing TLS server on `agentBindPort`
|
||||
MUST be constructed with **`requestCert: true` AND `rejectUnauthorized: true`**, `ca` loaded from
|
||||
`config.agentCaCertPath` (+ `agentCaChainPath`). This makes the TLS handshake itself reject any
|
||||
connection that presents **no** client cert or a cert not chaining to our CA — **before** `attach()`
|
||||
or `verifyPeer()` is ever called. `verifyPeer` then runs only on a cert that has **already passed
|
||||
CA-chain validation**, and does the app-layer bind (cert pubkey → registry `agent_pubkey`, INV14/INV4).
|
||||
This is the explicit guard against the "send-any-cert, we check it ourselves after the fact" downgrade:
|
||||
presentation is enforced at the TLS layer, identity binding at the app layer — never one without the other.
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `agent-listener.test.ts` (mock ws + mtls + registrar):
|
||||
- valid peer cert → `MuxSession` created, `registrar.register(hostId, nodeId, routeTtlMs)` called, tunnel indexed by `hostId`.
|
||||
- **TLS-layer enforcement (Finding-4):** a TLS handshake with **no client cert presented** is rejected at the TLS layer (`rejectUnauthorized`) — `attach()`/`verifyPeer()` are **never** invoked; assert the `attach` spy count is 0. Likewise a cert not chaining to `config.agentCaCertPath` → TLS reject, `attach` never called. Assert the TLS server options passed to the server factory are `{ requestCert: true, rejectUnauthorized: true, ca: <from agentCaCertPath/agentCaChainPath> }`.
|
||||
- **invalid/expired cert (`verifyPeer` → null) → the ws is closed, NO route registered** (INV14 — CA never binds an unknown pubkey; no shared secret path, INV4). (This is the app-layer check that runs *after* a cert has already passed CA-chain validation, e.g. cert chains to our CA but its pubkey is not in the host registry.)
|
||||
- heartbeat: every `heartbeatIntervalMs`, `registrar.heartbeat(hostId)` is called; on `onDead`/ws-close, `registrar.deregister(hostId)` runs and the tunnel is removed (**stateless — nothing durable survives**, INV7).
|
||||
- **revocation (INV12):** `closeTunnel()` tears down the ws + deregisters within the test tick.
|
||||
- reconnection: a second `attach` for the same `hostId` (agent reconnected on this node) replaces the prior tunnel and re-registers (latest wins; old one deregistered).
|
||||
2. **GREEN** implement; the node holds only an in-RAM `Map<hostId, AgentTunnel>` — **no DB of record** (INV7). Build the TLS server with `requestCert: true`, `rejectUnauthorized: true`, `ca` from the config CA paths; `attach` is only ever reached for a TLS-validated peer cert.
|
||||
- **Security note (INV4/INV7/INV12/INV14)**: agent auth is **mTLS only**, enforced at **two tiers** — TLS-layer `requestCert`/`rejectUnauthorized` against `config.agentCaCertPath` guarantees a cert was presented *and* chains to our CA, then P5's `verifyPeer` binds that cert's pubkey to the registry `agent_pubkey`. No bearer/shared secret is ever accepted here; there is no code path that reaches `attach()` for an unauthenticated or no-cert peer. A node crash loses only the in-RAM map; agents reconnect and re-register (P3). `closeTunnel` is the revocation lever P5/P3 call to drop a live tunnel in seconds.
|
||||
- **Accept**: `npx vitest run agent-listener` green.
|
||||
|
||||
#### T10 · Stateless relay node + opaque splice `[ ]` — v0.9 **(INV2/INV5/INV7/INV11 core)**
|
||||
- **Owns**: `term-relay/data-plane/relay-node.ts`, `term-relay/test/relay-node.test.ts`
|
||||
- **Depends**: T6, T8, T9, T1. · **Parallel-safe**: T11 (disjoint file).
|
||||
- **Contract**:
|
||||
```ts
|
||||
export interface RelayNode {
|
||||
handleAgentTunnel(ws: WebSocketLike, peerCert: Uint8Array): void
|
||||
handleBrowserUpgrade(req: UpgradeRequest, ws: WebSocketLike): Promise<void> // authorize → openStream → splice
|
||||
drain(reason: number): Promise<void> // DRAIN_REASON value; delegates to T11
|
||||
closeTunnel(hostId: string): void // WHOLE-host revocation lever (P5/P3) — kicks every device
|
||||
// SINGLE-device revocation (Finding-3): RST exactly the stream(s) opened under one capability token,
|
||||
// leaving every other device on the same host attached. Driven by P5's revoked:{jti} list (§4.2).
|
||||
// Returns the number of streams RST (0 if none matched — deny-by-default: unknown selector is a no-op).
|
||||
closeStream(hostId: string, selector: { readonly jti: string } | { readonly streamId: number }): number
|
||||
}
|
||||
export function createRelayNode(deps: {
|
||||
config: DataPlaneConfig; listener: ReturnType<typeof createAgentListener>
|
||||
authorize: (req: UpgradeRequest) => Promise<UpgradeDecision> // = authorizeUpgrade bound to deps
|
||||
}): RelayNode
|
||||
```
|
||||
- **Splice (the byte hot-loop) — OPAQUE, zero parsing**:
|
||||
```
|
||||
decision = await authorize(req); if !decision.ok → ws.close(decision.status) (INV6/INV15)
|
||||
tunnel = listener.tunnels().get(decision.hostId); if !tunnel → ws.close(1013 /*try later*/)
|
||||
stream = tunnel.session.openStream(decision.open) // relay allocates streamId
|
||||
splices.add({ hostId: decision.hostId, streamId: stream.streamId, // per-jti index for closeStream (Finding-3)
|
||||
jti: decision.open.capabilityTokenRef, ws, stream })
|
||||
ws.onMessage(bytes => stream.writeData(bytes)) // browser → agent : verbatim Uint8Array (INV2)
|
||||
stream via session.onData(id, bytes) → ws.send(bytes) // agent → browser : verbatim (INV2)
|
||||
ws.onClose(() => { stream.close(); splices.remove(...) })
|
||||
stream.onClose(() => { ws.close(); splices.remove(...) })
|
||||
```
|
||||
Buffers are **per-connection** (each `ws`↔`stream` pair) — **no global mutable buffer pool** (INV5, kills cross-tenant buffer bleed). The node never decodes DATA (INV2/INV11): a byte is a byte.
|
||||
- **`closeStream` (single-device revocation, Finding-3/INV12)**: the node maintains an in-RAM splice
|
||||
index (`splices`) mapping `hostId → jti → {streamId, ws, stream}` (built at splice time above, torn down
|
||||
on close — still **stateless**, in-RAM only, INV7). `closeStream(hostId, {jti})` looks up every splice
|
||||
for that host+`jti`, calls `stream.close(/*rst*/ true)` (RSTs just those streams via the mux, tunnel
|
||||
stays up) and `ws.close(4403)` on each, removes them from the index, and returns the count. `{streamId}`
|
||||
is the same by exact stream. An unmatched selector RSTs nothing and returns 0 (deny-by-default no-op —
|
||||
never falls back to a broader kill). This is the primitive P5 calls when it adds a `jti` to
|
||||
`revoked:{jti}` (§4.2) to revoke **one shared device** while the legitimate owner's other devices
|
||||
(different `jti`s, latest-writer-wins sharing) stay attached.
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `relay-node.test.ts` (in-memory ws pairs + a fake agent MuxSession):
|
||||
- browser upgrade authorized → a stream opens to the correct `hostId`'s tunnel; bytes typed at the browser arrive **byte-identical** at the agent side and vice-versa.
|
||||
- **INV2 ciphertext marker:** push a unique marker through; assert the relay-node code never inspects it and the marker is not retained in any node-level structure after CLOSE.
|
||||
- unauthorized upgrade (decision 401/403) → ws closed with the status; **no** stream opened, **no** tunnel touched.
|
||||
- no tunnel for the host (agent offline) → ws closed (1013), no crash.
|
||||
- **INV7 crash-survival:** drop the node's in-RAM maps (simulate crash) → no persisted state; a fresh node with a reconnected tunnel serves new upgrades; in-flight browser ws just reconnect (base-app backoff).
|
||||
- **INV1 isolation at the node:** an upgrade authorized for `hostA` can only ever reach `hostA`'s tunnel — there is no API to reach a tunnel by index/address; lookup is strictly `decision.hostId` (from the token, INV3).
|
||||
- **single-device revocation (Finding-3/INV12):** open two browser splices on the same host under two different `jti`s (`jtiA`, `jtiB` — the base-app multi-device sharing case); `closeStream(hostId, {jti: jtiA})` RSTs **only** `jtiA`'s stream and closes its ws (code 4403), returns `1`, while `jtiB`'s splice keeps flowing bytes bidirectionally (the legitimate owner's other device stays attached). Two splices under the **same** `jti` → both RST, returns `2`. `closeStream(hostId, {jti: 'unknown'})` → returns `0`, RSTs nothing (deny-by-default no-op, never widens to a whole-host kill). After a ws/stream closes normally, its splice-index entry is gone so a later `closeStream` for its `jti` returns `0` (no stale handle).
|
||||
2. **GREEN** implement thin; keep the file ≤ 300 lines (extract the splice helper / splice-index into a small module if needed). The splice index is in-RAM only (INV7).
|
||||
- **Security note (INV2/INV5/INV7/INV11/INV12)**: the node is a **ciphertext-shuttle** — opaque splice, per-connection buffers, no durable state, no terminal parser. `closeStream` gives P5 a **surgical** revocation lever (RST exactly the streams under one revoked `jti`) so revoking a single compromised/shared device does **not** kick the owner's other devices — removing the "nuke everyone or nothing" false choice that would otherwise delay real revocation. This is the load-bearing "byte-shuttle → ciphertext-shuttle" upgrade.
|
||||
- **Accept**: `npx vitest run relay-node` green.
|
||||
|
||||
#### T11 · Graceful drain + GOAWAY `[ ]` — v0.9 **(INV7/INV12)**
|
||||
- **Owns**: `term-relay/data-plane/drain.ts`, `term-relay/test/drain.test.ts`
|
||||
- **Depends**: T6 (GOAWAY), T9 (tunnels). · **Parallel-safe**: T10 (disjoint file).
|
||||
- **Contract**:
|
||||
```ts
|
||||
export const DRAIN_REASON = { operatorDrain: 1, revoked: 2, shutdown: 3 } as const
|
||||
export type DrainReason = typeof DRAIN_REASON[keyof typeof DRAIN_REASON]
|
||||
export function drainNode(deps: {
|
||||
tunnels(): ReadonlyMap<string, AgentTunnel>
|
||||
registrar: RouteRegistrar
|
||||
reason: DrainReason
|
||||
inFlightGraceMs: number // HONORED ONLY for operatorDrain/shutdown; FORCED to 0 for revoked (Finding-2)
|
||||
}): Promise<void>
|
||||
export function drainHost(hostId: string, deps: {
|
||||
tunnels(): ReadonlyMap<string, AgentTunnel>
|
||||
registrar: RouteRegistrar
|
||||
reason: DrainReason
|
||||
inFlightGraceMs: number // HONORED ONLY for operatorDrain/shutdown; FORCED to 0 for revoked
|
||||
}): Promise<void> // single-host (revocation / operator drain)
|
||||
```
|
||||
- **Grace-window rule (reason-differentiated, Finding-2/INV12):** the effective grace is
|
||||
`effectiveGraceMs = (reason === DRAIN_REASON.revoked) ? 0 : inFlightGraceMs`. A nonzero
|
||||
`inFlightGraceMs` is a *convenience for graceful operator drains/shutdowns only* (let an in-flight
|
||||
vim/paste flush before moving the host to another node). **`reason=revoked` is a security action against
|
||||
a compromised/malicious host or device** — it MUST force immediate teardown regardless of any caller-
|
||||
supplied `inFlightGraceMs`: RST every stream now, close the tunnel now, `registrar.deregister` now. This
|
||||
is what makes INV12's testable assertion ("its live tunnel drops within seconds") hold even when the
|
||||
operator-drain grace is tuned to tens of seconds.
|
||||
- **Steps (TDD)**:
|
||||
1. **RED**:
|
||||
- `drainNode` with `reason=operatorDrain` sends **GOAWAY(lastStreamId, reason)** on streamId 0 to every tunnel; existing streams are allowed to finish within `inFlightGraceMs`, then tunnels close and routes are deregistered (P3). New `openStream` after GOAWAY is refused.
|
||||
- **`drainHost(hostId, {reason: DRAIN_REASON.revoked, inFlightGraceMs: N})` tears down all of that host's streams IMMEDIATELY regardless of N** (assert with a large N, e.g. 30_000): every stream is RST, the tunnel closes, and `registrar.deregister(hostId)` runs within the test tick — the grace window is **never** waited on. Same for `drainNode` with `reason=revoked` across all tunnels.
|
||||
- `drainHost` targets exactly one host and leaves siblings running (whole-host revocation, INV12).
|
||||
2. **GREEN** implement: compute `effectiveGraceMs` from `reason` before scheduling teardown; deregister via `registrar.deregister` so the ingress stops routing to this node/host.
|
||||
- **Security note (INV12)**: `drainHost` is the whole-host revocation path — for `reason=revoked` it is GOAWAY + **immediate** RST + close + deregister (zero grace), dropping a compromised host's live tunnel within seconds; `operatorDrain`/`shutdown` keep a grace window purely for clean migration. **The customer's PTY survives** on their machine (base-app PTY≠WS decoupling); a relay-node bounce is invisible to the running Claude Code task (EXPLORE §3). For revoking **one shared device without kicking the others**, use T10's `RelayNode.closeStream` (per-`jti`), not a whole-host drain.
|
||||
- **Accept**: `npx vitest run drain` green.
|
||||
|
||||
#### T14 · Revocation subscriber — `relay:revocations` → §4.1 teardown `[ ]` — v0.9 **(INV12 data-plane teardown owner, FIX 4)**
|
||||
> **FIX 4 — one control→data-plane teardown contract.** Fast revocation must tear down an **already-open**
|
||||
> tunnel, not merely refuse the next connect. The frozen §4.2 bus (`RELAY_REVOCATIONS_CHANNEL` +
|
||||
> `KillSignal`/`RevocationScope`, promoted into `relay-contracts` so P1 does **not** import P5 — DAG stays
|
||||
> acyclic) is the **single** named channel: **P3/P5 PUBLISH, every P1 node SUBSCRIBES**. This task is P1's
|
||||
> subscriber — the data-plane end of INV12. It injects **no new wire type**: it maps a `KillSignal` onto the
|
||||
> existing §4.1 `CLOSE`+`flags.RST` (per stream) or link-level `GOAWAY` (global) via T10/T11's levers.
|
||||
- **Owns**: `term-relay/data-plane/revocation-subscriber.ts`, `term-relay/test/revocation-subscriber.test.ts`
|
||||
- **Depends**: T10 (`RelayNode.closeStream`/`closeTunnel`), T11 (`drainHost`/`drainNode`). **Cross-plan**: subscribes to a Redis pub/sub client (injected); the publisher is **P5** `revoke()` / **P3** (§4.2). · **Parallel-safe**: none in W3 (consumes T10+T11); runs after both freeze.
|
||||
- **Contract** (frozen §4.2 types imported from `relay-contracts`, never redefined):
|
||||
```ts
|
||||
import type { KillSignal, RevocationScope } from 'relay-contracts'
|
||||
import { RELAY_REVOCATIONS_CHANNEL, REVOCATION_PUSH_BUDGET_MS } from 'relay-contracts' // 'relay:revocations', 2000
|
||||
|
||||
export interface RevocationSubscriberDeps {
|
||||
subscribe(channel: string, onMessage: (raw: string) => void): { close(): void } // injected Redis pub/sub
|
||||
node: Pick<RelayNode, 'closeTunnel'> // whole-host lever (T10)
|
||||
drainHost(hostId: string, reason: typeof DRAIN_REASON.revoked): Promise<void> // immediate host teardown (T11)
|
||||
tunnels(): ReadonlyMap<string, AgentTunnel> // to enumerate hosts for account/global scope
|
||||
routeResolver: RouteResolver // account → hostIds this node currently serves
|
||||
now: () => number
|
||||
onApplied?(signal: KillSignal, hostsAffected: number, elapsedMs: number): void // budget/audit metadata only (INV10)
|
||||
}
|
||||
// Pure scope→hosts selector (testable in isolation): which of THIS node's live hosts a signal hits.
|
||||
export function hostsForScope(scope: RevocationScope, liveHosts: ReadonlyMap<string, AgentTunnel>,
|
||||
accountOf: (hostId: string) => string): readonly string[]
|
||||
export function startRevocationSubscriber(deps: RevocationSubscriberDeps): { close(): void }
|
||||
```
|
||||
- **Mapping (KillSignal → §4.1 teardown, no new frame type)**:
|
||||
```
|
||||
on message on RELAY_REVOCATIONS_CHANNEL:
|
||||
signal = parseKillSignal(raw) // Zod-validate at the boundary; malformed → drop + count (no throw across the bus)
|
||||
switch signal.scope.kind:
|
||||
'host' → drainHost(scope.hostId, DRAIN_REASON.revoked) // GOAWAY? no — RST every stream now + close tunnel + deregister (T11, grace forced 0)
|
||||
'account' → for hostId in hostsForScope(scope, tunnels(), accountOf): drainHost(hostId, revoked)
|
||||
'global' → for every tunnel: drainHost(hostId, revoked) // link-level GOAWAY + RST all (node-wide)
|
||||
onApplied?(signal, hostsAffected, now()-startedAt) // metadata only; MUST NOT log the reason string as payload (INV10)
|
||||
```
|
||||
Every path uses **T11's `reason=revoked` immediate teardown (grace forced to 0)** so the §4.2
|
||||
`REVOCATION_PUSH_BUDGET_MS = 2000` budget is met. An unknown/absent host (already gone) is a **no-op**
|
||||
(deny-by-default: nothing to tear down, never a broader kill).
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `revocation-subscriber.test.ts` (fake pub/sub + spy `drainHost`/`closeTunnel`, in-RAM tunnels):
|
||||
- a `{scope:{kind:'host',hostId:H}}` message → `drainHost(H, revoked)` called once **within `REVOCATION_PUSH_BUDGET_MS`** (assert with a fake clock); a host **not** on this node → no-op (0 calls).
|
||||
- `{scope:{kind:'account',accountId:A}}` → exactly the node's live hosts owned by A are torn down; a sibling host of another account keeps flowing (INV1 blast-radius contained to the account).
|
||||
- `{scope:{kind:'global'}}` → every live tunnel torn down (node-wide GOAWAY+RST).
|
||||
- **immediacy (INV12):** teardown does **not** wait any grace — assert `drainHost` is invoked with `reason=revoked` (grace forced to 0 per T11), even if the node's operator-drain grace is large.
|
||||
- **malformed signal** (bad JSON / missing `scope`) → dropped, counted, **no** teardown fired, subscriber stays alive (a poisoned message can't kill the bus or trigger a spurious kill).
|
||||
- **no payload leak (INV10):** `signal.reason` is never written to a data structure or log as content; only `{scope, hostsAffected, elapsedMs}` metadata is emitted via `onApplied`.
|
||||
- `close()` unsubscribes cleanly (no teardown after close).
|
||||
2. **GREEN** implement thin: `subscribe(RELAY_REVOCATIONS_CHANNEL, …)`, Zod-parse, `hostsForScope`, fan out to `drainHost(…, DRAIN_REASON.revoked)`. `hostsForScope` is a pure function (unit-tested standalone).
|
||||
- **Security note (INV12/INV1/INV10)**: this closes the FIX-4 gap — P5/P3 publish a `KillSignal`, **every** P1 node reacts by RST-ing exactly the in-scope streams and dropping the tunnels **immediately** (grace 0), so a compromised host/account is off the data plane within the 2s budget **even for connections already open**. Scope selection is blast-radius-bounded (`host` ⊂ `account` ⊂ `global`); a host this node doesn't serve is a no-op (no cross-tenant reach, INV1). The `reason` string is metadata only — never forwarded as terminal payload (INV10). P1 **subscribes only**; it never publishes (P5 stays the sole revocation authority). **Single-device (per-`jti`) revocation** is a finer granularity than the frozen `KillSignal` scope expresses — see §9 OQ6.
|
||||
- **Accept**: `npx vitest run revocation-subscriber` green; teardown latency assertion ≤ `REVOCATION_PUSH_BUDGET_MS`.
|
||||
|
||||
### W1-parallel lane — v0.8 scaffold
|
||||
|
||||
#### T12 · frp MVP scaffold (v0.8 stepping-stone) `[ ]` — v0.8
|
||||
- **Owns**: `term-relay/frp-scaffold/frps.toml`, `term-relay/frp-scaffold/README.md`, `term-relay/frp-scaffold/plugin-hook.ts`, `term-relay/test/plugin-hook.test.ts`
|
||||
- **Depends**: none (independent lane). **Cross-plan**: P3 exposes an authz hook endpoint; P2 wraps `frpc` (agent side).
|
||||
- **Deliverables**:
|
||||
- `frps.toml`: single-node `frps` on the VPS, **wildcard vhost** so `*.term.<domain>` routes by subdomain to the agent's proxy (EXPLORE §5.4); TLS terminated here or at Cloudflare in front (M6 scheme-following preserved end-to-end); `vhostHTTPSPort`/`subdomainHost` set to `BASE_DOMAIN`.
|
||||
- `plugin-hook.ts`: a **thin** HTTP server-plugin shim implementing frp's `Login`/`NewProxy`/`NewUserConn` hooks by **delegating** the decision to P3's control-plane authz endpoint (checks `agentToken`/`clientToken` hashes, subdomain ownership). **No tenancy logic lives here** — it forwards and enforces the deny-by-default answer.
|
||||
- `README.md`: the café-demo runbook — VPS + wildcard DNS (`*.term.<domain>`) + wildcard TLS (LetsEncrypt DNS-01) + `frps` + the P2 `frpc`-wrapped agent; **plus an explicit "retirement" section**: what each frp piece maps to in the native mux (frp yamux → §4.1 mux; frp subdomain vhost → T7 router; frp login plugin → T8 upgrade gate) so v0.9 is a swap, not a rewrite.
|
||||
- **Steps (TDD)**:
|
||||
1. **RED** `plugin-hook.test.ts`: a `NewUserConn`/`Login` payload with an unknown token → hook returns **reject** (deny-by-default); a valid one whose subdomain the control plane owns → allow; the hook **forwards** to P3 and never decides tenancy itself; malformed payload (Zod) → reject.
|
||||
2. **GREEN** implement the shim (Zod-validate the frp hook body at the boundary).
|
||||
- **Security note**: even the v0.8 scaffold keeps auth at the relay edge (the auth the base app never had, EXPLORE §5.5). The shared `clientToken`/`agentToken` gate is a **known, temporary** shortcut (INDEX v0.8) — it is replaced by capability tokens (T8) + mTLS (T9) + Passkey/pairing (P2/P3/P5) in v0.9/v0.10; the frame path is already designed so ciphertext (P4) drops into DATA without reshaping (INV2). **No plaintext terminal bytes are parsed even in v0.8** — frp forwards opaque TCP/WS streams (INV11).
|
||||
- **Accept**: `npx vitest run plugin-hook` green; README runbook reproduces the café demo on one VPS.
|
||||
|
||||
### W4 — invariant tripwires & integration
|
||||
|
||||
#### T13 · P1 invariant tripwires + integration `[ ]` — v0.9
|
||||
- **Owns**: `term-relay/test/inv-tripwires.test.ts`, `term-relay/test/integration/data-plane.test.ts`
|
||||
- **Depends**: T2–T11, T14. · **Parallel-safe**: none (integration convergence, like PLAN §W4).
|
||||
- **Steps (tests)**:
|
||||
- **INV11 static:** assert the `term-relay/mux/**` and `term-relay/data-plane/**` dependency graph imports **no** xterm/ANSI/terminal parser (scan `import`s; fail if any match `/xterm|ansi|vt100|terminal-parser/i`).
|
||||
- **INV2 ciphertext:** end-to-end through a real `MuxSession` pair + `relay-node` splice, inject a unique plaintext marker; assert it appears **only** as opaque bytes in transit and is absent from every relay-side retained structure and from logs.
|
||||
- **INV1 cross-tenant (data-plane assertion, complements P5's permanent CI tripwire):** device authorized for host A → 403 when hitting host B's subdomain, on first connect **and** on reconnect/reattach.
|
||||
- **INV7 stateless:** kill a node mid-stream → no data at rest; reconnected tunnel on a new node serves fresh upgrades; PTY (base app) unaffected.
|
||||
- **INV6 deny-by-default (FIX 6a):** the `authorizeUpgrade` adapter opens a stream **only** on P5's `{ok:true}`; every P5 `{ok:false}` and every local parse-guard failure returns 401/403 with no stream opened — assert the adapter has no independent allow branch (a P5 deny is never overridden into an allow).
|
||||
- **INV13 ordering:** DATA frames are delivered in order and never reordered/deduped by the relay (so P4's `seq` holds); an illegal transition RSTs one stream, not the tunnel.
|
||||
- **INV12 revocation (Finding-2/Finding-3 + FIX 4):** end-to-end, a `KillSignal{scope:{kind:'host',hostId}}` published on `relay:revocations` is consumed by **T14** and tears down that host's live tunnel within `REVOCATION_PUSH_BUDGET_MS` (already-open streams, not just the next connect); an `account` scope tears down only that account's hosts on the node (siblings keep flowing, INV1); `closeStream(hostId, {jti})` drops exactly one shared device's stream while a sibling device (different `jti`) keeps flowing; `drainHost(hostId, {reason: revoked, inFlightGraceMs: large})` tears down the whole host **without** waiting the grace window (immediate), whereas `reason: operatorDrain` honors it.
|
||||
- **INV15 token transport (Finding-1):** a browser upgrade carrying the capability token **only** in the query string is rejected (401) end-to-end; a token via `Sec-WebSocket-Protocol` (or `HttpOnly` cookie) is accepted; assert no test log line contains the raw token, `req.url`, or the `Sec-WebSocket-Protocol` value (INV9 access-log discipline).
|
||||
- **INV14 mTLS presentation (Finding-4):** a simulated agent handshake with no client cert (or a cert not chaining to `agentCaCertPath`) is rejected at the TLS layer — `attach()`/`verifyPeer()` are never reached.
|
||||
- **Accept**: `npx vitest run` (all `term-relay/` P1 tests) green; coverage ≥ 80% across mux + data-plane (`coding-style`/`testing.md`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Security section (P1)
|
||||
|
||||
**Threat model:** the relay bridges full interactive shells for many tenants; a data-plane compromise
|
||||
is worst-case RCE-shaped (EXPLORE §4). P1's job is to keep the node a **dumb ciphertext-shuttle** so it
|
||||
*cannot* leak or inject what it *cannot* parse, and to make cross-tenant reach structurally impossible.
|
||||
|
||||
- **Cross-tenant isolation (INV1)** — the only path to a host is `authorizeUpgrade`, which (FIX 6a) resolves
|
||||
the subdomain → `requestedHostId` and **delegates the `token.host == hostId` gate to P5's `onUpgrade`/`onReattach`**
|
||||
— the decision has exactly one owner, so it cannot drift between two implementations. No code resolves a host
|
||||
by raw address/port/user hostname. Host header is a hint, never authority (`aud` guard + nested-subdomain
|
||||
rejection, T7; P5 owns the token/`aud` check). `host_id` is an unguessable, never-recycled UUIDv4 (§4.2).
|
||||
Fuzzed `host_id` guessing → 403 (P5 `onUpgrade`; T13 asserts the adapter passes it through).
|
||||
- **Ciphertext-shuttle (INV2/INV11)** — DATA payloads are opaque `Uint8Array`; no module imports a
|
||||
terminal parser (static tripwire T13). E2E envelopes (§4.4) ride DATA unread; **no P1 change at v0.10**.
|
||||
- **Deny-by-default authz (INV6) + capability token on upgrade (INV15)** — the full decision (Origin/CSWSH
|
||||
retained **AND** §4.3 token required, default reject, least-privilege rights, DPoP, single-use burn, rate,
|
||||
step-up, audit) is **owned solely by P5** (`onUpgrade`/`onReattach`, FIX 6a). P1's `authorizeUpgrade` is a
|
||||
**thin adapter**: it extracts the token via the frozen §4.3 FIX-5 carriers (subprotocol/cookie, never query),
|
||||
builds P5's `UpgradeContext` (incl. `dpop` + `activeSessionCount`), and opens a stream only on P5's `{ok:true}`.
|
||||
- **`account_id`/`host_id` never client-supplied (INV3)** — identity derives only from the token +
|
||||
registry; `requestPath` query (`?join=…`) is opaque passthrough, never used for authz.
|
||||
- **No shared secrets / mTLS (INV4/INV14)** — the agent listener accepts only mTLS-verified peers
|
||||
(P5 checks the cert against the registry **public** key); no bearer/shared-secret branch exists in v0.9+.
|
||||
- **Stateless, no plaintext at rest (INV5/INV7)** — the node holds only in-RAM maps + per-connection
|
||||
buffers (no global pool → no buffer bleed); a crash persists nothing; P1 writes no shell bytes anywhere.
|
||||
- **Fast revocation (INV12), FIX 4 single channel** — every P1 node **subscribes** to the frozen §4.2
|
||||
`relay:revocations` bus (T14); **P3/P5 publish** a `KillSignal`. On a `host`/`account`-scoped signal the
|
||||
subscriber tears down the in-scope tunnel(s) via `drainHost(reason=revoked)` — RST every stream + close +
|
||||
deregister **immediately (grace forced to 0, Finding-2)**, within `REVOCATION_PUSH_BUDGET_MS` — and on
|
||||
`global` a node-wide GOAWAY+RST. `operatorDrain`/`shutdown` keep a grace window only for clean migration.
|
||||
Single-device (per-`jti`) `closeStream` remains a T10 lever but currently has no bus scope (§9 OQ6). P1
|
||||
**subscribes only, never publishes** (P5 stays sole revocation authority; P1 does not import P5). The
|
||||
customer's PTY survives locally in every case.
|
||||
- **Secrets (INV9)** — `loadDataPlaneConfig` fails fast on missing TLS server material, the
|
||||
`AGENT_CA_CERT_PATH` mTLS trust-anchor, or `BASE_DOMAIN`; **no `console.log`** in P1 (`code-review.md`);
|
||||
nothing secret is ever logged. **The capability token is bearer-equivalent**, so the data-plane edge
|
||||
access log excludes the raw upgrade `req.url`, the `Authorization` header, and the
|
||||
`Sec-WebSocket-Protocol` value — it records only `{subdomain, hostId, jti, decision, ts}` (Finding-1).
|
||||
- **Anti-replay/injection carrier (INV13)** — P1 preserves DATA ordering and RSTs illegal stream
|
||||
transitions, so P4's per-message nonce + monotonic `seq` are meaningful end-to-end.
|
||||
|
||||
**v0.8 shortcut, stated honestly:** the frp scaffold uses a shared `clientToken`/`agentToken` gate at
|
||||
the edge (INDEX v0.8). It is a **known temporary** posture, replaced by capability tokens + mTLS +
|
||||
Passkey/pairing before the first paid signup (EXPLORE §7). Even in v0.8, frp forwards opaque streams —
|
||||
**no terminal parsing** (INV11).
|
||||
|
||||
---
|
||||
|
||||
## 7. Verification
|
||||
|
||||
```bash
|
||||
# --- pure protocol leaves ---
|
||||
npx vitest run --dir term-relay frame-codec # §4.1 codec round-trip + adversarial/fuzz
|
||||
npx vitest run --dir term-relay stream # stream state machine (legal + illegal transitions)
|
||||
npx vitest run --dir term-relay flow-control # credit windows, per-stream isolation, deny-by-default
|
||||
npx vitest run --dir term-relay heartbeat # 15s PING/PONG, miss→dead, stale-token ignored
|
||||
|
||||
# --- mux + data plane ---
|
||||
npx vitest run --dir term-relay mux-session # multiplex, backpressure, RST-one-stream, frame ceiling
|
||||
npx vitest run --dir term-relay subdomain-router # extractSubdomain, Host-confusion rejection
|
||||
npx vitest run --dir term-relay upgrade # FIX 6a: adapter delegates to P5 onUpgrade/onReattach; FIX 5 token transport; zero local authz branches
|
||||
npx vitest run --dir term-relay agent-listener # INV4/INV7/INV14: mTLS-only, register/heartbeat/deregister
|
||||
npx vitest run --dir term-relay relay-node # INV2/INV5/INV7/INV11: opaque splice, per-conn buffers
|
||||
npx vitest run --dir term-relay drain # INV7/INV12: GOAWAY drain + per-host revocation
|
||||
npx vitest run --dir term-relay revocation-subscriber # INV12/FIX4: relay:revocations KillSignal → §4.1 CLOSE+RST/GOAWAY ≤ 2s
|
||||
|
||||
# --- v0.8 scaffold ---
|
||||
npx vitest run --dir term-relay plugin-hook # frp hook deny-by-default → delegates to P3
|
||||
|
||||
# --- invariant tripwires + integration + coverage ---
|
||||
npx vitest run --dir term-relay inv-tripwires # INV11 static import scan, INV2 marker, INV1 data-plane 403
|
||||
npx vitest run --dir term-relay integration # end-to-end data-plane splice through a MuxSession pair
|
||||
npx vitest run --dir term-relay --coverage # ≥ 80% across mux + data-plane (testing.md)
|
||||
cd term-relay && npx tsc --noEmit # types clean; relay-contracts imported read-only
|
||||
grep -rEi 'xterm|ansi|vt100|terminal-parser' term-relay/mux term-relay/data-plane && echo FAIL || echo OK # INV11
|
||||
```
|
||||
|
||||
**Manual / deployment verification (v0.8 café demo, T12):** one VPS, wildcard DNS `*.term.<domain>`,
|
||||
wildcard TLS (LetsEncrypt DNS-01 or Cloudflare in front), `frps` running, a P2 `frpc`-wrapped agent
|
||||
paired on a laptop. From a phone browser open `alice.term.<domain>`, pass the `clientToken` gate, land
|
||||
in the laptop shell — **no router/VPN/static-IP configured**. Confirm: (1) a relay-node restart does
|
||||
**not** kill the running shell (PTY survives, INV7); (2) a foreign-Origin page cannot open the WS
|
||||
(CSWSH retained); (3) all 212 base-app tests still pass (`npm test` in repo root) — the relay adds a
|
||||
layer *underneath* the existing WebSocket, it does not modify `src/`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Phasing summary
|
||||
|
||||
| Phase | P1 tasks | Deliverable |
|
||||
|---|---|---|
|
||||
| **v0.8 MVP** | T1 (config), T12 (frp scaffold), **T2 spec-written** | Café demo on frp; §4.1 frame format authored (not yet the substrate); auth at the relay edge (shared-token shortcut). |
|
||||
| **v0.9 SaaS** | T2–T11, **T14**, T13 | Native WS mux **replaces frp**: §4.1 as the real substrate, per-stream flow control, 15s heartbeat, reconnection, GOAWAY drain, stateless node, subdomain routing by authenticated session, **upgrade gate as a thin adapter delegating to P5 `onUpgrade`/`onReattach` (FIX 6a)**, mTLS agent listener, opaque splice, and the **`relay:revocations` subscriber that turns a `KillSignal` into immediate §4.1 teardown (FIX 4)**. |
|
||||
| **v0.10 E2E-hardening** | *(no P1 code change)* | DATA already carries P4's `E2EEnvelope` opaquely (INV2) — the frame path was designed so ciphertext drops in without reshaping. P1 only re-runs its INV2/INV13 tripwires against the E2E build. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions (for the orchestrator — do NOT guess, per CLAUDE.md subagent rule)
|
||||
|
||||
1. **CBOR dependency for typed control-frame payloads (OPEN/WINDOW_UPDATE/GOAWAY).** §4.1 says OPEN is
|
||||
"CBOR of MuxOpen". Confirm the shared CBOR lib choice (`cbor-x`?) and whether it belongs in
|
||||
`relay-contracts/` (so P2 agent + P1 relay share one encoder) or is P1-owned in `mux/`. Leaning:
|
||||
put the CBOR codec in `relay-contracts/` next to the frozen types so both sides import one impl.
|
||||
2. **Where `encodeMuxFrame`/`decodeMuxFrame` physically live.** §4.1 lists the signatures under "P1
|
||||
owns" but the *types* are frozen in `relay-contracts/`. This plan puts the **binary codec impl** in
|
||||
`term-relay/mux/frame-codec.ts` (P1) and has P2 import it. Confirm P2 importing from `term-relay/mux/`
|
||||
is acceptable, or whether the codec should move into `relay-contracts/` to keep the agent from
|
||||
depending on the relay package. (Either is contract-compatible; it's a packaging call.)
|
||||
3. **TLS termination boundary.** EXPLORE §3 allows terminating TLS at the relay **or** at Cloudflare in
|
||||
front. `DataPlaneConfig` carries cert/key paths for in-process termination; confirm the default
|
||||
deployment (in-process LetsEncrypt vs CF-in-front) so the T12 runbook picks one as primary.
|
||||
4. **Ingress vs in-process routing at scale.** This plan keeps routing in-process on a single node
|
||||
(v0.8/v0.9, YAGNI per EXPLORE §7 v0.11). Confirm the Redis-routing-table + L7-ingress (Envoy/Traefik)
|
||||
split is deferred to a later multi-node plan and not P1's responsibility now.
|
||||
5. **`AuthzOutcome` surfaces `jti` — RESOLVED (reconcile OQ5).** T8 is a thin adapter that no longer verifies
|
||||
the capability token (P5's `onUpgrade` does), so it cannot read `token.jti` itself — but `MuxOpen.capabilityTokenRef`
|
||||
(used by T10's per-`jti` splice index for single-device `closeStream`) needs it. P5 now adds `readonly jti: string`
|
||||
to the `ok` branch of `AuthzOutcome` (PLAN_RELAY_AUTH_ISOLATION.md T3), surfacing the *verified* token's jti; P1
|
||||
consumes `outcome.jti` read-only for `capabilityTokenRef`. No local redefinition of `AuthzOutcome` in P1.
|
||||
6. **Single-device (per-`jti`) revocation vs the frozen `KillSignal` scope.** FIX 4 froze `RevocationScope` as
|
||||
`host | account | global` only — it has **no per-device/per-`jti` granularity**. But INV12 and T10's
|
||||
`closeStream(hostId, {jti})` still require revoking **one shared device while the owner's other devices stay
|
||||
attached**. The `relay:revocations` bus therefore cannot currently express single-device revocation; T14 only
|
||||
covers host/account/global. **Ask the INDEX owner** whether to (a) add a `{kind:'device'; hostId; jti}` arm to
|
||||
`RevocationScope` (then T14 routes it to `closeStream`), or (b) keep single-device revocation on a separate
|
||||
per-`jti` mechanism. Pending that decision, T10's `closeStream` remains but has no bus trigger. **Do NOT extend
|
||||
`RevocationScope` locally** — it must change at the INDEX coordination point (§4.2).
|
||||
Reference in New Issue
Block a user