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,76 @@
/**
* T4 · Credit-based per-stream flow control (§4.1) — pure, in-memory, deny-by-default.
*
* Each stream has an independent send window: exhausting stream A's credit MUST NOT affect
* stream B (heavy `vim`/`top` redraw can't starve another stream). Unknown/released streams
* carry no credit (deny-by-default: prevents send-after-close write amplification).
* Immutable-style updates (replace the record, never mutate in place).
*/
export const DEFAULT_INITIAL_WINDOW = 256 * 1024
export const WINDOW_REPLENISH_THRESHOLD = 0.5 // emit WINDOW_UPDATE when half consumed
interface StreamCredit {
readonly window: number // remaining SEND credit
readonly initial: number // initial window (for replenish threshold)
readonly delivered: number // RECEIVED bytes since last replenish
}
export interface FlowController {
registerStream(streamId: number, initialWindow: number): void
releaseStream(streamId: number): void
canSend(streamId: number, bytes: number): boolean
consumeSendCredit(streamId: number, bytes: number): void
grantCredit(streamId: number, credit: number): void
creditFor(streamId: number): number
onDelivered(streamId: number, bytes: number): number
}
export function createFlowController(): FlowController {
const streams = new Map<number, StreamCredit>()
return {
registerStream(streamId, initialWindow) {
streams.set(streamId, { window: initialWindow, initial: initialWindow, delivered: 0 })
},
releaseStream(streamId) {
streams.delete(streamId)
},
canSend(streamId, bytes) {
const c = streams.get(streamId)
if (c === undefined) return false // deny-by-default (unknown/released stream)
return bytes <= c.window
},
consumeSendCredit(streamId, bytes) {
const c = streams.get(streamId)
if (c === undefined) return
const nextWindow = c.window - bytes
// Never go negative (canSend gates callers; clamp defensively).
streams.set(streamId, { ...c, window: Math.max(0, nextWindow) })
},
grantCredit(streamId, credit) {
const c = streams.get(streamId)
if (c === undefined) return
streams.set(streamId, { ...c, window: c.window + credit })
},
creditFor(streamId) {
return streams.get(streamId)?.window ?? 0
},
onDelivered(streamId, bytes) {
const c = streams.get(streamId)
if (c === undefined) return 0
const delivered = c.delivered + bytes
if (delivered >= c.initial * WINDOW_REPLENISH_THRESHOLD) {
streams.set(streamId, { ...c, delivered: 0 })
return delivered // replenish this many bytes of credit back to the peer
}
streams.set(streamId, { ...c, delivered })
return 0
},
}
}

View File

@@ -0,0 +1,28 @@
/**
* T2 · Mux frame codec (§4.1) — the 15-byte header + opaque payload, and the CBOR/int
* control-frame payload codecs.
*
* The binary codec is FROZEN in `relay-contracts` (INDEX §4.1, OQ2 resolved: the codec ships
* in contracts so P1 relay and P2 agent share ONE encoder). P1 re-exports it as the stable
* `mux/` surface that T6 (mux-session) and the data plane consume, adding NO wire behavior.
*
* Security (INV2/INV11): this module imports NO terminal/ANSI parser. DATA payloads are opaque
* `Uint8Array` and are never inspected here; only OPEN/WINDOW_UPDATE/GOAWAY control frames are
* shape-validated (via the contracts' Zod guards).
*/
export {
MUX_HEADER_BYTES,
MUX_VERSION,
encodeMuxFrame,
decodeHeader,
decodeMuxFrame,
encodeOpen,
decodeOpen,
encodeWindowUpdate,
decodeWindowUpdate,
encodeGoaway,
decodeGoaway,
} from 'relay-contracts'
export { TYPE_TO_BYTE, BYTE_TO_TYPE } from './type-bytes.js'
export type { MuxFrameType, MuxFrameHeader, MuxOpen, GoAwayReason } from 'relay-contracts'

View File

@@ -0,0 +1,22 @@
/**
* T2 · Boundary Zod validation of control-frame payloads (OPEN / WINDOW_UPDATE / GOAWAY).
*
* The decode+validate functions live in `relay-contracts` (they CBOR/int-decode then Zod-guard,
* throwing `ContractDecodeError` on a bad shape — never a partial value, INV boundary validation).
* P1 re-exports them plus a small `assertWithinFrameCeiling` helper the mux session uses to reject
* an oversized `payloadLen` before allocating (kills the OOM footgun, T6 frame-ceiling case).
*
* Security: NEVER validates DATA content (INV2) — DATA is opaque bytes.
*/
import { ContractDecodeError } from 'relay-contracts'
export { decodeOpen, decodeWindowUpdate, decodeGoaway } from 'relay-contracts'
/** Throw if a decoded `payloadLen` exceeds the configured ceiling (caller RSTs the stream). */
export function assertWithinFrameCeiling(payloadLen: number, maxFrameBytes: number): void {
if (payloadLen > maxFrameBytes) {
throw new ContractDecodeError(
`frame payloadLen ${payloadLen} exceeds ceiling ${maxFrameBytes}`,
)
}
}

View File

@@ -0,0 +1,96 @@
/**
* T5 · Heartbeat / liveness (§4.1) — 15s PING/PONG with an injectable timer.
*
* PING carries a fresh 8-byte crypto-random token every interval; the matching PONG must echo
* the EXACT outstanding token to reset the miss counter. An unmatched/stale/replayed PONG is
* ignored (INV13-adjacent: a replayed PONG can't keep a dead/hijacked tunnel "alive").
* `HEARTBEAT_MISS_LIMIT` consecutive missed PONGs (~45s) ⇒ `onDead` fires exactly once.
*/
import { randomBytes } from 'node:crypto'
export const HEARTBEAT_INTERVAL_MS = 15_000
export const HEARTBEAT_MISS_LIMIT = 3
const TOKEN_BYTES = 8
export interface ScheduleHandle {
cancel(): void
}
export interface HeartbeatDeps {
sendPing(token: Uint8Array): void
onDead(): void
now?: () => number
schedule?: (fn: () => void, ms: number) => ScheduleHandle
intervalMs?: number
}
export interface Heartbeat {
start(): void
stop(): void
onPong(token: Uint8Array): void
}
function defaultSchedule(fn: () => void, ms: number): ScheduleHandle {
const id = setInterval(fn, ms)
return { cancel: () => clearInterval(id) }
}
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false
}
return true
}
export function createHeartbeat(deps: HeartbeatDeps): Heartbeat {
const schedule = deps.schedule ?? defaultSchedule
const intervalMs = deps.intervalMs ?? HEARTBEAT_INTERVAL_MS
let handle: ScheduleHandle | null = null
let outstanding: Uint8Array | null = null // token awaiting a PONG, or null once matched
let misses = 0
let dead = false
function tick(): void {
if (dead) return
// If the previous PING was never answered, count a miss.
if (outstanding !== null) {
misses += 1
if (misses >= HEARTBEAT_MISS_LIMIT) {
dead = true
stop()
deps.onDead()
return
}
}
const token = new Uint8Array(randomBytes(TOKEN_BYTES))
outstanding = token
deps.sendPing(token)
}
function stop(): void {
if (handle !== null) {
handle.cancel()
handle = null
}
}
return {
start() {
if (handle !== null || dead) return
// Send the first PING immediately, then on every interval.
tick()
handle = schedule(tick, intervalMs)
},
stop,
onPong(token) {
if (dead || outstanding === null) return
if (bytesEqual(token, outstanding)) {
outstanding = null
misses = 0
}
// A stale/unknown token is ignored — it does NOT reset the miss counter.
},
}
}

View File

@@ -0,0 +1,296 @@
/**
* T6 · Mux session — the multiplexer over ONE WebSocket tunnel. Assembles the T2 codec,
* T3 stream state machine, T4 credit flow-control, and T5 heartbeat into a demux/dispatch loop.
*
* Security (INV2/INV5/INV11): DATA payloads are copied OPAQUE and never inspected; buffers are
* per-stream, per-session (no global pool → no cross-tenant buffer bleed) and freed on CLOSE;
* this module imports NO terminal/ANSI parser. An inbound DATA for an unknown/closed stream, an
* illegal transition, or a `payloadLen > maxFrameBytes` frame RSTs that ONE stream — the tunnel
* stays up (INV13). Ordering is preserved and never reordered/deduped (so P4's `seq` holds).
*/
import { concatBytes, GOAWAY_CODE_TO_REASON, type MuxOpen } from 'relay-contracts'
import {
MUX_HEADER_BYTES,
encodeMuxFrame,
decodeHeader,
encodeOpen,
decodeOpen,
encodeWindowUpdate,
decodeWindowUpdate,
encodeGoaway,
type MuxFrameHeader,
type MuxFrameType,
} from './frame-codec.js'
import { assertWithinFrameCeiling } from './frame-guards.js'
import {
initialStreamState,
nextStreamState,
isTerminal,
type StreamState,
} from './stream.js'
import { createFlowController, type FlowController } from './flow-control.js'
import {
createHeartbeat,
type Heartbeat,
type ScheduleHandle,
} from './heartbeat.js'
export interface MuxStreamHandle {
readonly streamId: number
writeData(payload: Uint8Array): boolean // false ⇒ backpressured (buffered until WINDOW_UPDATE)
close(rst?: boolean): void
onData(cb: (payload: Uint8Array) => void): void // inbound opaque bytes for THIS stream (INV2)
onClose(cb: (rst: boolean) => void): void // remote/RST close of THIS stream
}
export type MuxRole = 'relay' | 'agent'
export interface MuxSessionDeps {
role: MuxRole
sendWire(frame: Uint8Array): void
onOpen(open: MuxOpen, stream: MuxStreamHandle): void
onData(streamId: number, payload: Uint8Array): void // fallback if no per-stream subscriber
onClose(streamId: number, rst: boolean): void
onDead(): void
maxFrameBytes: number
initialWindowBytes: number
// P1-owned testability seam (NOT a frozen contract): inject the heartbeat timer.
schedule?: (fn: () => void, ms: number) => ScheduleHandle
heartbeatIntervalMs?: number
}
export interface MuxSession {
openStream(open: MuxOpen): MuxStreamHandle
onWire(buf: Uint8Array): void
drain(lastStreamId: number, reason: number): void
close(): void
}
interface StreamCtx {
state: StreamState
readonly handle: MuxStreamHandle
sendBuffer: Uint8Array[]
dataSub: ((payload: Uint8Array) => void) | null
closeSub: ((rst: boolean) => void) | null
}
const EMPTY = new Uint8Array(0)
export function createMuxSession(deps: MuxSessionDeps): MuxSession {
const flow: FlowController = createFlowController()
const streams = new Map<number, StreamCtx>()
let pending: Uint8Array = EMPTY
let nextStreamId = 1 // relay allocates monotonic, never reused
let draining = false
let closed = false
const heartbeat: Heartbeat = createHeartbeat({
sendPing: (token) => sendFrame('ping', 0, token, false, false),
onDead: () => deps.onDead(),
...(deps.schedule ? { schedule: deps.schedule } : {}),
...(deps.heartbeatIntervalMs !== undefined ? { intervalMs: deps.heartbeatIntervalMs } : {}),
})
function sendFrame(
type: MuxFrameType,
streamId: number,
payload: Uint8Array,
fin: boolean,
rst: boolean,
): void {
if (closed) return
const header: MuxFrameHeader = {
version: 1,
type,
fin,
rst,
streamId,
payloadLen: payload.length,
}
deps.sendWire(encodeMuxFrame(header, payload))
}
function makeHandle(streamId: number): MuxStreamHandle {
return {
streamId,
writeData: (payload) => writeData(streamId, payload),
close: (rst = false) => localClose(streamId, rst),
onData: (cb) => {
const ctx = streams.get(streamId)
if (ctx) ctx.dataSub = cb
},
onClose: (cb) => {
const ctx = streams.get(streamId)
if (ctx) ctx.closeSub = cb
},
}
}
function registerStream(streamId: number, state: StreamState): StreamCtx {
const handle = makeHandle(streamId)
const ctx: StreamCtx = { state, handle, sendBuffer: [], dataSub: null, closeSub: null }
streams.set(streamId, ctx)
flow.registerStream(streamId, deps.initialWindowBytes)
return ctx
}
function writeData(streamId: number, payload: Uint8Array): boolean {
const ctx = streams.get(streamId)
if (ctx === undefined || isTerminal(ctx.state)) return false
if (payload.length > deps.maxFrameBytes) return false // never emit an over-ceiling frame
if (!flow.canSend(streamId, payload.length)) {
ctx.sendBuffer.push(payload) // backpressure: buffer until WINDOW_UPDATE
return false
}
flow.consumeSendCredit(streamId, payload.length)
sendFrame('data', streamId, payload, false, false)
return true
}
function flushBuffer(streamId: number): void {
const ctx = streams.get(streamId)
if (ctx === undefined) return
while (ctx.sendBuffer.length > 0) {
const next = ctx.sendBuffer[0]!
if (!flow.canSend(streamId, next.length)) break
ctx.sendBuffer.shift()
flow.consumeSendCredit(streamId, next.length)
sendFrame('data', streamId, next, false, false)
}
}
function localClose(streamId: number, rst: boolean): void {
const ctx = streams.get(streamId)
if (ctx === undefined) return
sendFrame('close', streamId, EMPTY, !rst, rst)
cleanupStream(streamId)
}
function cleanupStream(streamId: number): void {
flow.releaseStream(streamId)
streams.delete(streamId)
}
/** RST exactly ONE stream (illegal transition / unknown stream / ceiling) — tunnel stays up. */
function rstStream(streamId: number): void {
const ctx = streams.get(streamId)
sendFrame('close', streamId, EMPTY, false, true)
if (ctx) {
if (ctx.closeSub) ctx.closeSub(true)
else deps.onClose(streamId, true)
}
cleanupStream(streamId)
}
function deliverData(ctx: StreamCtx, streamId: number, payload: Uint8Array): void {
if (ctx.dataSub) ctx.dataSub(payload)
else deps.onData(streamId, payload)
// Receiver-side replenishment: grant credit back once the threshold is crossed.
const replenish = flow.onDelivered(streamId, payload.length)
if (replenish > 0) sendFrame('windowUpdate', streamId, encodeWindowUpdate(replenish), false, false)
}
function dispatch(header: MuxFrameHeader, payload: Uint8Array): void {
const { type, streamId, fin, rst } = header
// Connection-level control (streamId 0).
if (streamId === 0) {
if (type === 'ping') sendFrame('pong', 0, payload, false, false)
else if (type === 'pong') heartbeat.onPong(payload)
else if (type === 'goaway') draining = true
return
}
if (type === 'open') {
if (deps.role !== 'agent') return // relay never receives OPEN
if (streams.get(streamId) !== undefined) {
rstStream(streamId) // re-OPEN of a live stream is illegal
return
}
const open = decodeOpen(payload)
const ctx = registerStream(streamId, 'open')
deps.onOpen(open, ctx.handle)
return
}
const ctx = streams.get(streamId)
if (ctx === undefined) {
// DATA/CLOSE/WU for an unknown/closed stream → RST that stream only.
sendFrame('close', streamId, EMPTY, false, true)
return
}
const transition = nextStreamState(ctx.state, type, fin, 'inbound')
if ('illegal' in transition) {
rstStream(streamId)
return
}
ctx.state = transition.next
if (type === 'data') {
deliverData(ctx, streamId, payload)
} else if (type === 'windowUpdate') {
flow.grantCredit(streamId, decodeWindowUpdate(payload))
flushBuffer(streamId)
} else if (type === 'close') {
if (ctx.closeSub) ctx.closeSub(rst)
else deps.onClose(streamId, rst)
cleanupStream(streamId)
}
}
return {
openStream(open) {
if (draining || closed) throw new Error('session draining/closed: cannot open new stream')
const streamId = nextStreamId++
const ctx = registerStream(streamId, 'open')
const full: MuxOpen = { ...open, streamId }
sendFrame('open', streamId, encodeOpen(full), false, false)
heartbeat.start()
return ctx.handle
},
onWire(buf) {
if (closed) return
heartbeat.start()
pending = pending.length === 0 ? buf : concatBytes([pending, buf])
for (;;) {
if (pending.length < MUX_HEADER_BYTES) break
let header: MuxFrameHeader
try {
header = decodeHeader(pending)
} catch {
// Unrecoverable framing error — drop the tunnel's buffer (do NOT parse further).
pending = EMPTY
break
}
try {
assertWithinFrameCeiling(header.payloadLen, deps.maxFrameBytes)
} catch {
if (header.streamId > 0) rstStream(header.streamId)
else deps.onDead()
pending = EMPTY
break
}
const total = MUX_HEADER_BYTES + header.payloadLen
if (pending.length < total) break // wait for the rest of the payload
const payload = pending.slice(MUX_HEADER_BYTES, total)
pending = pending.slice(total)
dispatch(header, payload)
}
},
drain(lastStreamId, reason) {
draining = true
const label = GOAWAY_CODE_TO_REASON[reason]
if (label !== undefined) sendFrame('goaway', 0, encodeGoaway(lastStreamId, label), false, false)
},
close() {
closed = true
heartbeat.stop()
streams.clear()
pending = EMPTY
},
}
}

88
term-relay/mux/stream.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* T3 · Stream state machine (§4.1) — a PURE reducer over the per-stream lifecycle.
*
* Lifecycle: `OPEN → (DATA | WINDOW_UPDATE)* → CLOSE`. An illegal transition returns
* `{ illegal: true }` so the caller RSTs that ONE stream (never the tunnel) — this is what
* lets P4's end-to-end `seq` monotonicity mean something (INV13). No I/O, no input mutation.
*/
import type { MuxFrameType } from 'relay-contracts'
export type StreamState =
| 'idle'
| 'open'
| 'halfClosedLocal'
| 'halfClosedRemote'
| 'closed'
export type StreamDirection = 'inbound' | 'outbound'
export type StreamTransition = { readonly next: StreamState } | { readonly illegal: true }
const ILLEGAL: StreamTransition = { illegal: true }
export function initialStreamState(): StreamState {
return 'idle'
}
export function isTerminal(s: StreamState): boolean {
return s === 'closed'
}
/** True when the state still accepts DATA/WINDOW_UPDATE flow in at least one direction. */
function isFlowing(s: StreamState): boolean {
return s === 'open' || s === 'halfClosedLocal' || s === 'halfClosedRemote'
}
/** The half-closed state produced when `dir` sends FIN while fully open. */
function halfCloseFor(dir: StreamDirection): StreamState {
return dir === 'outbound' ? 'halfClosedLocal' : 'halfClosedRemote'
}
/**
* Total transition function. Returns a NEW state (never mutates). `fin` marks a DATA/CLOSE
* frame that half/fully closes the sending direction; `dir` is which side sent the frame.
*/
export function nextStreamState(
current: StreamState,
type: MuxFrameType,
fin: boolean,
dir: StreamDirection,
): StreamTransition {
if (current === 'closed') return ILLEGAL
switch (type) {
case 'open':
// OPEN is only legal from idle; re-OPEN of a live stream is illegal.
return current === 'idle' ? { next: 'open' } : ILLEGAL
case 'data':
case 'windowUpdate': {
if (current === 'idle') return ILLEGAL // DATA/WU before OPEN
if (!isFlowing(current)) return ILLEGAL
// WINDOW_UPDATE never carries FIN; only DATA may half-close its direction.
if (type === 'windowUpdate') return { next: current }
if (!fin) return { next: current }
return finTransition(current, dir)
}
case 'close': {
if (current === 'idle') return ILLEGAL // CLOSE before OPEN
return { next: 'closed' }
}
// ping/pong/goaway are connection-level (streamId 0), never per-stream.
default:
return ILLEGAL
}
}
/** Apply a FIN from `dir` to a flowing state; both-side FIN ⇒ closed. */
function finTransition(current: StreamState, dir: StreamDirection): StreamTransition {
const half = halfCloseFor(dir)
if (current === 'open') return { next: half }
// Already half-closed on the OTHER side, and now this side FINs ⇒ fully closed.
if (current === 'halfClosedLocal' && dir === 'inbound') return { next: 'closed' }
if (current === 'halfClosedRemote' && dir === 'outbound') return { next: 'closed' }
// Re-FIN on the already-closed direction is illegal.
return ILLEGAL
}

View File

@@ -0,0 +1,21 @@
/**
* T2 · MuxFrameType ⇄ byte + flag bit maps (§4.1).
*
* The frozen wire codes live in `relay-contracts` (§4.1). P1 imports them read-only and
* re-exports the local stable surface the mux layer consumes — it NEVER redefines a code.
*/
export {
MUX_HEADER_BYTES,
MUX_VERSION,
MUX_TYPE_TO_CODE as TYPE_TO_BYTE,
MUX_CODE_TO_TYPE as BYTE_TO_TYPE,
FLAG_FIN,
FLAG_RST,
FLAG_RESERVED_MASK,
CONNECTION_STREAM_ID,
UINT32_MAX,
GOAWAY_REASON_TO_CODE,
GOAWAY_CODE_TO_REASON,
} from 'relay-contracts'
export type { MuxFrameType, MuxFrameHeader, MuxOpen, GoAwayReason } from 'relay-contracts'