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:
49
relay-run/src/servers/agent-tls.ts
Normal file
49
relay-run/src/servers/agent-tls.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
77
relay-run/src/servers/browser-server.ts
Normal file
77
relay-run/src/servers/browser-server.ts
Normal 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
|
||||
}
|
||||
41
relay-run/src/servers/control-plane-boot.ts
Normal file
41
relay-run/src/servers/control-plane-boot.ts
Normal 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() }
|
||||
}
|
||||
Reference in New Issue
Block a user