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:
147
term-relay/data-plane/agent-listener.ts
Normal file
147
term-relay/data-plane/agent-listener.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* T9 · Agent tunnel listener (INV4/INV7/INV12/INV14). Accepts the agent's mTLS dial-out, builds
|
||||
* a relay-side MuxSession per tunnel, and registers the route (P3) with a heartbeat TTL.
|
||||
*
|
||||
* Two-tier defense (Finding-4): the agent-facing TLS server is constructed with
|
||||
* `requestCert: true` AND `rejectUnauthorized: true` and a `ca` from config — so the TLS handshake
|
||||
* itself rejects a no-cert / non-chaining peer BEFORE `attach()`/`verifyPeer()` runs. `verifyPeer`
|
||||
* (P5) then binds the already-CA-validated cert's pubkey to the registry (INV14/INV4). No bearer/
|
||||
* shared-secret path exists. The node holds only an in-RAM Map (INV7); a crash loses only that.
|
||||
*/
|
||||
import type { DataPlaneConfig } from './config.js'
|
||||
import type { WebSocketLike } from './ws-like.js'
|
||||
import type { ScheduleHandle } from '../mux/heartbeat.js'
|
||||
import { createMuxSession, type MuxSession } from '../mux/mux-session.js'
|
||||
|
||||
export interface MtlsVerifier {
|
||||
// P5 impl — verifies the peer cert against the host registry pubkey (INV14).
|
||||
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 accountId: string // carried for account-scope revocation fan-out (T14)
|
||||
readonly session: MuxSession
|
||||
closeTunnel(): void
|
||||
}
|
||||
|
||||
/** Frozen mTLS server options — the ONLY shape the agent-facing TLS server is ever built with. */
|
||||
export interface TlsServerOptions {
|
||||
readonly requestCert: true
|
||||
readonly rejectUnauthorized: true
|
||||
readonly ca: readonly Uint8Array[]
|
||||
}
|
||||
|
||||
export interface TlsServerLike {
|
||||
close(): void
|
||||
}
|
||||
|
||||
export type TlsServerFactory = (
|
||||
opts: TlsServerOptions,
|
||||
onPeer: (ws: WebSocketLike, peerCert: Uint8Array) => void,
|
||||
) => TlsServerLike
|
||||
|
||||
export interface AgentListenerDeps {
|
||||
config: DataPlaneConfig
|
||||
mtls: MtlsVerifier
|
||||
registrar: RouteRegistrar
|
||||
onTunnel(t: AgentTunnel): void
|
||||
// CA bundle bytes (from config.agentCaCertPath/agentCaChainPath); injected for testability.
|
||||
caBundle: readonly Uint8Array[]
|
||||
// Optional seams (default: real setInterval; no TLS server started in unit tests).
|
||||
tlsServerFactory?: TlsServerFactory
|
||||
schedule?: (fn: () => void, ms: number) => ScheduleHandle
|
||||
onError?: (e: unknown) => void
|
||||
}
|
||||
|
||||
export interface AgentListener {
|
||||
attach(ws: WebSocketLike, peerCert: Uint8Array): void
|
||||
tunnels(): ReadonlyMap<string, AgentTunnel>
|
||||
close(): void
|
||||
}
|
||||
|
||||
function defaultSchedule(fn: () => void, ms: number): ScheduleHandle {
|
||||
const id = setInterval(fn, ms)
|
||||
id.unref?.()
|
||||
return { cancel: () => clearInterval(id) }
|
||||
}
|
||||
|
||||
export function createAgentListener(deps: AgentListenerDeps): AgentListener {
|
||||
const schedule = deps.schedule ?? defaultSchedule
|
||||
const onError = deps.onError ?? (() => {})
|
||||
const tunnels = new Map<string, AgentTunnel>()
|
||||
|
||||
function runAsync(p: Promise<void>): void {
|
||||
p.catch((e: unknown) => onError(e)) // boundary: route errors to onError, never swallow silently
|
||||
}
|
||||
|
||||
// TLS layer: presentation enforced HERE (requestCert + rejectUnauthorized). attach() is only ever
|
||||
// reached for a peer whose cert already chained to our CA.
|
||||
const server = deps.tlsServerFactory?.(
|
||||
{ requestCert: true, rejectUnauthorized: true, ca: deps.caBundle },
|
||||
(ws, peerCert) => attach(ws, peerCert),
|
||||
)
|
||||
|
||||
function attach(ws: WebSocketLike, peerCert: Uint8Array): void {
|
||||
const verified = deps.mtls.verifyPeer(peerCert) // app-layer bind (INV14) — after CA validation
|
||||
if (verified === null) {
|
||||
ws.close(4401) // cert chains to our CA but pubkey not in the host registry → no route
|
||||
return
|
||||
}
|
||||
const { hostId, accountId } = verified
|
||||
|
||||
// Agent reconnected on this node → replace the prior tunnel (latest wins).
|
||||
tunnels.get(hostId)?.closeTunnel()
|
||||
|
||||
const session: MuxSession = createMuxSession({
|
||||
role: 'relay',
|
||||
sendWire: (frame) => ws.send(frame),
|
||||
onOpen: () => {}, // relay never receives OPEN
|
||||
onData: () => {}, // inbound routed per-stream via the handle (relay-node subscribes)
|
||||
onClose: () => {},
|
||||
onDead: () => teardown(),
|
||||
maxFrameBytes: deps.config.maxFrameBytes,
|
||||
initialWindowBytes: deps.config.initialWindowBytes,
|
||||
})
|
||||
|
||||
let hbHandle: ScheduleHandle | null = schedule(() => {
|
||||
runAsync(deps.registrar.heartbeat(hostId))
|
||||
}, deps.config.heartbeatIntervalMs)
|
||||
|
||||
let torn = false
|
||||
function teardown(): void {
|
||||
if (torn) return
|
||||
torn = true
|
||||
hbHandle?.cancel()
|
||||
hbHandle = null
|
||||
session.close()
|
||||
ws.close()
|
||||
tunnels.delete(hostId)
|
||||
runAsync(deps.registrar.deregister(hostId)) // stateless: ingress stops routing here (INV7)
|
||||
}
|
||||
|
||||
ws.onMessage((bytes) => session.onWire(bytes))
|
||||
ws.onClose(() => teardown())
|
||||
|
||||
const tunnel: AgentTunnel = { hostId, accountId, session, closeTunnel: teardown }
|
||||
tunnels.set(hostId, tunnel)
|
||||
runAsync(deps.registrar.register(hostId, deps.config.relayNodeId, deps.config.routeTtlMs))
|
||||
deps.onTunnel(tunnel)
|
||||
}
|
||||
|
||||
return {
|
||||
attach,
|
||||
tunnels: () => tunnels,
|
||||
close: () => {
|
||||
for (const t of [...tunnels.values()]) t.closeTunnel()
|
||||
server?.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
41
term-relay/data-plane/authz-port.ts
Normal file
41
term-relay/data-plane/authz-port.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* P5 authorization SEAM (FIX 6a). These interfaces MIRROR relay-auth's frozen §6a shapes
|
||||
* (`UpgradeContext` / `AuthzOutcome` / `DpopContext` / `Authorizer`). P5 (`relay-auth/`) is not
|
||||
* built in this package's build graph, so — per the "program against interfaces / DI seams,
|
||||
* don't hard-import an unbuilt sibling" rule — P1 defines the port here and injects an impl.
|
||||
*
|
||||
* INTEGRATION POINT: when relay-auth lands, replace these local declarations with
|
||||
* `import type { UpgradeContext, AuthzOutcome, DpopContext, Authorizer } from 'relay-auth'`
|
||||
* (structurally identical). P1 holds NO authz logic — the decision is P5's alone.
|
||||
*/
|
||||
import type { CapabilityRight } from 'relay-contracts'
|
||||
|
||||
/** Proof-of-possession material P5 needs; P1 passes it through, never inspects it. */
|
||||
export interface DpopContext {
|
||||
readonly proof: string | null
|
||||
readonly publicKeyThumbprint: string | null
|
||||
}
|
||||
|
||||
/** Everything P5's `onUpgrade`/`onReattach` needs to make the deny-by-default decision. */
|
||||
export interface UpgradeContext {
|
||||
readonly capabilityRaw: string // opaque token string; P5 verifies the signature
|
||||
readonly originHeader: string // retained for Origin/CSWSH check (P5)
|
||||
readonly expectedAud: string // = the resolved subdomain (Host-confusion guard, INV1)
|
||||
readonly requestedHostId: string // P1 NAMES the resolved host; P5 gates token.host against it
|
||||
readonly requiredRight: CapabilityRight
|
||||
readonly remoteAddrHash: string // salted hash (audit only, INV10)
|
||||
readonly activeSessionCount: number
|
||||
readonly dpop: DpopContext
|
||||
readonly principal: string | null // P5 resolves from the signed token (INV3)
|
||||
}
|
||||
|
||||
/** P5's verdict. The `ok` branch carries the VERIFIED token's `jti` (OQ5 resolved). */
|
||||
export type AuthzOutcome =
|
||||
| { readonly ok: true; readonly hostId: string; readonly principal: string; readonly jti: string }
|
||||
| { readonly ok: false; readonly status: 401 | 403 }
|
||||
|
||||
/** The single authorizer (P5). P1 injects it and supplies the ctx; P5 makes every decision. */
|
||||
export interface Authorizer {
|
||||
onUpgrade(ctx: UpgradeContext, now: number): Promise<AuthzOutcome>
|
||||
onReattach(ctx: UpgradeContext & { readonly sessionId: string }, now: number): Promise<AuthzOutcome>
|
||||
}
|
||||
81
term-relay/data-plane/config.ts
Normal file
81
term-relay/data-plane/config.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* T1 · Data-plane config — env → frozen DataPlaneConfig (INV9 fail-fast, no secret logged).
|
||||
*
|
||||
* Startup MUST fail if TLS server material, the AGENT_CA_CERT_PATH mTLS trust-anchor, or
|
||||
* BASE_DOMAIN is absent (a missing CA would silently degrade "mTLS" to accept-any-cert,
|
||||
* Finding-4). Zod validates at the boundary; the returned object is frozen (immutable).
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
|
||||
const MAX_FRAME_BYTES_DEFAULT = 1024 * 1024 // 1 MiB (§4.1 payloadLen ceiling)
|
||||
const INITIAL_WINDOW_BYTES_DEFAULT = 256 * 1024 // 256 KiB (§4.1 credit window)
|
||||
const HEARTBEAT_INTERVAL_MS_DEFAULT = 15_000 // §4.1: PING every 15s
|
||||
const ROUTE_TTL_MS_DEFAULT = 45_000 // Redis route:{host_id} TTL
|
||||
const BIND_HOST_DEFAULT = '0.0.0.0'
|
||||
const BIND_PORT_DEFAULT = 8443
|
||||
const AGENT_BIND_PORT_DEFAULT = 8444
|
||||
|
||||
export interface DataPlaneConfig {
|
||||
readonly baseDomain: string
|
||||
readonly bindHost: string
|
||||
readonly bindPort: number
|
||||
readonly agentBindPort: number
|
||||
readonly tlsCertPath: string
|
||||
readonly tlsKeyPath: string
|
||||
readonly agentCaCertPath: string
|
||||
readonly agentCaChainPath: string
|
||||
readonly relayNodeId: string
|
||||
readonly maxFrameBytes: number
|
||||
readonly initialWindowBytes: number
|
||||
readonly heartbeatIntervalMs: number
|
||||
readonly routeTtlMs: number
|
||||
}
|
||||
|
||||
const requiredPath = z.string().min(1)
|
||||
const port = z.coerce.number().int().gt(0).max(65535)
|
||||
const positiveInt = z.coerce.number().int().positive()
|
||||
|
||||
const ConfigSchema = z.object({
|
||||
baseDomain: z.string().min(1),
|
||||
bindHost: z.string().min(1).default(BIND_HOST_DEFAULT),
|
||||
bindPort: port.default(BIND_PORT_DEFAULT),
|
||||
agentBindPort: port.default(AGENT_BIND_PORT_DEFAULT),
|
||||
tlsCertPath: requiredPath,
|
||||
tlsKeyPath: requiredPath,
|
||||
agentCaCertPath: requiredPath,
|
||||
agentCaChainPath: requiredPath,
|
||||
relayNodeId: z.string().min(1),
|
||||
maxFrameBytes: positiveInt.default(MAX_FRAME_BYTES_DEFAULT),
|
||||
initialWindowBytes: positiveInt.default(INITIAL_WINDOW_BYTES_DEFAULT),
|
||||
heartbeatIntervalMs: positiveInt.default(HEARTBEAT_INTERVAL_MS_DEFAULT),
|
||||
routeTtlMs: positiveInt.default(ROUTE_TTL_MS_DEFAULT),
|
||||
})
|
||||
|
||||
/**
|
||||
* Load + validate the data-plane config from `env`. Throws a clear, secret-free error
|
||||
* (fail-fast, INV9) when a required key is missing or a numeric key is non-numeric.
|
||||
* `agentCaChainPath` falls back to `agentCaCertPath` when unset (single-tier CA).
|
||||
*/
|
||||
export function loadDataPlaneConfig(env: NodeJS.ProcessEnv): DataPlaneConfig {
|
||||
const parsed = ConfigSchema.safeParse({
|
||||
baseDomain: env.BASE_DOMAIN,
|
||||
bindHost: env.BIND_HOST,
|
||||
bindPort: env.BIND_PORT,
|
||||
agentBindPort: env.AGENT_BIND_PORT,
|
||||
tlsCertPath: env.TLS_CERT_PATH,
|
||||
tlsKeyPath: env.TLS_KEY_PATH,
|
||||
agentCaCertPath: env.AGENT_CA_CERT_PATH,
|
||||
agentCaChainPath: env.AGENT_CA_CHAIN_PATH ?? env.AGENT_CA_CERT_PATH,
|
||||
relayNodeId: env.RELAY_NODE_ID,
|
||||
maxFrameBytes: env.MAX_FRAME_BYTES,
|
||||
initialWindowBytes: env.INITIAL_WINDOW_BYTES,
|
||||
heartbeatIntervalMs: env.HEARTBEAT_INTERVAL_MS,
|
||||
routeTtlMs: env.ROUTE_TTL_MS,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
// Report only WHICH keys are wrong — never echo values (INV9: no secret in logs).
|
||||
const fields = parsed.error.issues.map((i) => i.path.join('.')).join(', ')
|
||||
throw new Error(`invalid data-plane config: ${fields}`)
|
||||
}
|
||||
return Object.freeze(parsed.data)
|
||||
}
|
||||
73
term-relay/data-plane/drain.ts
Normal file
73
term-relay/data-plane/drain.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* T11 · Graceful drain + GOAWAY (INV7/INV12). GOAWAY on streamId 0 to every (or one) tunnel,
|
||||
* then teardown. The grace window is REASON-DIFFERENTIATED (Finding-2):
|
||||
* effectiveGraceMs = (reason === revoked) ? 0 : inFlightGraceMs
|
||||
* `revoked` is a SECURITY action against a compromised host/device — it forces immediate teardown
|
||||
* (RST/close now, deregister now) regardless of any caller-supplied grace, so INV12's "its live
|
||||
* tunnel drops within seconds" holds even when operator-drain grace is tuned to tens of seconds.
|
||||
* `operatorDrain`/`shutdown` keep a grace window purely for clean migration.
|
||||
*/
|
||||
import type { AgentTunnel, RouteRegistrar } from './agent-listener.js'
|
||||
|
||||
/** Drain reason ⇄ §4.1 GOAWAY wire code (frozen in relay-contracts as GOAWAY_REASON_TO_CODE). */
|
||||
export const DRAIN_REASON = { operatorDrain: 1, revoked: 2, shutdown: 3 } as const
|
||||
export type DrainReason = (typeof DRAIN_REASON)[keyof typeof DRAIN_REASON]
|
||||
|
||||
export interface DrainDeps {
|
||||
tunnels(): ReadonlyMap<string, AgentTunnel>
|
||||
registrar: RouteRegistrar
|
||||
reason: DrainReason
|
||||
inFlightGraceMs: number
|
||||
// Injected timer for the grace window (default global setTimeout); testability seam.
|
||||
setTimeoutFn?: (fn: () => void, ms: number) => unknown
|
||||
}
|
||||
|
||||
export interface DrainHostDeps extends DrainDeps {
|
||||
// single-host target resolved by drainHost's first arg
|
||||
}
|
||||
|
||||
function effectiveGrace(reason: DrainReason, inFlightGraceMs: number): number {
|
||||
return reason === DRAIN_REASON.revoked ? 0 : Math.max(0, inFlightGraceMs)
|
||||
}
|
||||
|
||||
async function tearDownTunnel(t: AgentTunnel, registrar: RouteRegistrar): Promise<void> {
|
||||
t.closeTunnel() // socket close (agent side sees the streams die)
|
||||
await registrar.deregister(t.hostId) // ingress stops routing to this host on this node
|
||||
}
|
||||
|
||||
/** GOAWAY then teardown one tunnel, honoring the reason-differentiated grace. */
|
||||
function drainOne(
|
||||
t: AgentTunnel,
|
||||
reason: DrainReason,
|
||||
inFlightGraceMs: number,
|
||||
registrar: RouteRegistrar,
|
||||
setTimeoutFn: (fn: () => void, ms: number) => unknown,
|
||||
): Promise<void> {
|
||||
t.session.drain(0, reason) // §4.1 GOAWAY on streamId 0; refuses new streams thereafter
|
||||
const grace = effectiveGrace(reason, inFlightGraceMs)
|
||||
if (grace === 0) {
|
||||
return tearDownTunnel(t, registrar) // immediate (revoked, or zero-grace operator drain)
|
||||
}
|
||||
setTimeoutFn(() => {
|
||||
void tearDownTunnel(t, registrar)
|
||||
}, grace)
|
||||
return Promise.resolve() // grace scheduled; drain initiated
|
||||
}
|
||||
|
||||
/** Drain EVERY tunnel on the node (operator drain / shutdown / node-wide revocation). */
|
||||
export async function drainNode(deps: DrainDeps): Promise<void> {
|
||||
const setTimeoutFn = deps.setTimeoutFn ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
await Promise.all(
|
||||
[...deps.tunnels().values()].map((t) =>
|
||||
drainOne(t, deps.reason, deps.inFlightGraceMs, deps.registrar, setTimeoutFn),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Drain exactly ONE host, leaving siblings running (whole-host revocation / operator drain, INV12). */
|
||||
export async function drainHost(hostId: string, deps: DrainHostDeps): Promise<void> {
|
||||
const setTimeoutFn = deps.setTimeoutFn ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const t = deps.tunnels().get(hostId)
|
||||
if (t === undefined) return // no-op: already gone (deny-by-default, never a broader kill)
|
||||
await drainOne(t, deps.reason, deps.inFlightGraceMs, deps.registrar, setTimeoutFn)
|
||||
}
|
||||
130
term-relay/data-plane/relay-node.ts
Normal file
130
term-relay/data-plane/relay-node.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* T10 · Stateless relay node + opaque splice (INV2/INV5/INV7/INV11 core). Browser upgrade →
|
||||
* authorize (P5 via T8) → openStream on the host's agent tunnel → OPAQUE byte splice. The node
|
||||
* NEVER decodes DATA (INV2/INV11): a byte is a byte. Buffers are per-connection (each ws↔stream
|
||||
* pair) — NO global mutable buffer pool (INV5, kills cross-tenant buffer bleed). All maps are
|
||||
* in-RAM only (INV7): a crash loses nothing; agents reconnect + re-register.
|
||||
*
|
||||
* closeStream (Finding-3/INV12): a per-`jti` splice index lets P5 RST exactly the stream(s) under
|
||||
* ONE revoked capability token, leaving the owner's other devices (different `jti`s) attached —
|
||||
* removing the "nuke everyone or nothing" false choice.
|
||||
*/
|
||||
import type { WebSocketLike } from './ws-like.js'
|
||||
import type { AgentListener } from './agent-listener.js'
|
||||
import type { DataPlaneConfig } from './config.js'
|
||||
import type { UpgradeRequest, UpgradeDecision } from './upgrade.js'
|
||||
import type { MuxStreamHandle } from '../mux/mux-session.js'
|
||||
|
||||
const WS_TRY_LATER = 1013 // agent offline for this host
|
||||
const WS_REVOKED = 4403 // single-device revocation close code
|
||||
|
||||
interface Splice {
|
||||
readonly hostId: string
|
||||
readonly streamId: number
|
||||
readonly jti: string
|
||||
readonly ws: WebSocketLike
|
||||
readonly stream: MuxStreamHandle
|
||||
}
|
||||
|
||||
export type StreamSelector = { readonly jti: string } | { readonly streamId: number }
|
||||
|
||||
export interface RelayNode {
|
||||
handleAgentTunnel(ws: WebSocketLike, peerCert: Uint8Array): void
|
||||
handleBrowserUpgrade(req: UpgradeRequest, ws: WebSocketLike): Promise<void>
|
||||
drain(reason: number): Promise<void>
|
||||
closeTunnel(hostId: string): void
|
||||
closeStream(hostId: string, selector: StreamSelector): number
|
||||
}
|
||||
|
||||
export interface RelayNodeDeps {
|
||||
config: DataPlaneConfig
|
||||
listener: AgentListener
|
||||
authorize(req: UpgradeRequest): Promise<UpgradeDecision>
|
||||
}
|
||||
|
||||
function selectorMatches(splice: Splice, selector: StreamSelector): boolean {
|
||||
return 'jti' in selector ? splice.jti === selector.jti : splice.streamId === selector.streamId
|
||||
}
|
||||
|
||||
export function createRelayNode(deps: RelayNodeDeps): RelayNode {
|
||||
// In-RAM splice index (INV7). No durable buffers, no global pool (INV5).
|
||||
const splices = new Set<Splice>()
|
||||
|
||||
function removeSplice(splice: Splice): void {
|
||||
splices.delete(splice)
|
||||
}
|
||||
|
||||
return {
|
||||
handleAgentTunnel(ws, peerCert) {
|
||||
deps.listener.attach(ws, peerCert) // TLS+mTLS already enforced upstream (T9)
|
||||
},
|
||||
|
||||
async handleBrowserUpgrade(req, ws) {
|
||||
const decision = await deps.authorize(req)
|
||||
if (!decision.ok) {
|
||||
ws.close(decision.status) // INV6/INV15: only P5's {ok:true} opens a stream
|
||||
return
|
||||
}
|
||||
const tunnel = deps.listener.tunnels().get(decision.hostId) // strictly by token-derived hostId (INV1/INV3)
|
||||
if (tunnel === undefined) {
|
||||
ws.close(WS_TRY_LATER) // agent offline; no crash
|
||||
return
|
||||
}
|
||||
const stream = tunnel.session.openStream(decision.open) // relay allocates streamId
|
||||
const splice: Splice = {
|
||||
hostId: decision.hostId,
|
||||
streamId: stream.streamId,
|
||||
jti: decision.open.capabilityTokenRef,
|
||||
ws,
|
||||
stream,
|
||||
}
|
||||
splices.add(splice)
|
||||
|
||||
// OPAQUE splice — verbatim Uint8Array both ways (INV2), per-connection only (INV5).
|
||||
stream.onData((bytes) => ws.send(bytes)) // agent → browser
|
||||
stream.onClose(() => {
|
||||
ws.close()
|
||||
removeSplice(splice)
|
||||
})
|
||||
ws.onMessage((bytes) => stream.writeData(bytes)) // browser → agent
|
||||
ws.onClose(() => {
|
||||
stream.close()
|
||||
removeSplice(splice)
|
||||
})
|
||||
},
|
||||
|
||||
async drain(reason) {
|
||||
// Whole-node drain: GOAWAY every tunnel, then close (deregisters via the listener's teardown).
|
||||
for (const t of [...deps.listener.tunnels().values()]) {
|
||||
t.session.drain(0, reason)
|
||||
t.closeTunnel()
|
||||
}
|
||||
await Promise.resolve()
|
||||
},
|
||||
|
||||
closeTunnel(hostId) {
|
||||
// Whole-host revocation lever — kicks every device on the host.
|
||||
for (const splice of [...splices]) {
|
||||
if (splice.hostId === hostId) {
|
||||
splice.ws.close(WS_REVOKED)
|
||||
removeSplice(splice)
|
||||
}
|
||||
}
|
||||
deps.listener.tunnels().get(hostId)?.closeTunnel()
|
||||
},
|
||||
|
||||
closeStream(hostId, selector) {
|
||||
// Single-device revocation (Finding-3): RST exactly the matching stream(s); unknown → 0 (no-op).
|
||||
let count = 0
|
||||
for (const splice of [...splices]) {
|
||||
if (splice.hostId !== hostId) continue
|
||||
if (!selectorMatches(splice, selector)) continue
|
||||
splice.stream.close(true) // RST just this stream via the mux (tunnel stays up)
|
||||
splice.ws.close(WS_REVOKED)
|
||||
removeSplice(splice)
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
},
|
||||
}
|
||||
}
|
||||
83
term-relay/data-plane/revocation-subscriber.ts
Normal file
83
term-relay/data-plane/revocation-subscriber.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* T14 · Revocation subscriber (INV12 data-plane teardown owner, FIX 4). Subscribes to the frozen
|
||||
* §4.2 `relay:revocations` bus and maps a `KillSignal` onto the EXISTING §4.1 wire (no new frame
|
||||
* type) via T11's `drainHost(…, revoked)` — immediate teardown (grace forced to 0) within
|
||||
* `REVOCATION_PUSH_BUDGET_MS`. P3/P5 PUBLISH; every P1 node SUBSCRIBES (P1 never imports P5 —
|
||||
* the KillSignal shape lives in relay-contracts, keeping the DAG acyclic).
|
||||
*
|
||||
* Security: 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). `signal.reason` is metadata only, never
|
||||
* written as terminal payload/log content (INV10). A malformed signal is dropped + counted — a
|
||||
* poisoned message can neither kill the bus nor trigger a spurious teardown.
|
||||
*/
|
||||
import {
|
||||
KillSignalSchema,
|
||||
RELAY_REVOCATIONS_CHANNEL,
|
||||
type KillSignal,
|
||||
type RevocationScope,
|
||||
} from 'relay-contracts'
|
||||
import type { AgentTunnel } from './agent-listener.js'
|
||||
import { DRAIN_REASON } from './drain.js'
|
||||
|
||||
export interface Subscription {
|
||||
close(): void
|
||||
}
|
||||
|
||||
export interface RevocationSubscriberDeps {
|
||||
subscribe(channel: string, onMessage: (raw: string) => void): Subscription
|
||||
drainHost(hostId: string, reason: typeof DRAIN_REASON.revoked): Promise<void>
|
||||
tunnels(): ReadonlyMap<string, AgentTunnel>
|
||||
now: () => number
|
||||
onApplied?(signal: KillSignal, hostsAffected: number, elapsedMs: number): void
|
||||
onDropped?(): void // malformed-signal counter (metadata only, INV10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure scope→hosts selector: which of THIS node's live hosts a signal hits. `accountOf` maps a
|
||||
* live hostId to its accountId (carried on the tunnel). Testable in isolation.
|
||||
*/
|
||||
export function hostsForScope(
|
||||
scope: RevocationScope,
|
||||
liveHosts: ReadonlyMap<string, AgentTunnel>,
|
||||
accountOf: (hostId: string) => string | undefined,
|
||||
): readonly string[] {
|
||||
switch (scope.kind) {
|
||||
case 'host':
|
||||
return liveHosts.has(scope.hostId) ? [scope.hostId] : []
|
||||
case 'account':
|
||||
return [...liveHosts.keys()].filter((h) => accountOf(h) === scope.accountId)
|
||||
case 'global':
|
||||
return [...liveHosts.keys()]
|
||||
}
|
||||
}
|
||||
|
||||
function parseKillSignal(raw: string): KillSignal | null {
|
||||
let json: unknown
|
||||
try {
|
||||
json = JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const result = KillSignalSchema.safeParse(json)
|
||||
return result.success ? result.data : null
|
||||
}
|
||||
|
||||
export function startRevocationSubscriber(deps: RevocationSubscriberDeps): Subscription {
|
||||
const accountOf = (hostId: string): string | undefined => deps.tunnels().get(hostId)?.accountId
|
||||
|
||||
const sub = deps.subscribe(RELAY_REVOCATIONS_CHANNEL, (raw) => {
|
||||
const startedAt = deps.now()
|
||||
const signal = parseKillSignal(raw)
|
||||
if (signal === null) {
|
||||
deps.onDropped?.() // dropped + counted; bus stays alive, no teardown fired
|
||||
return
|
||||
}
|
||||
const hosts = hostsForScope(signal.scope, deps.tunnels(), accountOf)
|
||||
// Fire immediate teardown (reason=revoked ⇒ grace forced to 0 in T11) for each in-scope host.
|
||||
void Promise.all(hosts.map((hostId) => deps.drainHost(hostId, DRAIN_REASON.revoked))).then(() => {
|
||||
deps.onApplied?.(signal, hosts.length, deps.now() - startedAt)
|
||||
})
|
||||
})
|
||||
|
||||
return { close: () => sub.close() }
|
||||
}
|
||||
43
term-relay/data-plane/subdomain-router.ts
Normal file
43
term-relay/data-plane/subdomain-router.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* T7 · Subdomain router (§4.2 routing). `extractSubdomain` returns the SINGLE leftmost label
|
||||
* of `host` under `baseDomain`, or null for a bare/nested/confusing Host (INV1 Host-confusion
|
||||
* guard). It is a HINT for candidate lookup only — NEVER the authority: T8 requires the
|
||||
* capability token's `aud` to equal this subdomain and P5 gates `token.host` against the
|
||||
* resolver's `hostId`. `RouteResolver` is IMPLEMENTED BY P3; P1 owns only the interface.
|
||||
*/
|
||||
export interface ResolvedHost {
|
||||
readonly hostId: string
|
||||
readonly accountId: string
|
||||
readonly subdomain: string
|
||||
}
|
||||
|
||||
export interface RouteResolver {
|
||||
resolveSubdomain(subdomain: string): Promise<ResolvedHost | null>
|
||||
}
|
||||
|
||||
const LABEL_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/
|
||||
|
||||
/** Strip a single trailing dot and lowercase; strip a `:port` suffix if present. */
|
||||
function normalizeHost(host: string): string {
|
||||
const noPort = host.split(':', 1)[0] ?? host
|
||||
const trimmed = noPort.endsWith('.') ? noPort.slice(0, -1) : noPort
|
||||
return trimmed.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the single tenant label: `'alice.term.example.com'` under `'term.example.com'` → `'alice'`.
|
||||
* Returns null when: host equals the base domain (bare), the suffix does not match, or the prefix
|
||||
* is multi-label (`'a.b.term.example.com'` → null — nested subdomains are rejected, INV1).
|
||||
*/
|
||||
export function extractSubdomain(hostHeader: string, baseDomain: string): string | null {
|
||||
const host = normalizeHost(hostHeader)
|
||||
const base = normalizeHost(baseDomain)
|
||||
if (host === base) return null // bare base domain — no tenant
|
||||
const suffix = '.' + base
|
||||
if (!host.endsWith(suffix)) return null // confusion attempt (e.g. '...example.com.evil.com')
|
||||
const label = host.slice(0, host.length - suffix.length)
|
||||
if (label.length === 0) return null
|
||||
if (label.includes('.')) return null // multi-label prefix rejected (single-label tenant only)
|
||||
if (!LABEL_RE.test(label)) return null // invalid DNS label
|
||||
return label
|
||||
}
|
||||
124
term-relay/data-plane/upgrade.ts
Normal file
124
term-relay/data-plane/upgrade.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* T8 · Upgrade authorization edge — a THIN ADAPTER over P5 (FIX 6a). Holds NO independent authz
|
||||
* logic: it (a) parses the upgrade (subdomain + FIX-5 token extraction + salted remoteAddr hash),
|
||||
* (b) resolves the subdomain → requestedHostId via P3's RouteResolver, (c) builds P5's
|
||||
* UpgradeContext, (d) calls the injected P5 Authorizer, (e) maps AuthzOutcome → §4.1 MuxOpen or a
|
||||
* 401/403. Every accept/reject verdict is P5's; the only LOCAL failures are the three parse guards.
|
||||
*
|
||||
* Security: capability token rides ONLY the two FIX-5 carriers (subprotocol PREFERRED, cookie
|
||||
* FALLBACK) — NEVER the query string (bearer-equivalent secrets must not leak into logs/history/
|
||||
* Referer). The accepted subprotocol echoed back is ALWAYS `APP_SUBPROTOCOL`, never the token
|
||||
* entry (INV15). Identity (`host_id`/`account_id`) derives only from the signed token + registry
|
||||
* via P5 (INV3). Logging discipline (INV9): callers MUST NOT log req.url / Authorization /
|
||||
* Sec-WebSocket-Protocol — only `{subdomain, hostId, jti, decision, ts}`.
|
||||
*/
|
||||
import { createHmac } from 'node:crypto'
|
||||
import { APP_SUBPROTOCOL, extractTokenFromSubprotocols, type MuxOpen, type CapabilityRight } from 'relay-contracts'
|
||||
import { extractSubdomain, type RouteResolver } from './subdomain-router.js'
|
||||
import type { Authorizer, UpgradeContext } from './authz-port.js'
|
||||
|
||||
/** Short-lived HttpOnly; Secure; SameSite=Strict cookie carrying the capability token (fallback). */
|
||||
export const TOKEN_COOKIE_NAME = 'term_cap'
|
||||
|
||||
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 transport)
|
||||
readonly cookies: Readonly<Record<string, string>> // parsed Cookie header (fallback transport)
|
||||
readonly remoteAddr: string
|
||||
readonly dpop: UpgradeContext['dpop']
|
||||
readonly activeSessionCount: number
|
||||
readonly sessionId?: string // present ⇒ reattach path (→ onReattach)
|
||||
}
|
||||
|
||||
export type UpgradeDecision =
|
||||
| {
|
||||
readonly ok: true
|
||||
readonly open: MuxOpen
|
||||
readonly hostId: string
|
||||
readonly acceptedSubprotocol: string
|
||||
}
|
||||
| { readonly ok: false; readonly status: 401 | 403 }
|
||||
|
||||
export interface AuthorizeDeps {
|
||||
authorizer: Authorizer
|
||||
resolver: RouteResolver
|
||||
baseDomain: string
|
||||
now: () => number
|
||||
remoteAddrSalt: string
|
||||
requiredRight: CapabilityRight
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the capability token from EXACTLY the two allowed FIX-5 carriers, in order:
|
||||
* (1) `Sec-WebSocket-Protocol` `term.token.<b64u>` entry, (2) the `term_cap` cookie. Returns the
|
||||
* opaque token + which carrier, or null. NEVER reads `req.url` (query-string tokens forbidden).
|
||||
*/
|
||||
export function extractCapabilityToken(
|
||||
req: UpgradeRequest,
|
||||
): { token: string; via: 'subprotocol' | 'cookie' } | null {
|
||||
const fromSub = extractTokenFromSubprotocols(req.subprotocols)
|
||||
if (fromSub !== null && fromSub.length > 0) return { token: fromSub, via: 'subprotocol' }
|
||||
const fromCookie = req.cookies[TOKEN_COOKIE_NAME]
|
||||
if (fromCookie !== undefined && fromCookie.length > 0) return { token: fromCookie, via: 'cookie' }
|
||||
return null
|
||||
}
|
||||
|
||||
/** Salted HMAC-SHA256 of the client IP, truncated — audit-only, never reversible (INV10). */
|
||||
function hashRemoteAddr(salt: string, remoteAddr: string): string {
|
||||
return createHmac('sha256', salt).update(remoteAddr).digest('hex').slice(0, 32)
|
||||
}
|
||||
|
||||
/** The opaque request path (everything the relay forwards but NEVER parses for authz). */
|
||||
function pathOf(url: string): string {
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse → resolve → build ctx → delegate to P5 → map. No decision branches beyond the three
|
||||
* LOCAL parse guards (unparseable Host → 401, no token carrier → 401, unknown subdomain → 403).
|
||||
*/
|
||||
export async function authorizeUpgrade(
|
||||
req: UpgradeRequest,
|
||||
deps: AuthorizeDeps,
|
||||
): Promise<UpgradeDecision> {
|
||||
const subdomain = extractSubdomain(req.host, deps.baseDomain)
|
||||
if (subdomain === null) return { ok: false, status: 401 } // unparseable Host (local)
|
||||
|
||||
const extracted = extractCapabilityToken(req)
|
||||
if (extracted === null) return { ok: false, status: 401 } // no token carrier present (local)
|
||||
|
||||
const resolved = await deps.resolver.resolveSubdomain(subdomain)
|
||||
if (resolved === null) return { ok: false, status: 403 } // unknown subdomain (local)
|
||||
|
||||
const remoteAddrHash = hashRemoteAddr(deps.remoteAddrSalt, req.remoteAddr)
|
||||
const ctx: UpgradeContext = {
|
||||
capabilityRaw: extracted.token,
|
||||
originHeader: req.origin ?? '',
|
||||
expectedAud: subdomain,
|
||||
requestedHostId: resolved.hostId, // P5 gates token.host against this (INV1)
|
||||
requiredRight: deps.requiredRight,
|
||||
remoteAddrHash,
|
||||
activeSessionCount: req.activeSessionCount,
|
||||
dpop: req.dpop,
|
||||
principal: null, // P5 resolves from the signed token (INV3)
|
||||
}
|
||||
|
||||
const outcome =
|
||||
req.sessionId !== undefined
|
||||
? await deps.authorizer.onReattach({ ...ctx, sessionId: req.sessionId }, deps.now())
|
||||
: await deps.authorizer.onUpgrade(ctx, deps.now())
|
||||
|
||||
if (!outcome.ok) return { ok: false, status: outcome.status }
|
||||
|
||||
const open: MuxOpen = {
|
||||
streamId: 0, // allocated by MuxSession
|
||||
subdomain,
|
||||
requestPath: pathOf(req.url),
|
||||
originHeader: req.origin ?? '',
|
||||
remoteAddrHash,
|
||||
capabilityTokenRef: outcome.jti, // OQ5 resolved: P5 surfaces the verified jti
|
||||
}
|
||||
return { ok: true, open, hostId: outcome.hostId, acceptedSubprotocol: APP_SUBPROTOCOL }
|
||||
}
|
||||
11
term-relay/data-plane/ws-like.ts
Normal file
11
term-relay/data-plane/ws-like.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Minimal WebSocket abstraction the data plane splices over. Kept as an injected interface so
|
||||
* the mux/data-plane code has NO hard dependency on the `ws` package (pure + unit-testable);
|
||||
* the composition root adapts a real `ws` socket to this shape.
|
||||
*/
|
||||
export interface WebSocketLike {
|
||||
send(data: Uint8Array): void
|
||||
close(code?: number): void
|
||||
onMessage(handler: (data: Uint8Array) => void): void
|
||||
onClose(handler: () => void): void
|
||||
}
|
||||
Reference in New Issue
Block a user