feat(relay-run): Phase 0 single-host relay — real data-plane wiring + integration test

New relay-run/ package boots the REAL term-relay relay-node between a browser WSS
listener and an agent mTLS listener, delegating every authz verdict to the real
relay-auth onUpgrade (Origin/CSWSH + capability verify + DPoP PoP + single-use jti),
with the real P4 E2E crypto. No audited package modified.

- P1 (done): in-process integration test round-trips a sealed payload both ways
  through the real createRelayNode splice + real createMuxSession; INV2 asserted
  (relay sees only ciphertext); negative controls (foreign Origin / missing DPoP -> 401).
- P2 (boots): main.ts brings up self-signed TLS + 3 listeners + in-memory control-plane.
- P3 (not landed): no agent dial / node-pty / relay-web serve yet.
- 4 tests green, tsc clean. node_modules via symlinks (gitignored).

Two real seam drifts surfaced (documented in PLAN_RELAY_RUN_PHASE0, not hacked):
(1) term-relay authz-port DpopContext shape vs relay-auth — reconciled (server-derived htu/htm);
(2) control-plane CapabilityVerifier.verify is sync but relay-auth verify is async — blocks
    HTTP account/pairing seeding until a 1-line async widen.
This commit is contained in:
Yaojia Wang
2026-07-04 14:13:04 +02:00
parent 3d17bed322
commit ba85871227
17 changed files with 1239 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
/**
* Real agent-facing mTLS server for `main.ts`. A `TlsServerFactory` that stands up an https server
* (`requestCert: true`, `rejectUnauthorized: true`, pinned dev CA) with a `ws` WebSocketServer over
* it, so an agent dials `wss://` under mTLS. The TLS layer rejects a no-cert / non-chaining peer
* BEFORE `attach()` runs (Finding-4 two-tier defense); `verifyPeer` (P5) then binds the cert to the
* registry. Every connection's DER cert is handed to the relay's `onPeer`.
*/
import { readFileSync } from 'node:fs'
import { createServer } from 'node:https'
import type { TLSSocket } from 'node:tls'
import type { IncomingMessage } from 'node:http'
import { WebSocketServer, type WebSocket as WsWebSocket } from 'ws'
import type { TlsServerFactory, TlsServerLike } from 'term-relay/data-plane/agent-listener.js'
import { wsToWebSocketLike } from '../wiring/socket-pipe.js'
export interface AgentTlsOptions {
readonly serverCertPath: string
readonly serverKeyPath: string
readonly bindHost: string
readonly bindPort: number
readonly onListening?: () => void
readonly onError?: (e: unknown) => void
}
export function makeAgentTlsServerFactory(opts: AgentTlsOptions): TlsServerFactory {
return (tlsOpts, onPeer): TlsServerLike => {
const server = createServer({
cert: readFileSync(opts.serverCertPath),
key: readFileSync(opts.serverKeyPath),
ca: tlsOpts.ca.map((b) => Buffer.from(b)),
requestCert: tlsOpts.requestCert,
rejectUnauthorized: tlsOpts.rejectUnauthorized,
})
const wss = new WebSocketServer({ server })
wss.on('connection', (ws: WsWebSocket, req: IncomingMessage) => {
const peer = (req.socket as TLSSocket).getPeerCertificate(true)
const der = peer && peer.raw ? new Uint8Array(peer.raw) : new Uint8Array()
onPeer(wsToWebSocketLike(ws), der)
})
server.on('error', (e) => opts.onError?.(e))
server.listen(opts.bindPort, opts.bindHost, () => opts.onListening?.())
return {
close: () => {
wss.close()
server.close()
},
}
}
}

View File

@@ -0,0 +1,77 @@
/**
* Browser-facing WSS server for `main.ts`. Terminates HTTPS on the tenant origin and, on WS
* upgrade, builds the P1 `UpgradeRequest` from the raw request headers and hands it to the REAL
* relay-node (→ real `authorizeUpgrade` → P5 `onUpgrade`: Origin/CSWSH + capability verify + DPoP).
* The handshake echoes ONLY `APP_SUBPROTOCOL` — never the token entry (INV15).
*/
import { readFileSync } from 'node:fs'
import { createServer, type Server } from 'node:https'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { WebSocketServer, type WebSocket as WsWebSocket } from 'ws'
import { APP_SUBPROTOCOL } from 'relay-contracts'
import type { UpgradeRequest } from 'term-relay/data-plane/upgrade.js'
import type { RelayNode } from 'term-relay/data-plane/relay-node.js'
import { wsToWebSocketLike } from '../wiring/socket-pipe.js'
export interface BrowserServerOptions {
readonly certPath: string
readonly keyPath: string
readonly bindHost: string
readonly bindPort: number
readonly node: RelayNode
readonly landingHtml: string
readonly onListening?: () => void
readonly onError?: (e: unknown) => void
}
function parseCookies(header: string | undefined): Record<string, string> {
const out: Record<string, string> = {}
if (!header) return out
for (const part of header.split(';')) {
const idx = part.indexOf('=')
if (idx === -1) continue
out[part.slice(0, idx).trim()] = part.slice(idx + 1).trim()
}
return out
}
function buildUpgradeRequest(req: IncomingMessage): UpgradeRequest {
const proto = req.headers['sec-websocket-protocol']
const subprotocols = (typeof proto === 'string' ? proto.split(',') : [])
.map((v) => v.trim())
.filter((v) => v.length > 0)
const dpopHeader = req.headers['dpop']
return {
host: req.headers.host ?? '',
origin: typeof req.headers.origin === 'string' ? req.headers.origin : undefined,
url: req.url ?? '/',
subprotocols,
cookies: parseCookies(req.headers.cookie),
remoteAddr: req.socket.remoteAddress ?? '',
dpop: { proof: typeof dpopHeader === 'string' ? dpopHeader : null, publicKeyThumbprint: null },
activeSessionCount: 0,
}
}
export function startBrowserServer(opts: BrowserServerOptions): Server {
const server = createServer(
{ cert: readFileSync(opts.certPath), key: readFileSync(opts.keyPath) },
(_req: IncomingMessage, res: ServerResponse) => {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
res.end(opts.landingHtml)
},
)
const wss = new WebSocketServer({
server,
handleProtocols: (protocols: Set<string>) =>
protocols.has(APP_SUBPROTOCOL) ? APP_SUBPROTOCOL : false,
})
wss.on('connection', (ws: WsWebSocket, req: IncomingMessage) => {
opts.node
.handleBrowserUpgrade(buildUpgradeRequest(req), wsToWebSocketLike(ws))
.catch((e) => opts.onError?.(e))
})
server.on('error', (e) => opts.onError?.(e))
server.listen(opts.bindPort, opts.bindHost, () => opts.onListening?.())
return server
}

View File

@@ -0,0 +1,41 @@
/**
* Best-effort control-plane (P3) boot for `main.ts`: in-memory stores + the dev in-process CA
* signer, listening for admin/enroll HTTP. Wrapped so a failure degrades gracefully (the data-plane
* is the security-critical surface and boots independently).
*
* KNOWN SEAM (reported, not hacked): P3's injected `CapabilityVerifier.verify` is SYNCHRONOUS, but
* relay-auth's real `verifyCapabilityToken` is ASYNC (WebCrypto). The real P5 verifier therefore
* cannot be injected without an additive change to control-plane, so the admin routes run on the
* default fail-closed verifier here (deny-by-default, never bypassed). Programmatic account/pairing
* seeding through those routes is consequently BLOCKED in Phase 0 — see the report.
*/
import { buildControlPlane } from 'control-plane'
import { loadEnv } from 'control-plane/src/env.js'
import { createMemoryStores } from 'control-plane/src/store/memory.js'
export interface ControlPlaneBootOptions {
readonly publicRaw: Uint8Array
readonly baseDomain: string
readonly caCertPath: string
readonly bindHost: string
readonly bindPort: number
}
export interface ControlPlaneHandle {
close(): Promise<void>
}
export async function bootControlPlane(opts: ControlPlaneBootOptions): Promise<ControlPlaneHandle> {
const env = loadEnv({
PG_URL: 'postgres://localhost:5432/relay_run_dev',
REDIS_URL: 'redis://localhost:6379',
CAPABILITY_SIGN_PUBKEY_B64: Buffer.from(opts.publicRaw).toString('base64'),
CA_INTERMEDIATE_KMS_KEY_REF: 'dev-inprocess-signer',
CA_INTERMEDIATE_CERT_PATH: opts.caCertPath,
NODE_MTLS_TRUST_BUNDLE_PATH: opts.caCertPath,
BASE_DOMAIN: opts.baseDomain,
})
const { app } = await buildControlPlane(env, { stores: createMemoryStores() })
await app.listen({ port: opts.bindPort, host: opts.bindHost })
return { close: () => app.close() }
}