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:
63
agent/src/transport/backoff.ts
Normal file
63
agent/src/transport/backoff.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Reconnection / backoff — PLAN_RELAY_AGENT T10. REUSES the base app's 1/2/4…cap-30s policy
|
||||
* (EXPLORE §3). `reconnectLoop` only CONSUMES an injected `isRevoked()` (the W0 seam) — a revoked
|
||||
* host short-circuits and never reconnects (INV12). No task edge to T14.
|
||||
*/
|
||||
import type { Tunnel } from './tunnel.js'
|
||||
|
||||
export const BACKOFF_BASE_MS = 1_000
|
||||
export const BACKOFF_CAP_MS = 30_000
|
||||
|
||||
export interface BackoffPolicy {
|
||||
nextDelayMs(): number
|
||||
reset(): void
|
||||
}
|
||||
|
||||
/** Exponential backoff 1s,2s,4s…capped at 30s, optional [0.5×,1×] jitter. */
|
||||
export function createBackoff(
|
||||
opts: { baseMs?: number; capMs?: number; jitter?: boolean; rng?: () => number } = {},
|
||||
): BackoffPolicy {
|
||||
const baseMs = opts.baseMs ?? BACKOFF_BASE_MS
|
||||
const capMs = opts.capMs ?? BACKOFF_CAP_MS
|
||||
const jitter = opts.jitter ?? false
|
||||
const rng = opts.rng ?? Math.random
|
||||
let attempt = 0
|
||||
return {
|
||||
nextDelayMs(): number {
|
||||
const raw = Math.min(baseMs * 2 ** attempt, capMs)
|
||||
attempt += 1
|
||||
if (!jitter) return raw
|
||||
return Math.round(raw * (0.5 + rng() * 0.5)) // [0.5×, 1×]
|
||||
},
|
||||
reset(): void {
|
||||
attempt = 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type Sleep = (ms: number) => Promise<void>
|
||||
const realSleep: Sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
/**
|
||||
* Dial with backoff until success (resolves the connected Tunnel) or the host is revoked
|
||||
* (resolves null — never reconnect). Each dial failure waits `backoff.nextDelayMs()`; a success
|
||||
* resets the backoff.
|
||||
*/
|
||||
export async function reconnectLoop(
|
||||
dial: () => Promise<Tunnel>,
|
||||
backoff: BackoffPolicy,
|
||||
isRevoked: () => boolean,
|
||||
sleep: Sleep = realSleep,
|
||||
): Promise<Tunnel | null> {
|
||||
while (!isRevoked()) {
|
||||
try {
|
||||
const tunnel = await dial()
|
||||
backoff.reset()
|
||||
return tunnel
|
||||
} catch {
|
||||
if (isRevoked()) return null
|
||||
await sleep(backoff.nextDelayMs())
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
103
agent/src/transport/dial.ts
Normal file
103
agent/src/transport/dial.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Outbound mTLS dial — PLAN_RELAY_AGENT T12 (INV14/INV4). Builds a wss:// client authenticated by
|
||||
* the client cert + IN-PROCESS Ed25519 key + pinned CA chain, `rejectUnauthorized: true`, and NO
|
||||
* bearer/agent token (mTLS IS the auth). Absent/expired cert ⇒ fail-fast, no dial.
|
||||
*/
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import type { WsLike } from './seams.js'
|
||||
|
||||
export class NotEnrolledError extends Error {
|
||||
constructor() {
|
||||
super('agent is not enrolled (no identity/cert in keystore)')
|
||||
this.name = 'NotEnrolledError'
|
||||
}
|
||||
}
|
||||
export class CertExpiredError extends Error {
|
||||
constructor() {
|
||||
super('client certificate has expired; renew before dialling')
|
||||
this.name = 'CertExpiredError'
|
||||
}
|
||||
}
|
||||
|
||||
/** TLS material for the wss client. NOTE: `rejectUnauthorized` is ALWAYS true (anti-MITM). */
|
||||
export interface TlsClientOptions {
|
||||
readonly cert: string
|
||||
readonly key: string
|
||||
readonly ca: string
|
||||
readonly rejectUnauthorized: true
|
||||
}
|
||||
|
||||
export interface CertInfo {
|
||||
readonly validTo: Date
|
||||
}
|
||||
export type CertParser = (certPem: string) => CertInfo
|
||||
const defaultCertParser: CertParser = (pem) => ({ validTo: new Date(new X509Certificate(pem).validTo) })
|
||||
|
||||
/**
|
||||
* Assemble the mTLS options from the keystore. Throws NotEnrolledError if key/cert are missing,
|
||||
* CertExpiredError if the cert is past `validTo`. There is NO token field by construction (INV4).
|
||||
*/
|
||||
export function buildTlsOptions(
|
||||
ks: Keystore,
|
||||
opts: { now?: Date; certParser?: CertParser } = {},
|
||||
): TlsClientOptions {
|
||||
const id = ks.loadIdentity()
|
||||
const certs = ks.loadCert()
|
||||
if (id === null || certs === null) throw new NotEnrolledError()
|
||||
const parse = opts.certParser ?? defaultCertParser
|
||||
const now = opts.now ?? new Date()
|
||||
if (parse(certs.certPem).validTo.getTime() < now.getTime()) throw new CertExpiredError()
|
||||
return {
|
||||
cert: certs.certPem,
|
||||
key: id.exportPrivatePkcs8Pem(),
|
||||
ca: certs.caChainPem,
|
||||
rejectUnauthorized: true,
|
||||
}
|
||||
}
|
||||
|
||||
export interface RawTlsWs {
|
||||
send(data: Uint8Array): void
|
||||
close(): void
|
||||
on(event: string, cb: (...args: unknown[]) => void): void
|
||||
once(event: string, cb: (...args: unknown[]) => void): void
|
||||
}
|
||||
export type TlsWsConstructor = new (url: string, opts: TlsClientOptions) => RawTlsWs
|
||||
|
||||
function toU8(data: unknown): Uint8Array | null {
|
||||
if (data instanceof Uint8Array) return data
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
return null
|
||||
}
|
||||
|
||||
function adapt(raw: RawTlsWs): WsLike {
|
||||
return {
|
||||
send: (d) => raw.send(d),
|
||||
on(ev, cb) {
|
||||
if (ev === 'message') {
|
||||
raw.on('message', (data: unknown) => {
|
||||
const bytes = toU8(data)
|
||||
if (bytes !== null) cb(bytes)
|
||||
})
|
||||
} else {
|
||||
raw.on(ev, cb)
|
||||
}
|
||||
},
|
||||
close: () => raw.close(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Dial the relay's /agent endpoint over mTLS wss. Resolves the connected WsLike on open. */
|
||||
export function dialRelay(
|
||||
cfg: AgentConfig,
|
||||
ks: Keystore,
|
||||
opts: { Ctor: TlsWsConstructor; now?: Date; certParser?: CertParser },
|
||||
): Promise<WsLike> {
|
||||
const tls = buildTlsOptions(ks, { ...(opts.now ? { now: opts.now } : {}), ...(opts.certParser ? { certParser: opts.certParser } : {}) })
|
||||
return new Promise<WsLike>((resolve, reject) => {
|
||||
const raw = new opts.Ctor(cfg.relayUrl, tls)
|
||||
raw.once('open', () => resolve(adapt(raw)))
|
||||
raw.once('error', (err: unknown) => reject(err instanceof Error ? err : new Error(String(err))))
|
||||
})
|
||||
}
|
||||
41
agent/src/transport/flowControl.ts
Normal file
41
agent/src/transport/flowControl.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Per-stream flow control — PLAN_RELAY_AGENT T11. CONSUMES the §4.1 WINDOW_UPDATE credit protocol
|
||||
* (P1 owns the protocol). Per-stream credit means one heavy vim/top redraw can't starve another
|
||||
* stream. streamId 0 is the connection-level window applied to the whole link.
|
||||
*/
|
||||
export interface FlowController {
|
||||
consume(streamId: number, bytes: number): boolean
|
||||
grant(streamId: number, credit: number): void
|
||||
initWindow(streamId: number, initialCredit: number): void
|
||||
}
|
||||
|
||||
const CONNECTION_STREAM_ID = 0
|
||||
|
||||
export function createFlowController(): FlowController {
|
||||
const windows = new Map<number, number>()
|
||||
// Connection-level window is unbounded until explicitly initialized.
|
||||
windows.set(CONNECTION_STREAM_ID, Number.POSITIVE_INFINITY)
|
||||
|
||||
function remaining(streamId: number): number {
|
||||
return windows.get(streamId) ?? 0
|
||||
}
|
||||
|
||||
return {
|
||||
initWindow(streamId: number, initialCredit: number): void {
|
||||
windows.set(streamId, initialCredit)
|
||||
},
|
||||
grant(streamId: number, credit: number): void {
|
||||
windows.set(streamId, remaining(streamId) + credit)
|
||||
},
|
||||
consume(streamId: number, bytes: number): boolean {
|
||||
const conn = remaining(CONNECTION_STREAM_ID)
|
||||
const stream = remaining(streamId)
|
||||
if (stream < bytes || conn < bytes) return false // credit exhausted → pause
|
||||
windows.set(streamId, stream - bytes)
|
||||
if (conn !== Number.POSITIVE_INFINITY) {
|
||||
windows.set(CONNECTION_STREAM_ID, conn - bytes)
|
||||
}
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
72
agent/src/transport/frpScaffold.ts
Normal file
72
agent/src/transport/frpScaffold.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* v0.8 frpc-wrap stepping-stone — PLAN_RELAY_AGENT T6. Fastest path to the café demo: wraps a
|
||||
* child `frpc` presenting the shared v0.8 `agentToken`, registering the subdomain, forwarding to
|
||||
* 127.0.0.1:3000. EXPLICITLY a stepping-stone — the native mux (T7–T11) replaces it at v0.9.
|
||||
*
|
||||
* Forwards ONLY to loopback (anti-SSRF). Retired once EnrollMode==='ed25519' (guard below).
|
||||
*/
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import { isLoopbackWsUrl } from '../config/agentConfig.js'
|
||||
import type { EnrollMode } from '../enroll/pair.js'
|
||||
|
||||
export interface FrpScaffold {
|
||||
start(): Promise<void>
|
||||
stop(): Promise<void>
|
||||
onExit(cb: (code: number) => void): void
|
||||
}
|
||||
|
||||
/** Minimal child-process seam so tests inject a fake spawn (no real frpc needed). */
|
||||
export interface ChildLike {
|
||||
on(ev: 'exit', cb: (code: number | null) => void): void
|
||||
kill(): void
|
||||
}
|
||||
export type SpawnImpl = (cmd: string, args: readonly string[]) => ChildLike
|
||||
|
||||
const LOOPBACK_IP = '127.0.0.1'
|
||||
const LOCAL_PORT = 3000
|
||||
|
||||
/** Build the frpc.toml. local_ip is ALWAYS loopback; tls is enabled. */
|
||||
export function buildFrpcToml(cfg: AgentConfig): string {
|
||||
if (!isLoopbackWsUrl(cfg.localTargetUrl)) {
|
||||
throw new Error('frpScaffold refuses a non-loopback localTargetUrl (anti-SSRF)')
|
||||
}
|
||||
const subdomain = cfg.subdomain ?? ''
|
||||
return [
|
||||
'[common]',
|
||||
'tls_enable = true',
|
||||
'',
|
||||
'[web-terminal]',
|
||||
'type = "tcp"',
|
||||
`local_ip = "${LOOPBACK_IP}"`,
|
||||
`local_port = ${LOCAL_PORT}`,
|
||||
`subdomain = "${subdomain}"`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** True once the native Ed25519 substrate is active — `run` must NOT wire frpc then. */
|
||||
export function isFrpRetired(mode: EnrollMode): boolean {
|
||||
return mode === 'ed25519'
|
||||
}
|
||||
|
||||
/** Spawn (a mockable) frpc child with a generated config. */
|
||||
export function spawnFrpc(cfg: AgentConfig, frpcPath: string, spawnImpl: SpawnImpl): FrpScaffold {
|
||||
const toml = buildFrpcToml(cfg) // validates loopback before spawning
|
||||
void toml
|
||||
let child: ChildLike | null = null
|
||||
const exitCbs: Array<(code: number) => void> = []
|
||||
return {
|
||||
async start(): Promise<void> {
|
||||
child = spawnImpl(frpcPath, ['-c', 'frpc.toml'])
|
||||
child.on('exit', (code) => {
|
||||
for (const cb of exitCbs) cb(code ?? 0)
|
||||
})
|
||||
},
|
||||
async stop(): Promise<void> {
|
||||
child?.kill()
|
||||
},
|
||||
onExit(cb: (code: number) => void): void {
|
||||
exitCbs.push(cb)
|
||||
},
|
||||
}
|
||||
}
|
||||
84
agent/src/transport/heartbeat.ts
Normal file
84
agent/src/transport/heartbeat.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* §4.1 heartbeat — PLAN_RELAY_AGENT T9. PING every 15s on streamId 0; a missed PONG within the
|
||||
* interval ⇒ the tunnel is dead (⇒ T10 reconnect). Also replies PONG (echoing the 8-byte token)
|
||||
* to an inbound PING. Timers are injectable (TimerLike) for deterministic fake-timer tests.
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import type { TimerLike } from './seams.js'
|
||||
import { pingHeader, pongHeader, type Tunnel } from './tunnel.js'
|
||||
|
||||
export const HEARTBEAT_INTERVAL_MS = 15_000
|
||||
|
||||
export interface Heartbeat {
|
||||
onPing(token: Uint8Array): void
|
||||
onPong(token: Uint8Array): void
|
||||
start(): void
|
||||
stop(): void
|
||||
onDead(cb: () => void): void
|
||||
}
|
||||
|
||||
const realTimer: TimerLike = {
|
||||
setTimeout: (cb, ms) => setTimeout(cb, ms),
|
||||
clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
||||
setInterval: (cb, ms) => setInterval(cb, ms),
|
||||
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||
}
|
||||
|
||||
export function createHeartbeat(
|
||||
tunnel: Tunnel,
|
||||
opts: { intervalMs?: number; timer?: TimerLike; genToken?: () => Uint8Array } = {},
|
||||
): Heartbeat {
|
||||
const intervalMs = opts.intervalMs ?? HEARTBEAT_INTERVAL_MS
|
||||
const timer = opts.timer ?? realTimer
|
||||
const genToken = opts.genToken ?? (() => new Uint8Array(randomBytes(8)))
|
||||
|
||||
let interval: unknown = null
|
||||
let deadline: unknown = null
|
||||
let pending = false
|
||||
let deadCb: (() => void) | null = null
|
||||
let dead = false
|
||||
|
||||
function fireDead(): void {
|
||||
if (dead) return
|
||||
dead = true
|
||||
stop()
|
||||
deadCb?.()
|
||||
}
|
||||
|
||||
function sendPing(): void {
|
||||
pending = true
|
||||
tunnel.send(pingHeader(), genToken())
|
||||
deadline = timer.setTimeout(() => {
|
||||
if (pending) fireDead()
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (interval !== null) timer.clearInterval(interval)
|
||||
if (deadline !== null) timer.clearTimeout(deadline)
|
||||
interval = null
|
||||
deadline = null
|
||||
}
|
||||
|
||||
return {
|
||||
onPing(token: Uint8Array): void {
|
||||
tunnel.send(pongHeader(), token) // echo the token byte-exact
|
||||
},
|
||||
onPong(): void {
|
||||
pending = false
|
||||
if (deadline !== null) {
|
||||
timer.clearTimeout(deadline)
|
||||
deadline = null
|
||||
}
|
||||
},
|
||||
start(): void {
|
||||
dead = false
|
||||
sendPing()
|
||||
interval = timer.setInterval(sendPing, intervalMs)
|
||||
},
|
||||
stop,
|
||||
onDead(cb: () => void): void {
|
||||
deadCb = cb
|
||||
},
|
||||
}
|
||||
}
|
||||
71
agent/src/transport/loopback.ts
Normal file
71
agent/src/transport/loopback.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Loopback forwarder — PLAN_RELAY_AGENT T8. One OPEN ⇒ one fresh ws://127.0.0.1:3000<path>
|
||||
* socket, REPLAYING the real browser `Origin` (§4.1 MuxOpen.originHeader) so the UNCHANGED base
|
||||
* app's Origin check still passes end-to-end (CSWSH protection preserved — EXPLORE §3).
|
||||
*
|
||||
* The raw `ws` constructor is injectable so the URL/Origin wiring is unit-testable without a real
|
||||
* socket. The target is always loopback (validated upstream in config).
|
||||
*/
|
||||
import type { WsLike } from './seams.js'
|
||||
|
||||
export type DialLoopback = (path: string, origin: string) => Promise<WsLike>
|
||||
|
||||
/** Minimal surface of a raw `ws` client the adapter needs. */
|
||||
export interface RawWs {
|
||||
send(data: Uint8Array): void
|
||||
close(): void
|
||||
on(event: string, cb: (...args: unknown[]) => void): void
|
||||
once(event: string, cb: (...args: unknown[]) => void): void
|
||||
}
|
||||
export type WsConstructor = new (
|
||||
url: string,
|
||||
opts?: { headers?: Record<string, string> },
|
||||
) => RawWs
|
||||
|
||||
/** Join the loopback target with the request path (avoids a double slash). */
|
||||
export function buildLoopbackUrl(target: string, path: string): string {
|
||||
const base = target.endsWith('/') ? target.slice(0, -1) : target
|
||||
const suffix = path.startsWith('/') ? path : `/${path}`
|
||||
return `${base}${suffix}`
|
||||
}
|
||||
|
||||
function toU8(data: unknown): Uint8Array | null {
|
||||
if (data instanceof Uint8Array) return data
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
return null
|
||||
}
|
||||
|
||||
function adapt(raw: RawWs): WsLike {
|
||||
return {
|
||||
send(d: Uint8Array): void {
|
||||
raw.send(d)
|
||||
},
|
||||
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void {
|
||||
if (ev === 'message') {
|
||||
raw.on('message', (data: unknown) => {
|
||||
const bytes = toU8(data)
|
||||
if (bytes !== null) cb(bytes)
|
||||
})
|
||||
} else {
|
||||
raw.on(ev, cb)
|
||||
}
|
||||
},
|
||||
close(): void {
|
||||
raw.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DialLoopback bound to `target`. Resolves once the loopback socket is open; rejects on
|
||||
* a pre-open error (so a down base app surfaces as a typed dial failure, not a silent hang).
|
||||
*/
|
||||
export function dialLoopback(target: string, Ctor: WsConstructor): DialLoopback {
|
||||
return (path: string, origin: string): Promise<WsLike> =>
|
||||
new Promise<WsLike>((resolve, reject) => {
|
||||
const url = buildLoopbackUrl(target, path)
|
||||
const raw = new Ctor(url, { headers: { Origin: origin } })
|
||||
raw.once('open', () => resolve(adapt(raw)))
|
||||
raw.once('error', (err: unknown) => reject(err instanceof Error ? err : new Error(String(err))))
|
||||
})
|
||||
}
|
||||
43
agent/src/transport/seams.ts
Normal file
43
agent/src/transport/seams.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* W0 shared injection seams (DEPENDENCY-CYCLE BREAKER) — PLAN_RELAY_AGENT §2 / T2.
|
||||
*
|
||||
* These are intra-`agent/` seam *types only*. NO cross-plan frozen contract lives here —
|
||||
* those stay in `relay-contracts/`. Declaring them once at W0 lets W2/W3 transport tasks
|
||||
* (T7/T10/T12/T14) consume a stable type WITHOUT a task-level cycle (see §3 T10↔T14 note).
|
||||
*
|
||||
* This module MUST remain side-effect-free and runtime-dependency-free (type-only): a test
|
||||
* asserts importing it pulls in no `ws`/crypto runtime, so W2/W3 can depend on it freely.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal WS surface the transport layer needs. dial/tunnel/backoff/loopback share it so no
|
||||
* per-task redeclare and no drift. Adapters wrap the real `ws` socket into this shape.
|
||||
*/
|
||||
export interface WsLike {
|
||||
send(d: Uint8Array): void
|
||||
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
/** Why a host stopped tunnelling. `renewal-refused`/`goaway-revoked` ⇒ do NOT reconnect. */
|
||||
export type RevokeReason = 'renewal-refused' | 'goaway-revoked' | 'operator'
|
||||
|
||||
/**
|
||||
* Revocation seam — T14 IMPLEMENTS it; T10's reconnectLoop only CONSUMES `isRevoked()`.
|
||||
* One-way edge (T14 → wires T10's loop), so there is no task cycle.
|
||||
*/
|
||||
export interface RevocationState {
|
||||
isRevoked(): boolean
|
||||
markRevoked(reason: RevokeReason): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal timer seam so heartbeat (T9) / cert rotation (T13) are testable with fake timers
|
||||
* without depending on Node's global timer types leaking into the transport surface.
|
||||
*/
|
||||
export interface TimerLike {
|
||||
setTimeout(cb: () => void, ms: number): unknown
|
||||
clearTimeout(handle: unknown): void
|
||||
setInterval(cb: () => void, ms: number): unknown
|
||||
clearInterval(handle: unknown): void
|
||||
}
|
||||
148
agent/src/transport/streamRouter.ts
Normal file
148
agent/src/transport/streamRouter.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Stream router — PLAN_RELAY_AGENT T8. Maps §4.1 streamId ⇄ a loopback socket. Each OPEN gets a
|
||||
* FRESH per-stream allocation (socket + transform state); there are NO global mutable buffers, so
|
||||
* cross-tenant/cross-stream buffer bleed is structurally impossible (EXPLORE §4b failure #3).
|
||||
*
|
||||
* MANDATORY INV1 defense-in-depth: the FIRST thing handleOpen does — before any allocation or
|
||||
* dial — is compare MuxOpen.subdomain (§4.1) against this agent's enrolled subdomain. A mismatch
|
||||
* is RST and NEVER dialed (metadata-only audit log, INV10). Belt-and-suspenders to relay authz.
|
||||
*/
|
||||
import type { MuxOpen } from 'relay-contracts'
|
||||
import { MuxOpenSchema } from 'relay-contracts'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import type { Logger } from '../log/logger.js'
|
||||
import type { WsLike } from './seams.js'
|
||||
import type { DialLoopback } from './loopback.js'
|
||||
import { closeHeader, dataHeader, type Tunnel } from './tunnel.js'
|
||||
|
||||
/**
|
||||
* Per-stream cipher transform. Identity in v0.9 (plaintext passthrough); replaced by the E2E
|
||||
* codec in v0.10 (T15). `takeControlFrames` lets an E2E transform emit host→client control frames
|
||||
* (e.g. HostHello) that the router forwards upstream — no-op for the identity transform.
|
||||
*/
|
||||
export interface FrameTransform {
|
||||
inbound(streamId: number, cipher: Uint8Array): Uint8Array | null
|
||||
outbound(streamId: number, plain: Uint8Array): Uint8Array
|
||||
openStream(streamId: number): void
|
||||
closeStream(streamId: number): void
|
||||
takeControlFrames?(streamId: number): Uint8Array[]
|
||||
}
|
||||
|
||||
export const identityTransform: FrameTransform = {
|
||||
inbound: (_s, cipher) => cipher,
|
||||
outbound: (_s, plain) => plain,
|
||||
openStream: () => {},
|
||||
closeStream: () => {},
|
||||
}
|
||||
|
||||
export interface StreamRouter {
|
||||
handleOpen(open: MuxOpen): void
|
||||
handleData(streamId: number, payload: Uint8Array): void
|
||||
handleClose(streamId: number, rst: boolean): void
|
||||
activeStreamCount(): number
|
||||
}
|
||||
|
||||
interface StreamState {
|
||||
socket: WsLike | null
|
||||
readonly pending: Uint8Array[] // inbound bytes buffered until the loopback socket is open
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
export function createStreamRouter(
|
||||
cfg: AgentConfig,
|
||||
tunnel: Tunnel,
|
||||
dial: DialLoopback,
|
||||
transform: FrameTransform,
|
||||
logger: Logger,
|
||||
): StreamRouter {
|
||||
const streams = new Map<number, StreamState>()
|
||||
|
||||
function flushControlFrames(streamId: number): void {
|
||||
const frames = transform.takeControlFrames?.(streamId) ?? []
|
||||
for (const frame of frames) {
|
||||
tunnel.send(dataHeader(streamId, frame.length), frame)
|
||||
}
|
||||
}
|
||||
|
||||
function teardown(streamId: number, rst: boolean): void {
|
||||
const state = streams.get(streamId)
|
||||
if (state === undefined) return
|
||||
// Delete FIRST so a synchronous socket 'close' event can't re-enter this teardown.
|
||||
streams.delete(streamId)
|
||||
state.closed = true
|
||||
transform.closeStream(streamId)
|
||||
tunnel.send(closeHeader(streamId, rst), new Uint8Array(0))
|
||||
state.socket?.close()
|
||||
}
|
||||
|
||||
return {
|
||||
handleOpen(open: MuxOpen): void {
|
||||
// INV1 defense-in-depth — FIRST statement, before any allocation or dial.
|
||||
if (open.subdomain !== cfg.subdomain) {
|
||||
tunnel.sendRst(open.streamId)
|
||||
logger.log('error', 'open.subdomain mismatch — refusing to dial', { streamId: open.streamId })
|
||||
return
|
||||
}
|
||||
if (!MuxOpenSchema.safeParse(open).success) {
|
||||
tunnel.sendRst(open.streamId)
|
||||
return
|
||||
}
|
||||
if (streams.has(open.streamId)) {
|
||||
tunnel.sendRst(open.streamId) // duplicate OPEN for a live stream
|
||||
return
|
||||
}
|
||||
|
||||
const state: StreamState = { socket: null, pending: [], closed: false }
|
||||
streams.set(open.streamId, state)
|
||||
transform.openStream(open.streamId)
|
||||
|
||||
dial(open.requestPath, open.originHeader)
|
||||
.then((socket) => {
|
||||
if (state.closed) {
|
||||
socket.close()
|
||||
return
|
||||
}
|
||||
state.socket = socket
|
||||
// loopback output → transform.outbound → tunnel DATA
|
||||
socket.on('message', (data: unknown) => {
|
||||
if (!(data instanceof Uint8Array)) return
|
||||
const cipher = transform.outbound(open.streamId, data)
|
||||
tunnel.send(dataHeader(open.streamId, cipher.length), cipher)
|
||||
})
|
||||
socket.on('close', () => teardown(open.streamId, false))
|
||||
// flush anything buffered before the socket opened
|
||||
for (const buffered of state.pending) socket.send(buffered)
|
||||
state.pending.length = 0
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logger.log('error', 'loopback dial failed', { streamId: open.streamId })
|
||||
void err
|
||||
teardown(open.streamId, true)
|
||||
})
|
||||
},
|
||||
|
||||
handleData(streamId: number, payload: Uint8Array): void {
|
||||
const state = streams.get(streamId)
|
||||
if (state === undefined) {
|
||||
tunnel.sendRst(streamId) // DATA before OPEN / after CLOSE / unknown stream → RST
|
||||
return
|
||||
}
|
||||
const plain = transform.inbound(streamId, payload)
|
||||
flushControlFrames(streamId) // E2E: emit HostHello etc. (no-op in v0.9)
|
||||
if (plain === null) return // consumed (handshake), nothing to forward
|
||||
if (state.socket === null) {
|
||||
state.pending.push(plain)
|
||||
return
|
||||
}
|
||||
state.socket.send(plain)
|
||||
},
|
||||
|
||||
handleClose(streamId: number, rst: boolean): void {
|
||||
teardown(streamId, rst)
|
||||
},
|
||||
|
||||
activeStreamCount(): number {
|
||||
return streams.size
|
||||
},
|
||||
}
|
||||
}
|
||||
161
agent/src/transport/tunnel.ts
Normal file
161
agent/src/transport/tunnel.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* §4.1 tunnel holder — PLAN_RELAY_AGENT T7. Holds ONE physical mux over a WsLike socket:
|
||||
* encodes outbound frames, decodes inbound frames (via the FROZEN relay-contracts codec — never
|
||||
* re-implemented), and dispatches by type to the router (OPEN/DATA/CLOSE) or heartbeat
|
||||
* (PING/PONG) or connection-level control (GOAWAY/WINDOW_UPDATE, streamId 0).
|
||||
*
|
||||
* INV11: payloads are OPAQUE — no ANSI/terminal parsing here. Malformed frames RST the affected
|
||||
* stream (never the whole tunnel). After an inbound GOAWAY the tunnel DRAINS: no new OPEN.
|
||||
*
|
||||
* Design note (vs plan `holdTunnel(socket, router, heartbeat)`): to keep the construction graph
|
||||
* ACYCLIC (router/heartbeat are built WITH the tunnel), wiring is a two-phase `dispatchTo()`
|
||||
* call rather than constructor args. Same behavior, no task cycle. Recorded as a deviation.
|
||||
*/
|
||||
import {
|
||||
decodeGoaway,
|
||||
decodeMuxFrame,
|
||||
decodeOpen,
|
||||
encodeGoaway,
|
||||
encodeMuxFrame,
|
||||
} from 'relay-contracts'
|
||||
import type { GoAwayReason, MuxFrameHeader, MuxOpen } from 'relay-contracts'
|
||||
import type { WsLike } from './seams.js'
|
||||
|
||||
const EMPTY = new Uint8Array(0)
|
||||
|
||||
/** Handlers the tunnel dispatches decoded frames to (wired post-construction). */
|
||||
export interface StreamHandlers {
|
||||
handleOpen(open: MuxOpen): void
|
||||
handleData(streamId: number, payload: Uint8Array): void
|
||||
handleClose(streamId: number, rst: boolean): void
|
||||
}
|
||||
export interface HeartbeatSink {
|
||||
onPing(token: Uint8Array): void
|
||||
onPong(token: Uint8Array): void
|
||||
}
|
||||
|
||||
export interface Tunnel {
|
||||
send(header: MuxFrameHeader, payload: Uint8Array): void
|
||||
onFrame(cb: (h: MuxFrameHeader, payload: Uint8Array) => void): void
|
||||
onGoAway(cb: (reason: GoAwayReason) => void): void
|
||||
goAway(lastStreamId: number, reason: GoAwayReason): void
|
||||
sendRst(streamId: number): void
|
||||
dispatchTo(router: StreamHandlers, heartbeat: HeartbeatSink): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
// --- frame-header builders (shared across the transport layer) --------------------------------
|
||||
|
||||
export function dataHeader(streamId: number, payloadLen: number): MuxFrameHeader {
|
||||
return { version: 1, type: 'data', fin: false, rst: false, streamId, payloadLen }
|
||||
}
|
||||
export function closeHeader(streamId: number, rst: boolean): MuxFrameHeader {
|
||||
return { version: 1, type: 'close', fin: !rst, rst, streamId, payloadLen: 0 }
|
||||
}
|
||||
export function rstHeader(streamId: number): MuxFrameHeader {
|
||||
return closeHeader(streamId, true)
|
||||
}
|
||||
export function pingHeader(): MuxFrameHeader {
|
||||
return { version: 1, type: 'ping', fin: false, rst: false, streamId: 0, payloadLen: 8 }
|
||||
}
|
||||
export function pongHeader(): MuxFrameHeader {
|
||||
return { version: 1, type: 'pong', fin: false, rst: false, streamId: 0, payloadLen: 8 }
|
||||
}
|
||||
|
||||
function toU8(data: unknown): Uint8Array | null {
|
||||
if (data instanceof Uint8Array) return data
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
if (Array.isArray(data) && data[0] instanceof Uint8Array) return data[0] as Uint8Array
|
||||
return null
|
||||
}
|
||||
|
||||
export function holdTunnel(socket: WsLike): Tunnel {
|
||||
let frameCb: ((h: MuxFrameHeader, payload: Uint8Array) => void) | null = null
|
||||
let goAwayCb: ((reason: GoAwayReason) => void) | null = null
|
||||
let handlers: StreamHandlers | null = null
|
||||
let heartbeat: HeartbeatSink | null = null
|
||||
let draining = false
|
||||
|
||||
function send(header: MuxFrameHeader, payload: Uint8Array): void {
|
||||
socket.send(encodeMuxFrame(header, payload))
|
||||
}
|
||||
function sendRst(streamId: number): void {
|
||||
send(rstHeader(streamId), EMPTY)
|
||||
}
|
||||
|
||||
function dispatch(header: MuxFrameHeader, payload: Uint8Array): void {
|
||||
frameCb?.(header, payload)
|
||||
switch (header.type) {
|
||||
case 'ping':
|
||||
heartbeat?.onPing(payload)
|
||||
return
|
||||
case 'pong':
|
||||
heartbeat?.onPong(payload)
|
||||
return
|
||||
case 'goaway': {
|
||||
const { reason } = decodeGoaway(payload)
|
||||
draining = true
|
||||
goAwayCb?.(reason)
|
||||
return
|
||||
}
|
||||
case 'open': {
|
||||
if (draining) {
|
||||
sendRst(header.streamId) // drain: refuse new streams
|
||||
return
|
||||
}
|
||||
let open: MuxOpen
|
||||
try {
|
||||
open = decodeOpen(payload)
|
||||
} catch {
|
||||
sendRst(header.streamId) // malformed OPEN → RST that stream, tunnel stays up
|
||||
return
|
||||
}
|
||||
handlers?.handleOpen(open)
|
||||
return
|
||||
}
|
||||
case 'data':
|
||||
handlers?.handleData(header.streamId, payload)
|
||||
return
|
||||
case 'close':
|
||||
handlers?.handleClose(header.streamId, header.rst)
|
||||
return
|
||||
case 'windowUpdate':
|
||||
return // consumed by the flow controller at the wiring layer (T11)
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('message', (...args: unknown[]) => {
|
||||
const bytes = toU8(args[0])
|
||||
if (bytes === null) return
|
||||
let decoded: { header: MuxFrameHeader; payload: Uint8Array }
|
||||
try {
|
||||
decoded = decodeMuxFrame(bytes)
|
||||
} catch {
|
||||
return // malformed framing: drop the frame, keep the tunnel alive (robust framing)
|
||||
}
|
||||
dispatch(decoded.header, decoded.payload)
|
||||
})
|
||||
|
||||
return {
|
||||
send,
|
||||
sendRst,
|
||||
onFrame(cb): void {
|
||||
frameCb = cb
|
||||
},
|
||||
onGoAway(cb): void {
|
||||
goAwayCb = cb
|
||||
},
|
||||
goAway(lastStreamId: number, reason: GoAwayReason): void {
|
||||
draining = true
|
||||
const payload = encodeGoaway(lastStreamId, reason)
|
||||
send({ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: payload.length }, payload)
|
||||
},
|
||||
dispatchTo(router: StreamHandlers, hb: HeartbeatSink): void {
|
||||
handlers = router
|
||||
heartbeat = hb
|
||||
},
|
||||
close(): void {
|
||||
socket.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user