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:
Yaojia Wang
2026-07-02 06:10:16 +02:00
parent e4c327e25e
commit 2af57e6686
326 changed files with 40877 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
/**
* T4 — `TerminalTransport` seam + `PassthroughTransport` (v0.8, no E2E).
*
* The abstraction that lets browser-side E2E (T8) "drop in" without reshaping the view: both the
* passthrough and the E2E transport implement `TerminalTransport`, so `terminal-view.ts` never
* changes. In v0.8 there is NO capability token (INDEX §1) — the same-origin signed cookie (T3)
* authenticates the upgrade and the base-app Origin/CSWSH check (M6) is retained.
*
* Token attachment (v0.9+): the §4.3 capability token rides the `Sec-WebSocket-Protocol`
* subprotocol list (frozen §4.3 wire format via `encodeTokenSubprotocol`), NEVER the URL/query
* string (which would leak the bearer credential into proxy/access logs, history, and `Referer`).
* After open, the client asserts the echoed subprotocol is exactly `APP_SUBPROTOCOL` and tears the
* socket down otherwise (echo rule).
*/
import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts'
import type { RelayWebConfig } from './config'
/** The seam every transport implements — plaintext in the browser, encoded by the impl. */
export interface TerminalTransport {
open(): Promise<void>
send(bytes: Uint8Array): void
onMessage(cb: (bytes: Uint8Array) => void): void
onClose(cb: (reason: string) => void): void
close(): void
}
/** Minimal structural WebSocket surface (so tests can inject a mock, no jsdom WS dependency). */
export interface WebSocketLike {
binaryType: string
protocol: string
send(data: ArrayBufferView | ArrayBufferLike | string): void
close(code?: number, reason?: string): void
onopen: ((ev: unknown) => void) | null
onmessage: ((ev: { data: unknown }) => void) | null
onclose: ((ev: { code?: number; reason?: string }) => void) | null
onerror: ((ev: unknown) => void) | null
}
export type WebSocketCtor = new (url: string, protocols?: string | readonly string[]) => WebSocketLike
export interface PassthroughOpts {
/** v0.9+ populates this; ABSENT in v0.8 (cookie-only auth). */
readonly capabilityToken?: string
/** DI seam: inject a mock WebSocket constructor in tests; defaults to global `WebSocket`. */
readonly wsCtor?: WebSocketCtor
}
/** Map a WS close event to a stable reason string the UI can render (no silent swallow). */
function closeReason(ev: { code?: number; reason?: string }): string {
if (ev.code === 4403) return 'forbidden' // INV1/INV6 upgrade denial mirror
if (ev.code === 4401) return 'unauthenticated'
if (ev.reason && ev.reason.length > 0) return ev.reason
return 'closed'
}
/** Coerce a WS message payload (ArrayBuffer | ArrayBufferView | string) to bytes. */
function toBytes(data: unknown): Uint8Array {
if (data instanceof Uint8Array) return data
if (data instanceof ArrayBuffer) return new Uint8Array(data)
if (ArrayBuffer.isView(data)) {
const view = data as ArrayBufferView
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
}
if (typeof data === 'string') return new TextEncoder().encode(data)
return new Uint8Array(0)
}
export function createPassthroughTransport(
cfg: RelayWebConfig,
opts: PassthroughOpts = {},
): TerminalTransport {
const Ctor: WebSocketCtor = opts.wsCtor ?? (globalThis.WebSocket as unknown as WebSocketCtor)
const token = opts.capabilityToken
const url = cfg.wsUrl('/term') // SAME-ORIGIN, scheme-following; NEVER carries a token (T4 §)
let ws: WebSocketLike | null = null
let messageCb: ((bytes: Uint8Array) => void) | null = null
let closeCb: ((reason: string) => void) | null = null
// App subprotocol FIRST; token entry (v0.9+) second — the frozen §4.3 order.
const protocols: readonly string[] =
token === undefined ? [APP_SUBPROTOCOL] : [APP_SUBPROTOCOL, encodeTokenSubprotocol(token)]
function open(): Promise<void> {
return new Promise((resolve, reject) => {
const socket = new Ctor(url, protocols)
socket.binaryType = 'arraybuffer'
ws = socket
socket.onopen = () => {
// Echo rule (v0.9+): with a token attached, accept ONLY the app subprotocol back — a relay
// echoing the token entry, an empty value, or a foreign one is rejected (bearer-leak guard).
if (token !== undefined && socket.protocol !== APP_SUBPROTOCOL) {
socket.close(4400, 'bad-subprotocol')
reject(new Error(`unexpected echoed subprotocol: '${socket.protocol}'`))
return
}
resolve()
}
socket.onmessage = (ev) => {
if (messageCb) messageCb(toBytes(ev.data))
}
socket.onclose = (ev) => {
if (closeCb) closeCb(closeReason(ev))
}
socket.onerror = () => {
// Surface as a close so callers never hang; open() rejection is handled by onclose too.
reject(new Error('websocket error'))
}
})
}
return {
open,
send(bytes: Uint8Array): void {
if (!ws) throw new Error('transport not open')
ws.send(bytes)
},
onMessage(cb: (bytes: Uint8Array) => void): void {
messageCb = cb
},
onClose(cb: (reason: string) => void): void {
closeCb = cb
},
close(): void {
if (ws) ws.close()
},
}
}