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:
296
term-relay/mux/mux-session.ts
Normal file
296
term-relay/mux/mux-session.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user