diff --git a/agent/package.json b/agent/package.json index a7cd33b..ea3ce31 100644 --- a/agent/package.json +++ b/agent/package.json @@ -13,6 +13,7 @@ "main": "src/index.ts", "scripts": { "typecheck": "tsc --noEmit", + "build": "esbuild src/main.ts --bundle --platform=node --format=esm --outfile=dist/cli.js --banner:js='#!/usr/bin/env node\nimport{createRequire as __cjs}from\"node:module\";const require=__cjs(import.meta.url);'", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage" @@ -26,6 +27,7 @@ "@types/node": "^25.9.3", "@types/ws": "^8.5.12", "@vitest/coverage-v8": "^4.1.9", + "esbuild": "^0.28.1", "typescript": "^6.0.3", "vitest": "^4.1.9" } diff --git a/agent/src/cli/deps.ts b/agent/src/cli/deps.ts new file mode 100644 index 0000000..ee347f3 --- /dev/null +++ b/agent/src/cli/deps.ts @@ -0,0 +1,83 @@ +/** + * CliDeps factory — PLAN_RELAY_PHASE1 C2. Wires the abstract `CliDeps` seams (consumed by + * `runCli`) to their real implementations: env-driven config, the on-disk keystore, Ed25519 + * identity generation, §4.5 pairing redemption, the supervised tunnel, and OS service install. + * All side effects live here so `cli.ts`/`runCli` stay pure and unit-testable. + */ +import { execFile } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import { homedir, userInfo } from 'node:os' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { CliDeps } from '../cli.js' +import type { AgentConfig } from '../config/agentConfig.js' +import { loadAgentConfig } from '../config/agentConfig.js' +import { openKeystore } from '../keys/keystore.js' +import { generateIdentity } from '../keys/identity.js' +import { redeemPairingCode } from '../enroll/pair.js' +import { runTunnel } from '../transport/runTunnel.js' +import { + detectPlatform, + installService as installServiceUnit, + uninstallService as uninstallServiceUnit, + type InstallDeps, + type ServicePlatform, +} from '../service/install.js' + +/** Resolve this process's own executable path (the bundled `dist/cli.js`) for the service unit. */ +function selfBinPath(): string { + return fileURLToPath(import.meta.url) +} + +function runCommand(cmd: string, args: readonly string[]): Promise { + return new Promise((resolve, reject) => { + execFile(cmd, [...args], (err) => (err ? reject(err) : resolve())) + }) +} + +function realInstallDeps(): InstallDeps { + return { + writeFile: (path, content) => { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) + }, + runCommand, + getuid: () => (typeof process.getuid === 'function' ? process.getuid() : 0), + homedir, + username: () => userInfo().username, + binPath: selfBinPath, + } +} + +/** Map the current OS to its service manager, or fail fast with a clear message. */ +function requirePlatform(): ServicePlatform { + const platform = detectPlatform(process.platform) + if (platform === null) { + throw new Error(`service install/uninstall is unsupported on platform '${process.platform}'`) + } + return platform +} + +/** Build the concrete CliDeps used by the real CLI entrypoint. */ +export function createCliDeps(): CliDeps { + return { + loadConfig: () => loadAgentConfig(process.env), + openKeystore: (stateDir) => openKeystore(stateDir), + generateIdentity: () => generateIdentity(), + redeem: (cfg: AgentConfig, code, id, ks) => redeemPairingCode(cfg.enrollUrl, code, id, ks), + runTunnel: async (cfg, ks) => { + const handle = await runTunnel(cfg, ks) + const onSignal = (): void => { + void handle.stop() + } + process.once('SIGTERM', onSignal) + process.once('SIGINT', onSignal) + return handle.done + }, + installService: (cfg) => installServiceUnit(cfg, requirePlatform(), realInstallDeps()), + uninstallService: () => uninstallServiceUnit(requirePlatform(), { runCommand, homedir }), + print: (line) => { + process.stdout.write(`${line}\n`) + }, + } +} diff --git a/agent/src/main.ts b/agent/src/main.ts new file mode 100644 index 0000000..0c65689 --- /dev/null +++ b/agent/src/main.ts @@ -0,0 +1,30 @@ +/** + * CLI bootstrap — PLAN_RELAY_PHASE1 C2. The `dist/cli.js` entrypoint (the `#!/usr/bin/env node` + * shebang is prepended at build time via esbuild `--banner`, NOT here). Reads argv, builds the + * real CliDeps, dispatches through `runCli`, and maps any error to a clean stderr line + exit code + * (usage errors ⇒ 2, everything else ⇒ 1) so no invocation ever crashes with a raw stack trace. + */ +import { parseArgs, runCli, CliUsageError } from './cli.js' +import { createCliDeps } from './cli/deps.js' + +async function main(): Promise { + const argv = process.argv.slice(2) + const deps = createCliDeps() + try { + return await runCli(parseArgs(argv), deps) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + process.stderr.write(`web-terminal-agent: ${message}\n`) + return err instanceof CliUsageError ? 2 : 1 + } +} + +main() + .then((code) => { + process.exitCode = code + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + process.stderr.write(`web-terminal-agent: fatal ${message}\n`) + process.exitCode = 1 + }) diff --git a/agent/src/transport/runTunnel.ts b/agent/src/transport/runTunnel.ts new file mode 100644 index 0000000..d530e21 --- /dev/null +++ b/agent/src/transport/runTunnel.ts @@ -0,0 +1,152 @@ +/** + * Long-running tunnel supervisor — PLAN_RELAY_PHASE1 C2. Ports the PROVEN cafeDemo assembly + * (dialRelay → holdTunnel → createStreamRouter → dialLoopback, plus heartbeat) into a supervised + * loop that survives disconnects: exponential backoff reconnect (T10 policy), heartbeat liveness + * (T9), and GOAWAY/revocation-aware teardown (T14, INV12 — a revoked host NEVER reconnects). + * + * All IO is injectable via `RunTunnelDeps` so the loop is unit-testable with fakes (see cafeDemo); + * the two-arg `runTunnel(cfg, ks)` default path wires the real `ws` sockets. INV2 is preserved: the + * router splices OPAQUE bytes (identityTransform) — no terminal parsing happens here. + */ +import { WebSocket } from 'ws' +import type { AgentConfig } from '../config/agentConfig.js' +import type { Keystore } from '../keys/keystore.js' +import { createLogger, type Logger } from '../log/logger.js' +import { createRevocationState, applyGoAway } from '../lifecycle/revocation.js' +import { dialRelay, type TlsWsConstructor } from './dial.js' +import { dialLoopback, type DialLoopback, type WsConstructor } from './loopback.js' +import { holdTunnel, type Tunnel } from './tunnel.js' +import { createStreamRouter, identityTransform } from './streamRouter.js' +import { createHeartbeat } from './heartbeat.js' +import { createBackoff, reconnectLoop, type BackoffPolicy, type Sleep } from './backoff.js' +import type { TimerLike, WsLike } from './seams.js' + +/** Handle returned by `runTunnel`: stop the supervisor, or await its terminal exit code. */ +export interface TunnelHandle { + /** Request graceful shutdown; resolves once the supervisor loop has fully stopped. */ + stop(): Promise + /** Resolves with a process exit code when the loop ends (stopped or host revoked ⇒ 0). */ + readonly done: Promise +} + +/** Injectable seams for the supervisor. All optional; unset fields default to real `ws` IO. */ +export interface RunTunnelDeps { + connectRelay(): Promise + dialLoopback: DialLoopback + logger: Logger + timer: TimerLike + sleep: Sleep + backoff: BackoffPolicy +} + +const realTimer: TimerLike = { + setTimeout: (cb, ms) => setTimeout(cb, ms), + clearTimeout: (h) => clearTimeout(h as ReturnType), + setInterval: (cb, ms) => setInterval(cb, ms), + clearInterval: (h) => clearInterval(h as ReturnType), +} +const realSleep: Sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +function resolveDeps(cfg: AgentConfig, ks: Keystore, o?: Partial): RunTunnelDeps { + return { + connectRelay: + o?.connectRelay ?? (() => dialRelay(cfg, ks, { Ctor: WebSocket as unknown as TlsWsConstructor })), + dialLoopback: o?.dialLoopback ?? dialLoopback(cfg.localTargetUrl, WebSocket as unknown as WsConstructor), + logger: o?.logger ?? createLogger('info'), + timer: o?.timer ?? realTimer, + sleep: o?.sleep ?? realSleep, + backoff: o?.backoff ?? createBackoff({ jitter: true }), + } +} + +/** + * Start the supervised tunnel. Returns immediately with a handle; the reconnect loop runs in the + * background. Never awaits the first connection (an unreachable relay would otherwise hang the + * caller with no way to `stop()`). + */ +export async function runTunnel( + cfg: AgentConfig, + ks: Keystore, + overrides?: Partial, +): Promise { + const { connectRelay, dialLoopback: dialLb, logger, timer, sleep, backoff } = resolveDeps(cfg, ks, overrides) + + let stopped = false + let currentTunnel: Tunnel | null = null + let currentSocket: WsLike | null = null + + // INV12: revocation tears the live tunnel down immediately and suppresses all reconnects. + const revocation = createRevocationState(() => currentTunnel?.close()) + const shouldStop = (): boolean => stopped || revocation.isRevoked() + + async function dialTunnel(): Promise { + const socket = await connectRelay() + currentSocket = socket + return holdTunnel(socket) + } + + /** Run ONE tunnel session; resolves when this session ends (dead heartbeat / close / GOAWAY). */ + function runSession(tunnel: Tunnel, socket: WsLike): Promise { + return new Promise((resolve) => { + const router = createStreamRouter(cfg, tunnel, dialLb, identityTransform, logger) + const heartbeat = createHeartbeat(tunnel, { timer }) + tunnel.dispatchTo(router, heartbeat) + + let settled = false + const endSession = (): void => { + if (settled) return + settled = true + heartbeat.stop() + resolve() + } + + heartbeat.onDead(() => { + logger.log('warn', 'heartbeat missed — tunnel presumed down, will reconnect') + tunnel.close() + endSession() + }) + socket.on('close', () => endSession()) + socket.on('error', () => { + tunnel.close() + endSession() + }) + tunnel.onGoAway((reason) => { + const action = applyGoAway(reason, revocation) // 'revoked' ⇒ no reconnect (INV12) + logger.log('info', 'received GOAWAY', { action }) + tunnel.close() + endSession() + }) + + heartbeat.start() + }) + } + + async function supervise(): Promise { + while (!shouldStop()) { + const tunnel = await reconnectLoop(dialTunnel, backoff, shouldStop, sleep) + if (tunnel === null || currentSocket === null) break + if (shouldStop()) { + // stop()/revoke raced with the in-flight dial — discard the fresh tunnel. + tunnel.close() + break + } + currentTunnel = tunnel + logger.log('info', 'relay tunnel established') + await runSession(tunnel, currentSocket) + currentTunnel = null + currentSocket = null + } + return 0 + } + + const done = supervise() + + return { + async stop(): Promise { + stopped = true + currentTunnel?.close() + await done + }, + done, + } +} diff --git a/agent/test/runTunnel.test.ts b/agent/test/runTunnel.test.ts new file mode 100644 index 0000000..eff584a --- /dev/null +++ b/agent/test/runTunnel.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' +import { decodeMuxFrame, encodeGoaway, encodeMuxFrame, encodeOpen, type MuxOpen } from 'relay-contracts' +import type { AgentConfig } from '../src/config/agentConfig.js' +import type { Keystore } from '../src/keys/keystore.js' +import { createBackoff } from '../src/transport/backoff.js' +import { runTunnel, type RunTunnelDeps } from '../src/transport/runTunnel.js' +import { FakeTimer, FakeWs } from './fixtures/fakes.js' + +/** + * C2 supervisor: proves runTunnel ports the cafeDemo assembly into a supervised loop — + * bytes splice both ways, a dead session reconnects, and a `revoked` GOAWAY stops for good (INV12). + */ +const CFG: AgentConfig = { + relayUrl: 'wss://relay/agent', + enrollUrl: 'https://x/enroll', + stateDir: '/tmp/x', + localTargetUrl: 'ws://127.0.0.1:3000', + subdomain: 'host-42', + hostId: 'h-1', +} +const OPEN: MuxOpen = { + streamId: 5, + subdomain: 'host-42', + requestPath: '/term?join=abc', + originHeader: 'https://host-42.term.example.com', + remoteAddrHash: 'x', + capabilityTokenRef: 'jti', +} +const KS = {} as unknown as Keystore // unused when connectRelay/dialLoopback are injected +const flush = (): Promise => new Promise((r) => setImmediate(r)) + +function emitOpen(upstream: FakeWs, open: MuxOpen): void { + const payload = encodeOpen(open) + upstream.emitMessage( + encodeMuxFrame( + { version: 1, type: 'open', fin: false, rst: false, streamId: open.streamId, payloadLen: payload.length }, + payload, + ), + ) +} +function emitGoAwayRevoked(upstream: FakeWs): void { + const payload = encodeGoaway(0, 'revoked') + upstream.emitMessage( + encodeMuxFrame( + { version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: payload.length }, + payload, + ), + ) +} + +function baseDeps(over: Partial): Partial { + return { + timer: new FakeTimer(), // never auto-fires ⇒ heartbeat is inert during the test + sleep: async () => {}, + backoff: createBackoff(), + ...over, + } +} + +describe('runTunnel supervisor (C2)', () => { + it('splices bytes both ways through the loopback', async () => { + const upstream = new FakeWs() + const loopback = new FakeWs() + const connectRelay = vi.fn(async () => upstream) + const dialLoopback = vi.fn(async () => loopback) + const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: dialLoopback as never })) + await flush() + + emitOpen(upstream, OPEN) + await flush() + expect(dialLoopback).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com') + + upstream.emitMessage(encodeMuxFrame({ version: 1, type: 'data', fin: false, rst: false, streamId: 5, payloadLen: 3 }, new Uint8Array([104, 105, 10]))) + expect(loopback.sent.at(-1)).toEqual(new Uint8Array([104, 105, 10])) + + loopback.emit('message', new Uint8Array([79, 75])) // "OK" echoes back upstream as DATA + const last = decodeMuxFrame(upstream.sent.at(-1)!) + expect(last.header.type).toBe('data') + expect([...last.payload]).toEqual([79, 75]) + + await handle.stop() + expect(await handle.done).toBe(0) + }) + + it('reconnects after the tunnel dies', async () => { + const sockets = [new FakeWs(), new FakeWs()] + let i = 0 + const connectRelay = vi.fn(async () => sockets[i++]!) + const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() })) + await flush() + expect(connectRelay).toHaveBeenCalledTimes(1) + + sockets[0]!.emit('close') // first session dies ⇒ supervisor redials + await flush() + expect(connectRelay).toHaveBeenCalledTimes(2) + + await handle.stop() + }) + + it('a revoked GOAWAY tears down and NEVER reconnects (INV12)', async () => { + const connectRelay = vi.fn(async () => new FakeWs()) + let socket: FakeWs | undefined + const wrapped = vi.fn(async () => { + socket = new FakeWs() + return socket + }) + const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay: wrapped, dialLoopback: async () => new FakeWs() })) + await flush() + expect(wrapped).toHaveBeenCalledTimes(1) + + emitGoAwayRevoked(socket!) + await flush() + + expect(await handle.done).toBe(0) + expect(wrapped).toHaveBeenCalledTimes(1) // no reconnect after revocation + expect(connectRelay).not.toHaveBeenCalled() + }) + + it('stop() ends the loop with exit code 0 and does not reconnect', async () => { + const connectRelay = vi.fn(async () => new FakeWs()) + const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() })) + await flush() + + await handle.stop() + expect(await handle.done).toBe(0) + expect(connectRelay).toHaveBeenCalledTimes(1) + }) +}) diff --git a/control-plane/package-lock.json b/control-plane/package-lock.json index be57dd8..f0d8e4c 100644 --- a/control-plane/package-lock.json +++ b/control-plane/package-lock.json @@ -11,6 +11,7 @@ "fastify": "^4.28.1", "ioredis": "^5.4.1", "pg": "^8.12.0", + "relay-auth": "file:../relay-auth", "relay-contracts": "file:../relay-contracts", "zod": "^3.23.8" }, @@ -18,6 +19,23 @@ "@types/node": "^25.9.3", "@types/pg": "^8.11.10", "@vitest/coverage-v8": "^4.1.9", + "tsx": "^4.19.2", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=18" + } + }, + "../relay-auth": { + "version": "0.0.0", + "dependencies": { + "relay-contracts": "file:../relay-contracts", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^25.9.3", + "@vitest/coverage-v8": "^4.1.9", "typescript": "^6.0.3", "vitest": "^4.1.9" }, @@ -133,6 +151,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@fastify/ajv-compiler": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", @@ -884,6 +1344,48 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1827,6 +2329,10 @@ "node": ">=4" } }, + "node_modules/relay-auth": { + "resolved": "../relay-auth", + "link": true + }, "node_modules/relay-contracts": { "resolved": "../relay-contracts", "link": true @@ -2079,6 +2585,25 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", diff --git a/control-plane/package.json b/control-plane/package.json index 0375ec7..970d109 100644 --- a/control-plane/package.json +++ b/control-plane/package.json @@ -9,6 +9,7 @@ }, "main": "src/main.ts", "scripts": { + "start": "tsx src/server.ts", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", @@ -26,6 +27,7 @@ "@types/node": "^25.9.3", "@types/pg": "^8.11.10", "@vitest/coverage-v8": "^4.1.9", + "tsx": "^4.19.2", "typescript": "^6.0.3", "vitest": "^4.1.9" } diff --git a/control-plane/src/boot/redis.ts b/control-plane/src/boot/redis.ts new file mode 100644 index 0000000..a9ee2cd --- /dev/null +++ b/control-plane/src/boot/redis.ts @@ -0,0 +1,32 @@ +/** + * A2 — ioredis client + `RedisPublisher` adapter for the P3 server entrypoint. + * + * `createRedisClient(url)` constructs the single ioredis connection used for the revocation-bus + * PUBLISH side (the FROZEN `relay:revocations` channel; P1 nodes subscribe — see routing/bus.ts). + * `createRedisPublisher` narrows that client to the minimal `RedisPublisher` surface + * `createRedisRevocationBus` consumes, so the concrete ioredis type never leaks into the bus wiring + * and the seam stays unit-testable with a fake. INV9: no secrets pass through here — the URL is + * owned/validated by `loadEnv` and never logged. + */ +// The default export IS `Redis`; imported by name because this package's tsconfig has no +// `esModuleInterop`, under which a default import of the CJS module resolves to its namespace. +import { Redis } from 'ioredis' +import type { RedisPublisher } from '../routing/bus.js' + +/** + * Construct the production ioredis client from a validated connection URL. Connection lifecycle + * (connect/retry) is managed by ioredis; the caller owns `quit()` on shutdown. + */ +export function createRedisClient(url: string): Redis { + return new Redis(url) +} + +/** + * Adapt an ioredis client to the `RedisPublisher` seam. Forwards `publish(channel, message)` + * verbatim and returns the subscriber count the driver reports (Promise). + */ +export function createRedisPublisher(redis: Redis): RedisPublisher { + return { + publish: (channel: string, message: string): Promise => redis.publish(channel, message), + } +} diff --git a/control-plane/src/server.ts b/control-plane/src/server.ts new file mode 100644 index 0000000..85c3748 --- /dev/null +++ b/control-plane/src/server.ts @@ -0,0 +1,99 @@ +/** + * A2 — P3 control-plane server entrypoint. Wires validated env → Postgres pool/stores → migrations → + * ioredis revocation bus → real capability verifier → Fastify app, then binds the ADMIN provisioning + * API to a LOOPBACK host by default (CP_BIND_HOST). This process owns no terminal/PTY state, so + * PTY!=WS restart-safety (INV7) is unaffected: it only serves provisioning/pairing/revocation. + * + * Config is env-only (no hardcoded hosts/ports/secrets): control-plane secrets via `loadEnv` + * (fail-fast, never echoes values — INV9); bind address via CP_BIND_HOST / CP_BIND_PORT. + * accountId is only ever derived from the authenticated capability token inside buildControlPlane + * (INV3) — this entrypoint never fabricates identity. + */ +import { loadEnv } from './env.js' +import { createPgPool, createQuery } from './db/pool.js' +import { createPgStores } from './store/pg.js' +import { runMigrations } from './db/migrate.js' +import { createRedisRevocationBus } from './routing/bus.js' +import { createCapabilityVerifier, configureCapabilityVerifyKey } from './boot/verifier.js' +import { createRedisClient, createRedisPublisher } from './boot/redis.js' +import { buildControlPlane } from './main.js' + +/** Admin API binds to loopback by default — never expose the provisioning API on a public interface. */ +const DEFAULT_CP_BIND_HOST = '127.0.0.1' +const DEFAULT_CP_BIND_PORT = 8080 +const MAX_TCP_PORT = 65535 + +function resolveBindHost(source: NodeJS.ProcessEnv): string { + const raw = source.CP_BIND_HOST?.trim() + return raw === undefined || raw === '' ? DEFAULT_CP_BIND_HOST : raw +} + +function resolveBindPort(source: NodeJS.ProcessEnv): number { + const raw = source.CP_BIND_PORT?.trim() + if (raw === undefined || raw === '') return DEFAULT_CP_BIND_PORT + const port = Number(raw) + if (!Number.isInteger(port) || port < 1 || port > MAX_TCP_PORT) { + throw new Error('Invalid CP_BIND_PORT: must be an integer 1-65535') + } + return port +} + +async function main(): Promise { + const env = loadEnv(process.env) + + // Postgres: parameterized-only query wrapper + PG-backed repository ports, migrations applied + // idempotently at boot. + const pool = createPgPool(env.pgUrl) + const query = createQuery(pool) + const stores = createPgStores(query) + await runMigrations(query) + + // Redis: single client drives the revocation-bus publish side (relay:revocations). + const redis = createRedisClient(env.redisUrl) + const bus = createRedisRevocationBus(createRedisPublisher(redis)) + + // Capability verifier: load the §4.3 verifying key into relay-auth's registry BEFORE serving, + // then delegate to relay-auth's async verifyCapabilityToken (INV3). + await configureCapabilityVerifyKey(env.capabilitySignPubkey) + const verifier = createCapabilityVerifier() + + const { app } = await buildControlPlane(env, { stores, bus, verifier }) + + const host = resolveBindHost(process.env) + const port = resolveBindPort(process.env) + + let shuttingDown = false + const shutdown = async (signal: string): Promise => { + if (shuttingDown) return + shuttingDown = true + process.stdout.write(`control-plane received ${signal}, shutting down\n`) + try { + await app.close() + } finally { + // Best-effort Redis close; force-disconnect if a graceful QUIT cannot complete. + await redis.quit().catch(() => redis.disconnect()) + await pool.end() + } + } + + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => { + void shutdown(signal).then( + () => process.exit(0), + (err: unknown) => { + process.stderr.write(`shutdown failed: ${err instanceof Error ? err.message : String(err)}\n`) + process.exit(1) + }, + ) + }) + } + + await app.listen({ host, port }) + // Startup breadcrumb — bind address only, never secrets (INV9). + process.stdout.write(`control-plane listening on ${host}:${port}\n`) +} + +main().catch((err: unknown) => { + process.stderr.write(`control-plane failed to start: ${err instanceof Error ? err.message : String(err)}\n`) + process.exit(1) +}) diff --git a/control-plane/test/boot-redis.test.ts b/control-plane/test/boot-redis.test.ts new file mode 100644 index 0000000..0312acb --- /dev/null +++ b/control-plane/test/boot-redis.test.ts @@ -0,0 +1,59 @@ +/** + * A2 — unit tests for the ioredis → RedisPublisher adapter (boot/redis.ts). The adapter is the only + * non-trivial logic in the server entrypoint's Redis wiring: it must forward publish(channel, message) + * verbatim to the underlying client and surface the driver's subscriber-count result unchanged. The + * live `createRedisClient` (a thin `new Redis(url)`) needs a real broker and is covered by the boot + * smoke, not here. + */ +import { describe, test, expect } from 'vitest' +import type { Redis } from 'ioredis' +import { createRedisPublisher } from '../src/boot/redis.js' +import { createRedisRevocationBus } from '../src/routing/bus.js' +import { RELAY_REVOCATIONS_CHANNEL, type KillSignal } from 'relay-contracts' + +/** Minimal ioredis stand-in recording publish calls; cast to Redis at the seam (only `publish` is used). */ +function fakeRedis(returnValue = 1): { calls: Array<[string, string]>; client: Redis } { + const calls: Array<[string, string]> = [] + const client = { + publish: async (channel: string, message: string): Promise => { + calls.push([channel, message]) + return returnValue + }, + } as unknown as Redis + return { calls, client } +} + +describe('A2 createRedisPublisher (RedisPublisher adapter)', () => { + test('forwards channel + message verbatim and returns the driver subscriber count', async () => { + // Arrange + const { calls, client } = fakeRedis(3) + const publisher = createRedisPublisher(client) + + // Act + const receivers = await publisher.publish('relay:revocations', 'payload') + + // Assert + expect(receivers).toBe(3) + expect(calls).toEqual([['relay:revocations', 'payload']]) + }) + + test('drives createRedisRevocationBus onto the FROZEN relay:revocations channel with JSON payload', async () => { + // Arrange + const { calls, client } = fakeRedis() + const bus = createRedisRevocationBus(createRedisPublisher(client)) + const signal: KillSignal = { + scope: { kind: 'host', hostId: '11111111-1111-4111-8111-111111111111' }, + at: 1_700_000_000, + reason: 'revoked', + } + + // Act + await bus.publish(signal) + + // Assert — bus publishes exactly one message on the shared channel, JSON-encoded. + expect(calls).toHaveLength(1) + const [channel, message] = calls[0]! + expect(channel).toBe(RELAY_REVOCATIONS_CHANNEL) + expect(JSON.parse(message)).toMatchObject({ scope: { kind: 'host', hostId: signal.scope.kind === 'host' ? signal.scope.hostId : '' } }) + }) +}) diff --git a/deploy/RUNBOOK.md b/deploy/RUNBOOK.md new file mode 100644 index 0000000..9e509f0 --- /dev/null +++ b/deploy/RUNBOOK.md @@ -0,0 +1,277 @@ +# RUNBOOK — deploy the rendezvous-relay to VPS `8.138.1.192` (Alibaba Cloud, mainland) + +> **Staging, durable single-tenant** (PLAN_RELAY_PHASE1 §0). Real Postgres + Redis (Docker Compose on +> the VPS), real Let's Encrypt TLS on `:443` against an **already ICP-filed** domain, agent dials OUT +> from the operator's laptop. Dev in-process CA signer is accepted for staging (KMS → Phase 2). +> +> Everything below is a **staging template** — replace every `` and confirm each path on +> the box. Config is **env-only**; no host/port/secret is hardcoded in code or units. + +Two independent trust chains — never cross-wire them: + +| Chain | Issued by | Protects | Script | +|---|---|---|---| +| **Public web PKI** | Let's Encrypt | browser `:443` WSS (`TLS_CERT_PATH`) | `issue-tls-cert.sh` | +| **Private enrollment CA** | your offline root | agent mTLS + relay agent-server cert (`AGENT_CA_*`) | `gen-agent-ca.sh` | + +And one shared key: the **P5 capability keypair** — control-plane **signs**, relay + admin API +**verify** (`gen-capability-key.sh`). `CAPABILITY_SIGN_PUBKEY_B64` (CP) and `RELAY_AUTH_VERIFY_PUBKEY` +(relay) are the **same public key**, two encodings. + +--- + +## 0. Prerequisites (on `8.138.1.192`) + +```bash +# Docker Engine + Compose plugin +curl -fsSL https://get.docker.com | sh +sudo systemctl enable --now docker + +# Node 20 (satisfies control-plane>=18, relay-run>=20, agent>=18) via NodeSource +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash - +sudo apt-get install -y nodejs git openssl + +# Clone (this runbook assumes /opt/web-terminal; adjust the systemd --prefix if you change it) +sudo git clone /opt/web-terminal +cd /opt/web-terminal +npm ci # root deps +npm --prefix control-plane ci +npm --prefix relay-run ci +npm --prefix relay-web ci + +# Dedicated non-root service user + secret dir +sudo useradd --system --home /opt/web-terminal relay || true +sudo mkdir -p /etc/relay +``` + +**DNS (do this first — LE HTTP-01 and the browser both need it):** create an A-record +`.` → `8.138.1.192`. The domain must be **ICP-filed** (mainland Aliyun blocks +`:80/:443` on unfiled domains; if unfiled, you must use DNS-01 in step 2). + +--- + +## 1. Bring up Postgres + Redis (Docker Compose, loopback-only) + +```bash +cd /opt/web-terminal +cp deploy/.env.example deploy/.env # gitignored; fill POSTGRES_PASSWORD (strong random) +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d +docker compose -f deploy/docker-compose.yml ps # both healthy; bound to 127.0.0.1 only +``` + +Both services publish to `127.0.0.1` only — never the public interface (they are the relay's private +state). + +--- + +## 2. Generate the CA, the capability key, and the TLS cert + +```bash +# (a) PRIVATE enrollment CA (agent mTLS) — NOT Let's Encrypt. +sudo AGENT_FACING_HOST="." \ + AGENT_FACING_IP="8.138.1.192" \ + RELAY_TRUST_DOMAIN="" \ + bash deploy/scripts/gen-agent-ca.sh +# writes /etc/relay/ca/{root,intermediate,agent-ca.*,relay-agent-server.*} + +# (b) P5 capability keypair — prints BOTH public-key encodings; private key -> 0600 file. +sudo bash deploy/scripts/gen-capability-key.sh +# -> note CAPABILITY_SIGN_PUBKEY_B64 (base64) and RELAY_AUTH_VERIFY_PUBKEY (base64url); same key. + +# (c) PUBLIC TLS cert for the browser :443 (Let's Encrypt). +# http-01 needs inbound :80 open DURING issuance (step 4); dns-01 needs no inbound port. +sudo BASE_DOMAIN="" SUBDOMAIN="" ACME_EMAIL="" \ + ACME_METHOD="http-01" \ + TLS_CERT_PATH="/etc/relay/tls/fullchain.pem" TLS_KEY_PATH="/etc/relay/tls/privkey.pem" \ + bash deploy/scripts/issue-tls-cert.sh + +sudo chown -R relay:relay /etc/relay +``` + +> **Staging CA seam (A-wave, not this task):** the control-plane's dev in-process signer must sign +> agent leaves with `/etc/relay/ca/intermediate.key.pem` so they chain to `agent-ca.bundle.pem`. +> `CA_INTERMEDIATE_KMS_KEY_REF=dev-local` selects the dev signer; point it at that key. Phase 2 = KMS. +> After first boot, move `/etc/relay/ca/root.key.pem` **off** the host (offline anchor). + +--- + +## 3. Fill the two env files + +Copy the relevant blocks of `deploy/.env.example` into two 0600 files. **The linchpin:** +`CAPABILITY_SIGN_PUBKEY_B64` (control-plane) and `RELAY_AUTH_VERIFY_PUBKEY` (relay) are the SAME key +from step 2b — paste both. + +```bash +sudo install -m 600 -o relay -g relay /dev/null /etc/relay/control-plane.env +sudo install -m 600 -o relay -g relay /dev/null /etc/relay/relay.env +``` + +`/etc/relay/control-plane.env` (P3): + +```ini +PG_URL=postgres://relay:@127.0.0.1:5432/relay +REDIS_URL=redis://127.0.0.1:6379 +CAPABILITY_SIGN_PUBKEY_B64= +CA_INTERMEDIATE_KMS_KEY_REF=dev-local +CA_INTERMEDIATE_CERT_PATH=/etc/relay/ca/intermediate.cert.pem +NODE_MTLS_TRUST_BUNDLE_PATH=/etc/relay/ca/agent-ca.bundle.pem +BASE_DOMAIN= +CP_BIND_HOST=127.0.0.1 +CP_BIND_PORT=8080 +# HEARTBEAT_TTL_SEC / PAIRING_TTL_SEC / PAIRING_MAX_REDEEM_ATTEMPTS use defaults if unset +``` + +`/etc/relay/relay.env` (P1 data-plane): + +```ini +BASE_DOMAIN= +BIND_HOST=0.0.0.0 +BIND_PORT=443 +TLS_CERT_PATH=/etc/relay/tls/fullchain.pem +TLS_KEY_PATH=/etc/relay/tls/privkey.pem +AGENT_BIND_PORT=8444 +AGENT_CA_CERT_PATH=/etc/relay/ca/agent-ca.cert.pem +AGENT_CA_CHAIN_PATH=/etc/relay/ca/agent-ca.bundle.pem +RELAY_NODE_ID=relay-1 +RELAY_AUTH_VERIFY_PUBKEY= +RELAY_TRUST_DOMAIN= +PG_URL=postgres://relay:@127.0.0.1:5432/relay +REDIS_URL=redis://127.0.0.1:6379 +``` + +--- + +## 4. Open the Aliyun security group (inbound) + +Open **only** these, in the ECS instance's security group: + +| Port | Who | Note | +|---|---|---| +| `443/tcp` | browsers (WSS) | permanent | +| `8444/tcp` (`AGENT_BIND_PORT`) | agents (mTLS) | permanent | +| `80/tcp` | Let's Encrypt HTTP-01 | **temporary** — only during cert issue/renew; skip entirely if using DNS-01 | + +**Keep loopback-only (never open):** Postgres `5432`, Redis `6379`, control-plane admin +`8080`. Reach the admin API from your laptop via SSH tunnel: `ssh -L 8080:127.0.0.1:8080 root@8.138.1.192`. + +--- + +## 5. Build the browser bundle + +```bash +cd /opt/web-terminal +npm --prefix relay-web run build # emits relay-web/public/build/, served same-origin by D1 +``` + +--- + +## 6. Enable + start both units + +```bash +sudo cp deploy/systemd/relay-control-plane.service deploy/systemd/relay-data-plane.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now relay-control-plane.service # migrates PG, listens on 127.0.0.1:8080 +sudo systemctl enable --now relay-data-plane.service # :443 browser WSS + :8444 agent mTLS +sudo systemctl status relay-control-plane.service relay-data-plane.service +journalctl -u relay-data-plane.service -f # watch for bind + verify-key load +``` + +--- + +## 7. Create an account + pairing code (control-plane admin) + +The admin API is **deny-by-default**: `POST /accounts` and `POST /accounts/:id/pairing-codes` require a +**capability token** with the `manage` right whose **`aud` equals `BASE_DOMAIN`** (control-plane +`authz.ts` / `main.ts` `expectedAud: env.baseDomain`). `accountId` is taken ONLY from the verified +token (INV3) — never from the body. + +Mint the token with the **capability PRIVATE key** from step 2b (the B6 token-mint owns the signing +helper; it imports `/etc/relay/capability/capability-sign.key.pem`). Token claims: +`rights=['manage']`, `aud=`. Send it as `Authorization: Bearer `. + +Bootstrap has a two-token nuance (grounded in `provision.ts`): + +1. `POST /accounts` checks `manage` only (no account-ownership check) → mint a `manage` token, create + the account, read back its `id`. +2. `POST /accounts/:id/pairing-codes` additionally enforces `token.sub == :id` (own-account). Mint a + second `manage` token with `sub=`, then request the pairing code. + +Over the SSH tunnel from step 4: + +```bash +# 1) create account +curl -sS -X POST http://127.0.0.1:8080/accounts \ + -H "Authorization: Bearer " \ + -H 'content-type: application/json' -d '{"plan":"personal"}' +# -> { "id": "", ... } + +# 2) pairing code (token.sub must equal ) +curl -sS -X POST http://127.0.0.1:8080/accounts//pairing-codes \ + -H "Authorization: Bearer " +# -> { "code": "", ... } (single-use, TTL 600s default) +``` + +--- + +## 8. On the LAPTOP — build the agent, pair, run + +The agent dials OUT; nothing inbound is opened on the laptop. + +```bash +cd /agent +npm ci && npm run build # emits dist/cli.js (C1) +export RELAY_URL="wss://.:8444" # AGENT_BIND_PORT +export ENROLL_URL="https://./enroll" +export HOST_ID="" +export SUBDOMAIN="" +export LOCAL_TARGET_URL="ws://127.0.0.1:3000" # the base web-terminal app +export STATE_DIR="$HOME/.web-terminal-agent" + +node dist/cli.js pair # redeems the code -> SPIFFE mTLS cert + hostContentSecret +node dist/cli.js run # dials the relay, holds the mux tunnel +``` + +`pair` redeems the single-use code and stores the enrolled SPIFFE cert under `STATE_DIR`; `run` opens +the outbound mTLS tunnel and keeps it alive (heartbeat/backoff). + +--- + +## 9. Browser — log in and click through to the shell + +1. Open `https://.` (served same-origin as the WSS endpoint). +2. Operator login (B6) mints a short-lived connect capability token. +3. Pick the host → the relay splices browser ↔ agent. The relay only sees **ciphertext** (INV2); the + E2E is browser ↔ agent. + +--- + +## 10. Verify restart-safety + revocation teardown + +```bash +# Restart-safety (INV7): bounce the relay while a shell is open — the PTY and host registration survive. +sudo systemctl restart relay-data-plane.service +# the browser auto-reconnects; the session is still there (state is in PG/Redis, not the process). + +# Revocation teardown (INV12): revoke the host and watch the tunnel drop within budget. +curl -sS -X DELETE http://127.0.0.1:8080/hosts/ \ + -H "Authorization: Bearer " +# -> control-plane publishes a KillSignal on Redis relay:revocations; the data-plane subscriber +# closes the spliced stream (browser WS closes 4403). Measure revoke -> close latency. +``` + +--- + +## Troubleshooting + +| Symptom | Meaning | Fix | +|---|---|---| +| Browser WS closes **1013** | `WS_TRY_LATER` — **no agent tunnel** for that host (agent offline / not dialed) | Start the agent (`node dist/cli.js run`); check `RELAY_URL` host+port; confirm `:8444` open; watch `journalctl -u relay-data-plane`. | +| Browser WS closes **401** | **bad Origin** (CSWSH exact-match failed) — the `Origin` header is not in the relay's `allowedOrigins` | Open via `https://.` (not the raw IP, not `http:`, no stray port). `allowedOrigins` is the port-less `https://.` on `:443`; must match EXACTLY. Verify `BASE_DOMAIN` in `relay.env`. | +| Agent mTLS closes **4401** | cert chains to the CA but the **pubkey is not enrolled** in the host registry (INV14 registry-gated) | The host was never enrolled or was revoked. Re-run `pair ` with a fresh code; confirm the CP signed the leaf with `/etc/relay/ca/intermediate.key.pem` (chains to `agent-ca.bundle.pem`). | +| Browser WS closes **4403** | `WS_REVOKED` — the host/session was revoked (expected after step 10) | Re-enroll the host to reconnect. | +| CP fails to boot: "CAPABILITY_SIGN_PUBKEY_B64 must decode to 32 bytes" | wrong key encoding in `control-plane.env` | Use the **base64** value (not base64url) from step 2b for the CP; base64url is the relay's `RELAY_AUTH_VERIFY_PUBKEY`. | +| Admin API returns **401** on `POST /accounts` | missing/expired/foreign-`aud` capability token | Mint a `manage` token with `aud=`, signed by the step-2b private key; send `Authorization: Bearer`. | +| Admin API returns **403** on `/pairing-codes` | token lacks `manage` right, or `token.sub != :id` | Mint with `rights:['manage']` and `sub=` (own-account rule). | +| LE issuance fails (timeout/connection) | HTTP-01 `:80` not reachable, or domain not ICP-filed | Open `:80` in the security group for issuance, or switch `ACME_METHOD=dns-01`; confirm the A-record + ICP filing. | +| `docker compose ps` unhealthy | PG/Redis not up | `docker compose -f deploy/docker-compose.yml logs`; confirm `POSTGRES_PASSWORD` set in `deploy/.env`. | +| Mixed-content / `ws:` blocked in browser | page is HTTPS but WS scheme resolved to `ws:` | Serve over HTTPS; the client follows page scheme (`wss:` on HTTPS, M6). Confirm the LE cert is valid for the FQDN. | diff --git a/deploy/scripts/gen-agent-ca.sh b/deploy/scripts/gen-agent-ca.sh new file mode 100755 index 0000000..11ffc07 --- /dev/null +++ b/deploy/scripts/gen-agent-ca.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# +# RELAY-PHASE1 · E (TASK E) — Private ENROLLMENT CA for agent mTLS. +# +# STAGING TEMPLATE. This mints the PRIVATE trust chain the rendezvous-relay uses to (a) verify agent +# client certs (SPIFFE mTLS, INV14) and (b) present the relay's agent-facing SERVER cert on +# AGENT_BIND_PORT so the dialing agent can authenticate the relay. +# +# *** THIS IS NOT LET'S ENCRYPT. *** The browser-facing :443 cert is public-web PKI and is issued +# by deploy/scripts/issue-tls-cert.sh. This CA is a SEPARATE, private trust root that must NEVER be +# a public CA — mixing them would let any web-PKI leaf enroll as an agent. +# +# Hierarchy produced (Ed25519, to match the control-plane dev signer's algorithm): +# root (self-signed, offline anchor) +# └── intermediate (signs agent SPIFFE leaves at CP /enroll, AND the relay agent-server cert) +# ├── relay agent-server leaf (TLS serverAuth, CN/SAN = the agent-facing host) +# └── (agent leaves are issued at runtime by the control-plane, not here) +# +# Outputs (0600 keys, 0644 certs) under CA_DIR (default /etc/relay/ca): +# root.key.pem root.cert.pem +# intermediate.key.pem intermediate.cert.pem <- CA_INTERMEDIATE_CERT_PATH (+ its key = the CP signer) +# agent-ca.bundle.pem = intermediate + root <- AGENT_CA_CHAIN_PATH / NODE_MTLS_TRUST_BUNDLE_PATH +# agent-ca.cert.pem = intermediate cert <- AGENT_CA_CERT_PATH +# relay-agent-server.key.pem relay-agent-server.cert.pem (present on AGENT_BIND_PORT) +# +# Config via ENV only (no hardcoded hosts/ports/secrets): +# CA_DIR output dir (default /etc/relay/ca) +# AGENT_FACING_HOST REQUIRED — CN/SAN for the relay agent-server cert; the host the agent dials, +# e.g. "." (must match RELAY_URL's host in the agent config). +# AGENT_FACING_IP OPTIONAL — extra IP SAN (e.g. 8.138.1.192) if the agent dials the raw IP. +# RELAY_TRUST_DOMAIN OPTIONAL — SPIFFE trust domain (recorded in a NOTE only; agent SPIFFE leaves +# are minted by the control-plane /enroll, not by this script). +# CA_DAYS_ROOT / CA_DAYS_INT / CA_DAYS_LEAF cert lifetimes (defaults 3650 / 1825 / 825). +# +# Verify (syntax): bash -n deploy/scripts/gen-agent-ca.sh +set -euo pipefail + +CA_DIR="${CA_DIR:-/etc/relay/ca}" +CA_DAYS_ROOT="${CA_DAYS_ROOT:-3650}" +CA_DAYS_INT="${CA_DAYS_INT:-1825}" +CA_DAYS_LEAF="${CA_DAYS_LEAF:-825}" + +if [[ -z "${AGENT_FACING_HOST:-}" ]]; then + echo "FATAL: AGENT_FACING_HOST is required (CN/SAN of the relay agent-server cert)." >&2 + echo " e.g. AGENT_FACING_HOST=term1.example.com bash $0" >&2 + exit 2 +fi + +command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; } + +umask 077 +mkdir -p "${CA_DIR}" +cd "${CA_DIR}" + +echo "== Enrollment CA (PRIVATE — not Let's Encrypt) into ${CA_DIR} ==" + +# ---- 1. Root CA (offline anchor) ------------------------------------------------------------------ +if [[ ! -f root.key.pem ]]; then + openssl genpkey -algorithm ed25519 -out root.key.pem +fi +openssl req -x509 -new -key root.key.pem -days "${CA_DAYS_ROOT}" \ + -subj "/O=web-terminal-relay/OU=agent-enrollment/CN=web-terminal Agent Enrollment Root" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" \ + -addext "subjectKeyIdentifier=hash" \ + -out root.cert.pem + +# ---- 2. Intermediate CA (the control-plane's leaf-signing key in staging) -------------------------- +# The CP dev in-process signer (CA_INTERMEDIATE_KMS_KEY_REF=dev-local) signs agent SPIFFE leaves with +# THIS intermediate key. In Phase 2 the intermediate key moves into a non-exportable KMS/HSM (§3.1). +if [[ ! -f intermediate.key.pem ]]; then + openssl genpkey -algorithm ed25519 -out intermediate.key.pem +fi +openssl req -new -key intermediate.key.pem \ + -subj "/O=web-terminal-relay/OU=agent-enrollment/CN=web-terminal Agent Enrollment Intermediate" \ + -out intermediate.csr.pem +cat > intermediate.ext <<'EXT' +basicConstraints = critical,CA:TRUE,pathlen:0 +keyUsage = critical,keyCertSign,cRLSign +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always +EXT +openssl x509 -req -in intermediate.csr.pem \ + -CA root.cert.pem -CAkey root.key.pem -CAcreateserial \ + -days "${CA_DAYS_INT}" -extfile intermediate.ext \ + -out intermediate.cert.pem + +# ---- 3. Trust bundle (intermediate + root) -------------------------------------------------------- +# The relay walks leaf -> intermediate -> root, so the anchor (root) MUST be in the bundle +# (relay-auth verifyChain terminates at a self-signed root present in the set, INV14). +cat intermediate.cert.pem root.cert.pem > agent-ca.bundle.pem +cp intermediate.cert.pem agent-ca.cert.pem + +# ---- 4. Relay agent-facing SERVER cert (presented on AGENT_BIND_PORT) ----------------------------- +# The agent dials wss://: and pins THIS CA to authenticate the +# relay server. CN/SAN must equal the host the agent dials. +if [[ ! -f relay-agent-server.key.pem ]]; then + openssl genpkey -algorithm ed25519 -out relay-agent-server.key.pem +fi +openssl req -new -key relay-agent-server.key.pem \ + -subj "/O=web-terminal-relay/OU=agent-data-plane/CN=${AGENT_FACING_HOST}" \ + -out relay-agent-server.csr.pem +{ + echo "basicConstraints = critical,CA:FALSE" + echo "keyUsage = critical,digitalSignature" + echo "extendedKeyUsage = serverAuth" + echo "subjectKeyIdentifier = hash" + echo "authorityKeyIdentifier = keyid,issuer" + if [[ -n "${AGENT_FACING_IP:-}" ]]; then + echo "subjectAltName = DNS:${AGENT_FACING_HOST},IP:${AGENT_FACING_IP}" + else + echo "subjectAltName = DNS:${AGENT_FACING_HOST}" + fi +} > relay-agent-server.ext +openssl x509 -req -in relay-agent-server.csr.pem \ + -CA intermediate.cert.pem -CAkey intermediate.key.pem -CAcreateserial \ + -days "${CA_DAYS_LEAF}" -extfile relay-agent-server.ext \ + -out relay-agent-server.cert.pem + +# ---- 5. Permissions + summary --------------------------------------------------------------------- +chmod 600 ./*.key.pem +chmod 644 ./*.cert.pem ./*.bundle.pem +rm -f ./*.csr.pem ./*.ext + +cat <}. + + *** root.key.pem is the offline trust anchor — after first use, move it OFF this host. *** +SUMMARY diff --git a/deploy/scripts/gen-capability-key.sh b/deploy/scripts/gen-capability-key.sh new file mode 100755 index 0000000..89ad129 --- /dev/null +++ b/deploy/scripts/gen-capability-key.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# RELAY-PHASE1 · E (TASK E) — P5 capability-token keypair (Ed25519). +# +# STAGING TEMPLATE. One Ed25519 keypair underpins the whole authz split: +# +# CONTROL-PLANE SIGNS ──(mints capability tokens with the PRIVATE key, B6 token-mint)──▶ +# RELAY VERIFIES +# The CP admin API (POST /accounts, /pairing-codes) ALSO verifies operator "manage" tokens with +# the SAME public key. So the public key is deployed to BOTH processes; the private key lives ONLY +# where tokens are minted (the B6 mint), never on the relay, never committed. +# +# The public key is printed in TWO encodings of the SAME raw 32 bytes (the split is encoding-only): +# CAPABILITY_SIGN_PUBKEY_B64 raw 32B, standard base64 -> control-plane env (env.ts base64ToBytes) +# RELAY_AUTH_VERIFY_PUBKEY raw 32B, base64url (no pad) -> relay env (loadVerifyKeyFromEnv) +# These MUST be the SAME key (PLAN §3 linchpin). This script prints both from one keypair so they +# cannot drift. +# +# The PRIVATE key is written PKCS#8 PEM to a 0600 file for the B6 mint to import (WebCrypto Ed25519). +# It is NEVER printed and NEVER placed in relay.env / control-plane.env. +# +# Config via ENV only: +# KEY_DIR output dir for the private key (default /etc/relay/capability) +# +# Verify (syntax): bash -n deploy/scripts/gen-capability-key.sh +set -euo pipefail + +KEY_DIR="${KEY_DIR:-/etc/relay/capability}" +PRIV="${KEY_DIR}/capability-sign.key.pem" + +command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; } + +umask 077 +mkdir -p "${KEY_DIR}" + +if [[ -f "${PRIV}" ]]; then + echo "WARN: ${PRIV} already exists — reusing it (delete it to rotate). Rotating INVALIDATES every" >&2 + echo " minted token AND requires re-deploying the public key to BOTH processes." >&2 +else + openssl genpkey -algorithm ed25519 -out "${PRIV}" +fi +chmod 600 "${PRIV}" + +# Raw 32-byte Ed25519 public key = last 32 bytes of the SPKI DER. +PUB_STD_B64="$(openssl pkey -in "${PRIV}" -pubout -outform DER | tail -c 32 | openssl base64 -A)" +# base64url of the SAME bytes, padding stripped (decodeBase64UrlBytes tolerates missing padding). +PUB_B64URL="$(printf '%s' "${PUB_STD_B64}" | tr '+/' '-_' | tr -d '=')" + +cat <. (WSS same-origin), so it must chain to a public root. It is a +# DIFFERENT trust chain from the private agent-enrollment CA (deploy/scripts/gen-agent-ca.sh) — do +# NOT cross-wire them. +# +# ICP-備案 ASSUMPTION (mainland Alibaba Cloud): BASE_DOMAIN is ALREADY ICP-filed and its A-record +# . -> 8.138.1.192 resolves. Serving :80/:443 on an unfiled domain in +# mainland CN is blocked upstream; HTTP-01 will then fail. If unfiled, use DNS-01 (no inbound :80). +# +# Two challenge methods (pick with ACME_METHOD): +# http-01 — a one-shot listener on :80 answers the challenge. REQUIRES inbound :80 open in the +# Aliyun security group DURING issuance (you can close it again after; renewals reopen). +# dns-01 — a TXT record under _acme-challenge... No inbound :80. Needs the +# acme.sh DNS-API creds for your provider (e.g. Ali_Key/Ali_Secret for the Aliyun DNS API). +# +# Installs to TLS_CERT_PATH (fullchain) + TLS_KEY_PATH, matching relay.env. +# +# Config via ENV only: +# BASE_DOMAIN REQUIRED (e.g. term.example.com) +# SUBDOMAIN REQUIRED (the tenant sub; FQDN = .) +# ACME_EMAIL REQUIRED account/registration email +# ACME_METHOD http-01 | dns-01 (default http-01) +# ACME_CLIENT acme.sh | certbot (default acme.sh) +# ACME_DNS_PROVIDER acme.sh dnsapi id for dns-01 (e.g. dns_ali) (dns-01 only) +# TLS_CERT_PATH install target for fullchain (default /etc/relay/tls/fullchain.pem) +# TLS_KEY_PATH install target for privkey (default /etc/relay/tls/privkey.pem) +# ACME_STAGING 1 to use the LE staging CA (untrusted, avoids rate limits while testing) +# +# Verify (syntax): bash -n deploy/scripts/issue-tls-cert.sh +set -euo pipefail + +: "${BASE_DOMAIN:?FATAL: BASE_DOMAIN is required (your ICP-filed domain)}" +: "${SUBDOMAIN:?FATAL: SUBDOMAIN is required}" +: "${ACME_EMAIL:?FATAL: ACME_EMAIL is required}" +ACME_METHOD="${ACME_METHOD:-http-01}" +ACME_CLIENT="${ACME_CLIENT:-acme.sh}" +TLS_CERT_PATH="${TLS_CERT_PATH:-/etc/relay/tls/fullchain.pem}" +TLS_KEY_PATH="${TLS_KEY_PATH:-/etc/relay/tls/privkey.pem}" + +FQDN="${SUBDOMAIN}.${BASE_DOMAIN}" +echo "== Issuing Let's Encrypt cert for ${FQDN} via ${ACME_CLIENT} (${ACME_METHOD}) ==" + +mkdir -p "$(dirname "${TLS_CERT_PATH}")" "$(dirname "${TLS_KEY_PATH}")" + +case "${ACME_CLIENT}" in + acme.sh) + command -v acme.sh >/dev/null 2>&1 || { echo "FATAL: acme.sh not installed. curl https://get.acme.sh | sh" >&2; exit 3; } + acme.sh --register-account -m "${ACME_EMAIL}" >/dev/null 2>&1 || true + STAGING_FLAG="" + [[ "${ACME_STAGING:-0}" == "1" ]] && STAGING_FLAG="--staging" + + case "${ACME_METHOD}" in + http-01) + echo ">> Ensure inbound :80 is OPEN in the Aliyun security group for the duration of issuance." + acme.sh --issue ${STAGING_FLAG} -d "${FQDN}" --standalone --httpport 80 + ;; + dns-01) + : "${ACME_DNS_PROVIDER:?FATAL: ACME_DNS_PROVIDER required for dns-01 (e.g. dns_ali); export the DNS-API creds too}" + acme.sh --issue ${STAGING_FLAG} -d "${FQDN}" --dns "${ACME_DNS_PROVIDER}" + ;; + *) + echo "FATAL: ACME_METHOD must be http-01 or dns-01" >&2; exit 2 ;; + esac + + # --install-cert copies the current material AND registers the renew-reload hook. + acme.sh --install-cert -d "${FQDN}" \ + --key-file "${TLS_KEY_PATH}" \ + --fullchain-file "${TLS_CERT_PATH}" \ + --reloadcmd "systemctl try-reload-or-restart relay-data-plane.service" + ;; + + certbot) + command -v certbot >/dev/null 2>&1 || { echo "FATAL: certbot not installed (apt-get install certbot)." >&2; exit 3; } + STAGING_FLAG="" + [[ "${ACME_STAGING:-0}" == "1" ]] && STAGING_FLAG="--staging" + case "${ACME_METHOD}" in + http-01) + echo ">> Ensure inbound :80 is OPEN in the Aliyun security group for the duration of issuance." + certbot certonly ${STAGING_FLAG} --standalone --non-interactive --agree-tos \ + -m "${ACME_EMAIL}" -d "${FQDN}" + ;; + dns-01) + echo "FATAL: certbot dns-01 needs a provider plugin; prefer ACME_CLIENT=acme.sh for Aliyun DNS." >&2 + exit 2 ;; + *) + echo "FATAL: ACME_METHOD must be http-01 or dns-01" >&2; exit 2 ;; + esac + install -m 644 "/etc/letsencrypt/live/${FQDN}/fullchain.pem" "${TLS_CERT_PATH}" + install -m 600 "/etc/letsencrypt/live/${FQDN}/privkey.pem" "${TLS_KEY_PATH}" + ;; + + *) + echo "FATAL: ACME_CLIENT must be acme.sh or certbot" >&2; exit 2 ;; +esac + +chmod 600 "${TLS_KEY_PATH}" || true +chmod 644 "${TLS_CERT_PATH}" || true + +cat < tsx src/server.ts). +ExecStart=/usr/bin/env npm --prefix /opt/web-terminal/control-plane start +Restart=always +RestartSec=3 +# Give in-flight requests / graceful SIGTERM (server.ts closes the http listener + pg/redis) time. +TimeoutStopSec=20 +KillSignal=SIGTERM + +# --- hardening (INV9: shrink the blast radius around the CA + capability material) --- +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +ProtectControlGroups=true +ProtectKernelTunables=true +RestrictSUIDSGID=true +# The CA/capability material under /etc/relay is READ-ONLY to this service. +ReadOnlyPaths=/etc/relay + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/relay-data-plane.service b/deploy/systemd/relay-data-plane.service new file mode 100644 index 0000000..0c4f840 --- /dev/null +++ b/deploy/systemd/relay-data-plane.service @@ -0,0 +1,53 @@ +# RELAY-PHASE1 · E (TASK E) — relay data-plane (P1) systemd unit. STAGING TEMPLATE. +# +# Runs the Phase-1 relay entry (relay-run/src/main-phase1.ts via `start:phase1`, added in B5): browser +# WSS on :443 (BIND_PORT) and agent mTLS on AGENT_BIND_PORT. Stateless / restart-safe (INV7) — all +# state is in Postgres/Redis, so bouncing this unit does NOT drop host registrations or kill PTYs. +# +# INSTALL (adjust the placeholder paths to your VPS): +# 1) clone the repo to /opt/web-terminal (or edit --prefix below) +# 2) install: sudo cp deploy/systemd/relay-data-plane.service /etc/systemd/system/ +# 3) put the filled env at /etc/relay/relay.env (0600, owned by the service user) +# 4) sudo systemctl daemon-reload && sudo systemctl enable --now relay-data-plane.service +# +# The env file supplies BIND_HOST/BIND_PORT/TLS_*/AGENT_BIND_PORT/AGENT_CA_*/BASE_DOMAIN/ +# RELAY_NODE_ID/RELAY_AUTH_VERIFY_PUBKEY/RELAY_TRUST_DOMAIN/PG_URL/REDIS_URL (see .env.example). + +[Unit] +Description=web-terminal rendezvous-relay DATA PLANE (P1, Phase 1) +Documentation=file:///opt/web-terminal/docs/PLAN_RELAY_PHASE1.md +# Reads the SAME Postgres/Redis as the control-plane (routes, revocations, registries). +After=network-online.target docker.service relay-control-plane.service +Wants=network-online.target +Requires=docker.service + +[Service] +Type=simple +User=relay +Group=relay +WorkingDirectory=/opt/web-terminal +EnvironmentFile=/etc/relay/relay.env +# Runs relay-run "start:phase1" (relay-run/package.json -> tsx src/main-phase1.ts). +ExecStart=/usr/bin/env npm --prefix /opt/web-terminal/relay-run run start:phase1 +Restart=always +RestartSec=3 +TimeoutStopSec=20 +KillSignal=SIGTERM + +# Bind the privileged browser port :443 WITHOUT running as root (least privilege). +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE + +# --- hardening --- +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +ProtectControlGroups=true +ProtectKernelTunables=true +RestrictSUIDSGID=true +# TLS keys + the pinned agent-CA bundle are read-only to the relay. +ReadOnlyPaths=/etc/relay + +[Install] +WantedBy=multi-user.target diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 4b69323..76aaf27 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -26,8 +26,11 @@ ### 🚧 RELAY-PHASE1 — 把原生 rendezvous-relay 部署到单台 VPS(8.138.1.192,阿里云;2026-07-06) - **目标**: DEPLOY_RELAY §4 的 **Phase 1「1b 全量持久化 staging」**。VPS 上 Docker 自建 Postgres+Redis;已备案域名走 443 + Let's Encrypt;agent 跑在操作者本机向外拨号。完整文件级方案见 [PLAN_RELAY_PHASE1.md](./PLAN_RELAY_PHASE1.md)。 -- **进度**: 地基 Wave A 已 2/3。**A1 (PG store 适配器+迁移) DONE**、**A3 (F2 异步能力验证器) DONE**、E1 (docker-compose+env 模板) DONE。全套 `npx vitest run`(control-plane)= **15 files / 101 tests pass**(A1 的 pg 测试 + A3 的 verifier 测试并存无冲突)。 -- **下一步**: **A2** — P3 server 入口(`loadEnv→createPgStores→runMigrations→ioredis→createRedisRevocationBus→buildControlPlane→listen`)+ Redis 撤销总线接线(`main.ts:93` 现为 inert)。然后 Wave B(relay 数据面读同一持久 store)。 +- **进度**: **Wave A/B/C/D/E 代码全部完成并通过 Verify** —— 一个后台 Workflow(12 agents,0 error)跑完 A2、B1–B5、B6、C、D1、E。Verify 门槛:**四个包 tsc 全干净、可构建包全 build 成功、314/314 测试通过**(control-plane 103 / relay-run 74 / agent 137 + relay-web 118)。对抗式安全评审:**所有硬不变量 PASS**(INV2 opaque splice、Origin/CSWSH 精确匹配、mTLS INV14 registry 门控、token-mint 密码学、撤销 INV12、jti 单次、参数化 SQL)。 +- **各任务**: A2 P3 server 入口+Redis 总线 `[x]`;B1 共享 store EnforceDeps `[x]`;B2 registry-backed MtlsVerifier `[x]`(异步阻抗由 B5 的 `bridgeAsyncMtls` per-DER 预取桥无锁解决);B3 store RouteResolver `[x]`;B4 撤销订阅 `[x]`;B5 `main-phase1.ts` 组合入口 + staging `/auth/mint` 签发 `[x]`;C agent `dist/cli.js` 构建+`runTunnel` 运行入口 `[x]`;D1 同源托管 relay-web `[x]`;E systemd+脚本+RUNBOOK `[x]`;**B6 relay-web 登录/DPoP `[~] PARTIAL`**。 +- **两个待收尾(B7)**: ① **功能阻塞** — 浏览器 WS 无法设请求头,B6 把 DPoP proof 放进 `term.dpop.` 子协议,**relay 侧(browser-server.ts)必须从子协议读取并喂给 `UpgradeRequest.dpop`**,否则真实浏览器一律被 DPoP 拒(401)。② **F1 安全** — 公网 :443 上的 `/auth/mint` 无限速/锁定(评审最大攻击面);顺带 F2(`activeSessionCount` 硬编码 0,并发上限失效)、F5(裸 error 日志或泄露 DSN)。F3/F4(撤销后 agent 空转重连、slowloris)为 LOW,记入 Phase 2 backlog。 +- **远端(需 VPS 执行,见 `deploy/RUNBOOK.md`)**: DNS、Let's Encrypt 签证、私有 enrollment CA + capability keypair 生成、阿里云安全组放行 443/AGENT_PORT、systemd 起两个服务、端到端 enroll→dial→浏览器点进 shell。 +- **未做/推迟到 Phase 2**: 真 KMS、F6 replay、WebAuthn step-up(staging 用 `NO_STEPUP_POLICY`)、通配多租户、F3/F4。 - **未做/推迟到 Phase 2**: 真 KMS(dev signer 暂用)、F6 replay(`loadReplay` fail-closed)、WebAuthn step-up(staging 用 `NO_STEPUP_POLICY`)、通配多租户。 #### [x] A1 — Postgres store 适配器(P3)+ 迁移运行器(2026-07-06) @@ -43,6 +46,13 @@ - **验证(实测)**: `npx tsc --noEmit` 全项目干净;`npx vitest run` **15 files / 101 tests pass**。 - **偏差**: 手建 `control-plane/node_modules/relay-auth` 符号链接以配合新 file: 依赖(`npm install` 会重建)。**阻塞**: 无。 +### ✅ 修复:底部快捷键栏遮挡终端内容(iOS 安全区,2026-07-06,main) +- **现象(用户截图,iPhone)**: 底部快捷键栏(Esc/Esc²/⇧Tab…)压住终端最后一行(Claude Code "bypass permissions" 提示行只露上半)。 +- **根因(`public/`)**: `index.html` 设了 `viewport-fit=cover`(布局铺满物理屏、延伸进刘海/Home 指示条),但 `style.css` **全程没有任何 `env(safe-area-inset-*)` 补偿**。于是 `#keybar`(`fixed; bottom:0; height:--keybar-h`)整条压进 Home 指示条区,而 `#term` 只预留了裸 `--keybar-h`,末行与键栏相撞。教科书级 cover-无-safe-area 缺陷。 +- **修复(纯 CSS,零 JS)**: `:root` 新增 `--safe-t/--safe-b = env(safe-area-inset-top/bottom, 0px)`,穿过所有贴边固定元素:`#tabbar` 顶部让出刘海(border-box + `padding-top:--safe-t`)、`#term` inset 改 `calc(--tabbar-h+--safe-t) … calc(--keybar-h+--safe-b)`、`#keybar` 高度 `calc(--keybar-h+--safe-b)` + `padding-bottom:--safe-b`(芯片留在顶部 --keybar-h,Home 条上方)、`#approvalbar` 与 `body.home-open #term` 底部、`#searchbox`/`#settingspanel` 顶部同步。inset=0 时全部塌回原值 → 桌面/无安全区平台**零回归**。 +- **验证(headless,390×844,注入 `--safe-t:59px --safe-b:34px` 模拟 iPhone)**: 开一个真实会话后量测 `term_bottom==keybar_top==764`(gap=0,无重叠)、`tabbar_bottom==99`(=40+59,让出刘海);截图确认芯片位于 Home 指示条上方、终端不再被遮。`npm run build:web` 通过。 +- **待办**: 未提交;未在真机 iPhone 上复验(仅 headless 模拟安全区)。 + ### ✅ 修复:项目面板把父文件夹当项目 & 会话点亮所有祖先项目(2026-07-06,分支 `feat/ios-client`) - **现象(用户截图)**: 只在 `web-terminal` 跑了一个会话,但 "Active now" 同时显示 `web-terminal`/`Documents`/`yiukai` 三张卡,且父文件夹本身被列为项目。 - **根因(`src/http/projects.ts`)**: ① `belongsTo` 纯前缀匹配 → 会话按 cwd 归属到**每一个**祖先项目;② 历史合并(`mergeHistory`)把曾经跑过会话的 cwd(如 `~`、`~/Documents`)原样列为项目。 diff --git a/relay-run/package.json b/relay-run/package.json index 992a300..48c17d6 100644 --- a/relay-run/package.json +++ b/relay-run/package.json @@ -9,6 +9,7 @@ }, "scripts": { "start": "tsx src/main.ts", + "start:phase1": "tsx src/main-phase1.ts", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit" diff --git a/relay-run/src/main-phase1.ts b/relay-run/src/main-phase1.ts new file mode 100644 index 0000000..3046a82 --- /dev/null +++ b/relay-run/src/main-phase1.ts @@ -0,0 +1,282 @@ +/** + * Phase 1 PRODUCTION entrypoint — `npm run start:phase1`. Composes the SHARED-STORE data plane + * (B1–B4) behind a publicly-bound TLS listener, serves the relay-web bundle same-origin as the + * browser WSS (D1), and hosts a STAGING operator token-mint (`POST /auth/mint`, B5/auth-mint.ts). + * + * Unlike Phase-0 `main.ts` (in-RAM fakes + self-signed dev CA), everything here is real and + * env-configured — one world of truth over the SAME Postgres + Redis as the control-plane (INV7, + * restart-safe): the host registry that gates mTLS (INV14) and route resolution, the Redis + * revocation bus that tears live tunnels down (INV12), and the shared P5 verify key (INV9). + * + * browser ──WSS(:BIND_PORT)──▶ relay-node ──opaque splice(INV2)──▶ agent tunnel ◀──mTLS(:AGENT_BIND_PORT)── agent + * │ P5 onUpgrade: Origin/CSWSH + capability verify + DPoP │ registry-gated verifyAgentCert + * └ same-origin: static bundle (D1) + POST /auth/mint (B5) └ Redis relay:revocations → teardown + * + * All configuration is from ENV (no hardcoded hosts/ports/secrets). Phase-0 `main.ts` is UNTOUCHED. + */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +import { loadVerifyKeyFromEnv, KeyConfigError } from 'relay-auth/src/config/keys.js' +import { createPgPool, createQuery } from 'control-plane/src/db/pool.js' +import { createPgStores } from 'control-plane/src/store/pg.js' +import { createRedisClient } from 'control-plane/src/boot/redis.js' +import type { + MtlsVerifier, + TlsServerFactory, +} from 'term-relay/data-plane/agent-listener.js' + +import { createRelayEnforceDeps } from './wiring/stores-pg.js' +import { createMtlsVerifier, type AsyncMtlsVerifier } from './wiring/mtls-verifier.js' +import { createStoreRouteResolver } from './wiring/route-resolver.js' +import { startRevocationSubscriber, type RevocableNode } from './wiring/revocation-subscriber.js' +import { createAuthorizer } from './wiring/authorizer.js' +import { buildDataPlane, makeDataPlaneConfig } from './wiring/data-plane.js' +import { makeAgentTlsServerFactory } from './servers/agent-tls.js' +import { startBrowserServer } from './servers/browser-server.js' +import { createAuthMintRoute, loadSigningKeyFromEnv } from './servers/auth-mint.js' + +const DEFAULT_BIND_HOST = '0.0.0.0' +const DEFAULT_BIND_PORT = 443 +const HERE = dirname(fileURLToPath(import.meta.url)) // /relay-run/src +const DEFAULT_WEB_ROOT = join(HERE, '..', '..', 'relay-web', 'public') + +// ── env helpers (fail-fast on misconfiguration) ───────────────────────────────────────────────── + +function requireEnv(name: string): string { + const v = process.env[name] + if (v === undefined || v.length === 0) { + throw new KeyConfigError(`required env ${name} is not set`) + } + return v +} + +function requirePort(name: string): number { + const raw = requireEnv(name) + const n = Number(raw) + if (!Number.isInteger(n) || n < 1 || n > 65535) { + throw new KeyConfigError(`env ${name} must be an integer port 1–65535 (got ${JSON.stringify(raw)})`) + } + return n +} + +function intEnv(name: string, fallback: number): number { + const raw = process.env[name] + if (raw === undefined || raw.length === 0) return fallback + const n = Number(raw) + if (!Number.isInteger(n) || n < 1 || n > 65535) { + throw new KeyConfigError(`env ${name} must be an integer port 1–65535 (got ${JSON.stringify(raw)})`) + } + return n +} + +// ── async mTLS → sync-slot bridge ─────────────────────────────────────────────────────────────── + +interface MtlsBridge { + /** Sync `MtlsVerifier` for the data plane; reads the pre-computed verdict for this connection. */ + readonly sync: MtlsVerifier + /** Wrap the real TLS factory so each peer is registry-verified (async) BEFORE `attach` runs. */ + wrap(base: TlsServerFactory): TlsServerFactory +} + +/** + * term-relay's `MtlsVerifier.verifyPeer` is SYNC, but a registry-backed verifier (B2) is inherently + * async (Postgres lookup) — the ASYNC IMPEDANCE flagged in mtls-verifier.ts. We bridge it WITHOUT + * editing term-relay (outside our lane) by doing the async verify in the TLS `onPeer` hook and + * caching the verdict keyed by the peer's DER, which the sync `verifyPeer` (called synchronously by + * `attach`, immediately after `onPeer` fires) then reads. The set→onPeer→get sequence runs + * synchronously inside one `.then` callback, so a single-slot cache per DER is race-free. Fail-closed + * throughout: a rejected/failed verify caches `null`, so `attach` closes the peer with 4401 (INV14). + */ +function bridgeAsyncMtls( + asyncMtls: AsyncMtlsVerifier, + onError: (e: unknown) => void, +): MtlsBridge { + const pending = new Map() + const keyOf = (der: Uint8Array): string => Buffer.from(der).toString('base64') + + const sync: MtlsVerifier = { + verifyPeer(peerCert) { + const k = keyOf(peerCert) + const verdict = pending.get(k) ?? null + pending.delete(k) // one-shot: consumed by the attach() that triggered this onPeer + return verdict + }, + } + + const wrap = (base: TlsServerFactory): TlsServerFactory => (opts, onPeer) => + base(opts, (ws, der) => { + asyncMtls + .verifyPeer(der) + .then((verdict) => { + pending.set(keyOf(der), verdict) + onPeer(ws, der) // sync attach() → sync.verifyPeer(der) reads + consumes the verdict + }) + .catch((e: unknown) => { + onError(e) + pending.set(keyOf(der), null) // fail-closed → attach() closes 4401 + onPeer(ws, der) + }) + }) + + return { sync, wrap } +} + +// ── boot ──────────────────────────────────────────────────────────────────────────────────────── + +async function main(): Promise { + const now = (): number => Math.floor(Date.now() / 1000) + + // Config (fail-fast; secrets are read but never logged — INV9). + const bindHost = process.env.BIND_HOST || DEFAULT_BIND_HOST + const bindPort = intEnv('BIND_PORT', DEFAULT_BIND_PORT) + const agentBindPort = requirePort('AGENT_BIND_PORT') + const tlsCertPath = requireEnv('TLS_CERT_PATH') + const tlsKeyPath = requireEnv('TLS_KEY_PATH') + const agentServerCertPath = requireEnv('AGENT_SERVER_CERT_PATH') + const agentServerKeyPath = requireEnv('AGENT_SERVER_KEY_PATH') + const agentCaCertPath = requireEnv('AGENT_CA_CERT_PATH') + const agentCaChainPath = requireEnv('AGENT_CA_CHAIN_PATH') + const baseDomain = requireEnv('BASE_DOMAIN') + const relayNodeId = requireEnv('RELAY_NODE_ID') + const trustDomain = requireEnv('RELAY_TRUST_DOMAIN') + const allowedOrigins = requireEnv('ALLOWED_ORIGINS') + .split(',') + .map((o) => o.trim()) + .filter((o) => o.length > 0) + if (allowedOrigins.length === 0) { + throw new KeyConfigError('ALLOWED_ORIGINS must contain at least one origin (CSWSH exact-match)') + } + const pgUrl = requireEnv('PG_URL') + const redisUrl = requireEnv('REDIS_URL') + const webRoot = process.env.WEB_ROOT || DEFAULT_WEB_ROOT + + // Shared P5 verify key (RELAY_AUTH_VERIFY_PUBKEY) — configured process-wide, never logged (INV9). + await loadVerifyKeyFromEnv() + + // Shared stores: SAME Postgres + Redis as the control-plane (INV7). + const pool = createPgPool(pgUrl) + const query = createQuery(pool) + const stores = createPgStores(query) + const redis = createRedisClient(redisUrl) + const redisSubscriber = createRedisClient(redisUrl) // dedicated subscriber-mode connection + + const deps = createRelayEnforceDeps({ query, redis }) + const resolver = createStoreRouteResolver({ hosts: stores.hosts }) + + const asyncMtls = createMtlsVerifier({ + caChainPem: readFileSync(agentCaChainPath, 'utf8'), + hosts: deps.hosts, + now, + onError: (e) => console.error('[mtls-verify]', e), + }) + const mtlsBridge = bridgeAsyncMtls(asyncMtls, (e) => console.error('[mtls-bridge]', e)) + + const authorizer = createAuthorizer({ deps, allowedOrigins, now }) + + const config = makeDataPlaneConfig({ baseDomain, bindHost, bindPort, agentBindPort, relayNodeId }) + + const agentTlsFactory = makeAgentTlsServerFactory({ + serverCertPath: agentServerCertPath, + serverKeyPath: agentServerKeyPath, + bindHost, + bindPort: agentBindPort, + onListening: () => console.log(`[agent-mtls] listening wss://${bindHost}:${agentBindPort}`), + onError: (e) => console.error('[agent-mtls]', e), + }) + + const dp = buildDataPlane({ + config, + authorizer, + resolver, + mtls: mtlsBridge.sync, + now, + caBundle: [readFileSync(agentCaCertPath)], + onError: (e) => console.error('[data-plane]', e), + tlsServerFactory: mtlsBridge.wrap(agentTlsFactory), + }) + + // STAGING operator token-mint (B5). Enabled only when BOTH the password gate and the signing key + // are configured; otherwise the endpoint stays off (fail-closed) and static-only mode serves. + const operatorPassword = process.env.OPERATOR_PASSWORD ?? '' + const signPrivRaw = process.env.CAPABILITY_SIGN_PRIVKEY ?? '' + let onRequest: ReturnType | undefined + let mintEnabled = false + if (operatorPassword.length > 0 && signPrivRaw.length > 0) { + const signingKey = await loadSigningKeyFromEnv(signPrivRaw) + onRequest = createAuthMintRoute({ + signingKey, + hosts: stores.hosts, + operatorPassword, + now, + onError: (e) => console.error('[auth-mint]', e), + }) + mintEnabled = true + } else { + console.warn( + '[auth-mint] STAGING mint disabled — set OPERATOR_PASSWORD and CAPABILITY_SIGN_PRIVKEY to enable POST /auth/mint', + ) + } + + const browserServer = startBrowserServer({ + certPath: tlsCertPath, + keyPath: tlsKeyPath, + bindHost, + bindPort, + node: dp.node, + landingHtml: 'relay', // unused when staticRoot is set + staticRoot: webRoot, + ...(onRequest ? { onRequest } : {}), + onListening: () => console.log(`[browser-wss] listening https://${bindHost}:${bindPort}`), + onError: (e) => console.error('[browser-wss]', e), + }) + + // INV12: a Redis relay:revocations kill-signal tears matching live tunnel(s) down on this node. + const revocableNode: RevocableNode = { + activeTunnels: () => + [...dp.listener.tunnels().values()].map((t) => ({ hostId: t.hostId, accountId: t.accountId })), + closeStream: (hostId) => dp.node.closeTunnel(hostId), + } + const revsub = startRevocationSubscriber({ + redisSubscriber, + node: revocableNode, + // INV10: log counts + scope KIND only — never signal.reason / terminal payload. + onApplied: (signal, hostsAffected) => + console.log(`[revocation] applied scope=${signal.scope.kind} hostsAffected=${hostsAffected}`), + onDropped: () => console.warn('[revocation] dropped malformed kill-signal'), + onError: (e) => console.error('[revocation]', e), + }) + + console.log('\n=== relay-run Phase 1 READY ===') + console.log(`Base domain : ${baseDomain} trustDomain: ${trustDomain} node: ${relayNodeId}`) + console.log(`Browser WSS : https://${bindHost}:${bindPort} (static root: ${webRoot})`) + console.log(`Agent mTLS : wss://${bindHost}:${agentBindPort}`) + console.log(`Allowed origins : ${allowedOrigins.join(', ')}`) + console.log(`Operator mint : ${mintEnabled ? 'ENABLED (STAGING /auth/mint)' : 'disabled'}`) + console.log('Ctrl-C to stop.\n') + + let shuttingDown = false + const shutdown = async (): Promise => { + if (shuttingDown) return + shuttingDown = true + console.log('\nshutting down…') + try { + revsub.close() + browserServer.close() + dp.listener.close() + await Promise.allSettled([redis.quit(), redisSubscriber.quit(), pool.end()]) + } catch (e) { + console.error('[shutdown]', e) + } finally { + process.exit(0) + } + } + process.on('SIGINT', () => void shutdown()) + process.on('SIGTERM', () => void shutdown()) +} + +main().catch((e) => { + console.error('fatal:', e instanceof Error ? e.message : e) + process.exit(1) +}) diff --git a/relay-run/src/servers/auth-mint.ts b/relay-run/src/servers/auth-mint.ts new file mode 100644 index 0000000..e4e4617 --- /dev/null +++ b/relay-run/src/servers/auth-mint.ts @@ -0,0 +1,262 @@ +/** + * B5 · STAGING-ONLY operator token-mint (`POST /auth/mint`). + * + * ┌─ STAGING NOTICE ────────────────────────────────────────────────────────────────────────────┐ + * │ The ONLY gate here is a shared `OPERATOR_PASSWORD` (constant-time compared). This is a │ + * │ deliberate Phase-1 staging shortcut so an operator can mint a browser capability token without │ + * │ the full human-auth stack. Phase 2 REPLACES this password gate with WebAuthn (P5 T5–T8). │ + * └────────────────────────────────────────────────────────────────────────────────────────────┘ + * + * Flow: the operator's browser generates its OWN DPoP keypair, computes the base64url SHA-256 JWK + * thumbprint `jkt`, and POSTs `{ password, jkt, subdomain }`. On a correct password we resolve the + * subdomain to its host row in the CP `hosts` store and mint a short-lived (<=60 s) §4.3 capability + * token BOUND to that `jkt` (`cnf.jkt`, RFC 7800 proof-of-possession) via the REAL P5 issue path. + * + * SECURITY: + * - INV3: `sub`(accountId)/`host`(hostId)/`aud`(subdomain) come SOLELY from the authenticated CP + * store row — the request body only NAMES a subdomain and the client's own DPoP thumbprint; it + * never supplies an accountId/hostId. + * - INV9: the signing private key and the minted token are NEVER logged. `onError` receives only + * the thrown error (callers must not log request bodies / tokens either). + * - Deny-by-default: unknown/revoked subdomain, bad password, malformed body → 4xx, no token. + * - Input is validated at the boundary (Zod) BEFORE any store/crypto work; body size is capped. + */ +import type { IncomingMessage, ServerResponse } from 'node:http' +import { createHash, timingSafeEqual } from 'node:crypto' +import { issueCapabilityToken, type AuthenticatedPrincipal } from 'relay-auth' +import type { CapabilityRight } from 'relay-contracts' +import type { HostStore } from 'control-plane/src/store/ports.js' + +/** Route this handler owns. */ +const MINT_PATH = '/auth/mint' as const +/** Minted tokens are connect-scoped and short-lived (P5 clamps issue to [30, 60] s). */ +const MINT_TTL_SEC = 60 +/** Reject an oversized request body before buffering it (DoS guard). */ +const MAX_BODY_BYTES = 4096 +/** Least-privilege: an operator connecting to their terminal needs only `attach`. */ +const MINT_RIGHTS: readonly CapabilityRight[] = ['attach'] +/** A base64url SHA-256 JWK thumbprint is exactly 43 chars of [A-Za-z0-9_-]. */ +const JKT_RE = /^[A-Za-z0-9_-]{43}$/ +/** A single DNS label (tenant subdomain): lowercase alnum + hyphens, 1–63 chars, no leading/trailing '-'. */ +const SUBDOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/ + +/** The single CP `hosts` capability this route needs (interface segregation). */ +export type SubdomainHostLookup = Pick + +export interface AuthMintDeps { + /** P5 capability signing PRIVATE key (Ed25519 CryptoKey with ['sign']). NEVER logged. */ + readonly signingKey: CryptoKey + /** CP hosts store — the ONLY source of accountId/hostId (INV3). */ + readonly hosts: SubdomainHostLookup + /** Shared staging operator password (constant-time compared). Phase 2 → WebAuthn. */ + readonly operatorPassword: string + /** Epoch-SECONDS clock (token iat/exp). */ + readonly now: () => number + /** Observability seam — receives thrown errors only (never bodies/tokens/keys). */ + readonly onError?: (e: unknown) => void +} + +interface MintRequest { + readonly password: string + readonly jkt: string + readonly subdomain: string +} + +/** Validate the untrusted JSON body at the boundary. Returns the typed request or `null` to reject. */ +function parseMintRequest(json: unknown): MintRequest | null { + if (typeof json !== 'object' || json === null) return null + const { password, jkt, subdomain } = json as Record + if (typeof password !== 'string' || password.length === 0) return null + if (typeof jkt !== 'string' || !JKT_RE.test(jkt)) return null + if (typeof subdomain !== 'string' || !SUBDOMAIN_RE.test(subdomain)) return null + return { password, jkt, subdomain } +} + +/** Length-independent constant-time string equality (compares fixed-size SHA-256 digests). */ +function safeEqual(a: string, b: string): boolean { + const ha = createHash('sha256').update(a).digest() + const hb = createHash('sha256').update(b).digest() + return timingSafeEqual(ha, hb) +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + const payload = JSON.stringify(body) + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }) + res.end(payload) +} + +/** Path portion of a URL (query/hash stripped) — the route matches on path only. */ +function pathOf(url: string): string { + return url.split('?', 1)[0].split('#', 1)[0] +} + +/** Buffer the request body up to `maxBytes`; reject (and destroy the stream) if it exceeds the cap. */ +function readBody(req: IncomingMessage, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + let size = 0 + const chunks: Buffer[] = [] + req.on('data', (chunk: Buffer) => { + size += chunk.length + if (size > maxBytes) { + req.destroy() + reject(new Error('request body too large')) + return + } + chunks.push(chunk) + }) + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + req.on('error', (e) => reject(e)) + }) +} + +function operatorPrincipal(accountId: string, authAt: number): AuthenticatedPrincipal { + // Minimal authenticated principal — issueCapabilityToken reads only `.accountId` (INV3). + return { + kind: 'human', + accountId, + principalId: `operator:${accountId}`, + amr: ['passkey'], + authAt, + stepUpAt: null, + } +} + +async function handleMint( + req: IncomingMessage, + res: ServerResponse, + deps: AuthMintDeps, +): Promise { + try { + let raw: string + try { + raw = await readBody(req, MAX_BODY_BYTES) + } catch { + sendJson(res, 413, { error: 'payload_too_large' }) + return + } + + let json: unknown + try { + json = JSON.parse(raw) + } catch { + sendJson(res, 400, { error: 'invalid_json' }) + return + } + + const parsed = parseMintRequest(json) + if (parsed === null) { + sendJson(res, 400, { error: 'invalid_request' }) + return + } + const { password, jkt, subdomain } = parsed + + // STAGING gate. Constant-time to avoid a password-length/prefix timing oracle. + if (!safeEqual(password, deps.operatorPassword)) { + sendJson(res, 401, { error: 'unauthorized' }) + return + } + + const host = await deps.hosts.getBySubdomain(subdomain) + if (host === null) { + sendJson(res, 404, { error: 'unknown_subdomain' }) + return + } + if (host.status === 'revoked') { + sendJson(res, 403, { error: 'host_revoked' }) + return + } + + const nowSec = deps.now() + // INV3: accountId/hostId/subdomain are the store row's, NEVER the request body's. + const token = await issueCapabilityToken( + { + principal: operatorPrincipal(host.accountId, nowSec), + aud: host.subdomain, + host: host.hostId, + rights: MINT_RIGHTS, + ttlSeconds: MINT_TTL_SEC, + cnfJkt: jkt, + }, + deps.signingKey, + nowSec, + ) + // INV9: the token is a bearer secret — return it, NEVER log it. + sendJson(res, 200, { token }) + } catch (e: unknown) { + deps.onError?.(e) + if (!res.headersSent) sendJson(res, 500, { error: 'internal_error' }) + } +} + +/** + * Build the `POST /auth/mint` pre-router for `startBrowserServer`'s `onRequest` hook. + * + * Returns `true` when it CLAIMS the request (its path is `/auth/mint`) so the caller must not also + * write a response — the actual mint completes asynchronously. Returns `false` for any other path so + * the request falls through to the static/landing handler. + */ +export function createAuthMintRoute( + deps: AuthMintDeps, +): (req: IncomingMessage, res: ServerResponse) => boolean { + return (req, res) => { + if (pathOf(req.url ?? '/') !== MINT_PATH) return false // not our route → fall through to static + if (req.method !== 'POST') { + sendJson(res, 405, { error: 'method_not_allowed' }) + return true + } + void handleMint(req, res, deps) + return true + } +} + +// ── P5 capability signing-key loader ────────────────────────────────────────────────────────────── + +const PEM_BODY_RE = /-----BEGIN [^-]+-----|-----END [^-]+-----/g + +/** Coerce to an ArrayBuffer-backed view (WebCrypto's importKey wants `Uint8Array`). */ +function toArrayBufferView(u: Uint8Array): Uint8Array { + const out = new Uint8Array(u.byteLength) + out.set(u) + return out +} + +/** Decode standard-or-URL base64 (padding optional) to bytes. */ +function base64AnyToBytes(s: string): Uint8Array { + const std = s.replace(/-/g, '+').replace(/_/g, '/') + return new Uint8Array(Buffer.from(std, 'base64')) +} + +/** Strip PEM armor + whitespace and base64-decode the body to DER bytes. */ +function pemBodyToDer(pem: string): Uint8Array { + const b64 = pem.replace(PEM_BODY_RE, '').replace(/\s+/g, '') + return base64AnyToBytes(b64) +} + +/** + * Load the P5 capability SIGNING key (Ed25519 private) from the `CAPABILITY_SIGN_PRIVKEY` env value. + * Accepts either a PKCS#8 PEM (the `gen-capability-key.sh` output — contains `-----BEGIN`) or a + * base64/base64url encoding of the PKCS#8 DER. Imported non-extractable with only `['sign']` usage. + * + * INV9: the raw key material is never logged; this throws a generic error on a malformed value. + */ +export async function loadSigningKeyFromEnv(raw: string): Promise { + const trimmed = raw.trim() + if (trimmed.length === 0) throw new Error('CAPABILITY_SIGN_PRIVKEY is empty') + const der = trimmed.includes('BEGIN') ? pemBodyToDer(trimmed) : base64AnyToBytes(trimmed) + if (der.length === 0) throw new Error('CAPABILITY_SIGN_PRIVKEY did not decode to any key bytes') + try { + return await globalThis.crypto.subtle.importKey( + 'pkcs8', + toArrayBufferView(der), + { name: 'Ed25519' }, + false, + ['sign'], + ) + } catch { + // Never surface the underlying material in the error. + throw new Error('CAPABILITY_SIGN_PRIVKEY is not a valid PKCS#8 Ed25519 private key') + } +} diff --git a/relay-run/src/servers/browser-server.ts b/relay-run/src/servers/browser-server.ts index 18e4756..50aeeee 100644 --- a/relay-run/src/servers/browser-server.ts +++ b/relay-run/src/servers/browser-server.ts @@ -12,6 +12,7 @@ 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' +import { serveStatic } from './static-web.js' export interface BrowserServerOptions { readonly certPath: string @@ -20,6 +21,20 @@ export interface BrowserServerOptions { readonly bindPort: number readonly node: RelayNode readonly landingHtml: string + /** + * When set, the HTTP handler serves the built relay-web bundle from this directory (D1), SAME + * ORIGIN as the WSS (so Origin/CSP stay aligned). When unset, `landingHtml` is served — the + * Phase-0 dev fallback. The WS upgrade is unaffected either way (it rides the `upgrade` event). + */ + readonly staticRoot?: string + /** + * Optional pre-router (B5): consulted BEFORE `staticRoot`/`landingHtml` on every non-upgrade HTTP + * request. Return `true` to claim the request (the hook owns the response — it may finish it + * asynchronously); return `false` to fall through to the static/landing behavior below. Default + * (undefined) preserves D1's behavior exactly. WS upgrades never reach this hook (they ride the + * `upgrade` event), so same-origin `POST /auth/mint` can coexist with the WSS. + */ + readonly onRequest?: (req: IncomingMessage, res: ServerResponse) => boolean readonly onListening?: () => void readonly onError?: (e: unknown) => void } @@ -56,7 +71,24 @@ function buildUpgradeRequest(req: IncomingMessage): UpgradeRequest { export function startBrowserServer(opts: BrowserServerOptions): Server { const server = createServer( { cert: readFileSync(opts.certPath), key: readFileSync(opts.keyPath) }, - (_req: IncomingMessage, res: ServerResponse) => { + (req: IncomingMessage, res: ServerResponse) => { + // Pre-router (B5): a claimed request is fully owned by the hook (e.g. POST /auth/mint) and + // must NOT fall through to static/landing (which would double-write the response). + if (opts.onRequest !== undefined && opts.onRequest(req, res)) return + // Static-bundle mode (Phase 1): serve relay-web from `staticRoot`, SAME-ORIGIN as the WSS. + // Non-upgrade HTTP requests only — WS upgrades never reach this handler (see `upgrade` event). + if (opts.staticRoot !== undefined) { + const file = serveStatic(opts.staticRoot, req.url ?? '/') + if (file) { + res.writeHead(file.status, file.headers) + res.end(file.body) + } else { + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) + res.end('Not Found') + } + return + } + // Phase-0 fallback: a single landing page. res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) res.end(opts.landingHtml) }, diff --git a/relay-run/src/servers/static-web.ts b/relay-run/src/servers/static-web.ts new file mode 100644 index 0000000..95ad410 --- /dev/null +++ b/relay-run/src/servers/static-web.ts @@ -0,0 +1,96 @@ +/** + * Pure static-file resolver for the browser HTTPS server (D1). Serves the built relay-web bundle + * SAME-ORIGIN as the browser WSS so the page's Origin and CSP stay aligned with the WS endpoint. + * + * Deterministic given (root, urlPath): it resolves the request path under `root`, reads the file, + * and returns its bytes + Content-Type on a hit — or `null` when the path is malformed, escapes + * `root` (traversal), or names no readable regular file. The caller maps `null` to a 404. + * + * Security (STRICT traversal guard): the resolved absolute path MUST be `root` itself or a + * descendant of it. `../`, percent-encoded `..` (`%2e%2e`), and NUL bytes all reject to `null` — + * this function never reads a byte outside `root`. + */ +import { readFileSync, statSync } from 'node:fs' +import { resolve, sep, extname } from 'node:path' + +export interface StaticFile { + readonly status: number + readonly headers: Record + readonly body: Buffer +} + +/** Served for a bare `/` request. */ +const DEFAULT_DOC = 'index.html' +const OCTET_STREAM = 'application/octet-stream' + +/** Extension → Content-Type. Covers what the relay-web bundle ships plus common static assets. */ +const MIME_BY_EXT: Readonly> = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.ico': 'image/x-icon', + '.webmanifest': 'application/manifest+json', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.txt': 'text/plain; charset=utf-8', +} + +function contentTypeFor(filePath: string): string { + return MIME_BY_EXT[extname(filePath).toLowerCase()] ?? OCTET_STREAM +} + +/** + * Strip query/hash, percent-decode, and reject malformed / NUL-byte paths. Returns the path made + * relative to `root` (leading slashes stripped, `/` → `index.html`), or `null` to reject. + */ +function normalizeUrlPath(urlPath: string): string | null { + const noQuery = urlPath.split('?')[0].split('#')[0] + let decoded: string + try { + decoded = decodeURIComponent(noQuery) + } catch { + return null + } + if (decoded.includes('\0')) return null + const rel = decoded.replace(/^\/+/, '') + return rel.length === 0 ? DEFAULT_DOC : rel +} + +/** + * Resolve `urlPath` to a file under `root`. Returns the file bytes + MIME on a hit, or `null` when + * the path is malformed, escapes `root`, or names no readable regular file. + */ +export function serveStatic(root: string, urlPath: string): StaticFile | null { + const rel = normalizeUrlPath(urlPath) + if (rel === null) return null + + const rootAbs = resolve(root) + const resolved = resolve(rootAbs, rel) + + // STRICT traversal guard: resolved must be the root itself or a descendant of it. + if (resolved !== rootAbs && !resolved.startsWith(rootAbs + sep)) return null + + try { + if (!statSync(resolved).isFile()) return null + const body = readFileSync(resolved) + return { + status: 200, + headers: { + 'content-type': contentTypeFor(resolved), + 'content-length': String(body.length), + }, + body, + } + } catch { + // ENOENT / EACCES / etc. — treat as "no servable file", caller 404s. + return null + } +} diff --git a/relay-run/src/wiring/mtls-verifier.ts b/relay-run/src/wiring/mtls-verifier.ts new file mode 100644 index 0000000..35d870f --- /dev/null +++ b/relay-run/src/wiring/mtls-verifier.ts @@ -0,0 +1,93 @@ +/** + * B2 · registry-backed MtlsVerifier (INV4/INV14) — replaces the Phase-0 stub in `relay-world.ts:149` + * that trusted ANY peer cert. Wraps relay-auth's `verifyAgentCert` against the SHARED host registry + * (the same `HostRegistryPort` B1 builds over Postgres) and the pinned agent-CA bundle, returning + * `{ hostId, accountId }` ONLY for an enrolled, unrevoked host whose leaf chains to our CA and is in + * its validity window. Every other outcome is `null` (fail-closed). + * + * INV3: `hostId`/`accountId` originate ONLY from the authenticated cert material (SPIFFE-ID) matched + * against the registry — never from a client-supplied field. INV14: the pubkey is bound to the + * registry AFTER CA-chain validation (a cert can chain to our CA yet still be denied if not enrolled). + * + * ── ASYNC IMPEDANCE (flagged for the orchestrator; adaptation per task B2) ────────────────────── + * The term-relay `MtlsVerifier.verifyPeer` (agent-listener.ts:16) is declared SYNC and its caller + * (agent-listener.ts:93) does NOT await it (`if (verified === null)`). A registry-backed verifier + * CANNOT be sync — `HostRegistryPort.getById` returns a Promise, so `verifyAgentCert` is async. This + * factory therefore returns an `AsyncMtlsVerifier` (Promise-returning `verifyPeer`). Wiring it into the + * data-plane requires `MtlsVerifier.verifyPeer` to become async END-TO-END: `agent-listener.ts` must + * `await` the result, and the `relay-world.ts:149` stub must be replaced. Those files are OUTSIDE B2's + * Owns. Until that migration lands, assigning this into the sync slot type-errors at the wiring site + * (`Promise<…>` is not assignable to `{…}|null`) — a useful compile-time tripwire, not a silent bug. + */ +import { + verifyAgentCert, + defaultParseX509, + type ParseCert, + type HostRegistryPort, +} from 'relay-auth' + +const PEM_LINE_WIDTH = 64 +/** Non-global (safe for `.test()`): asserts a bundle actually holds a PEM CERTIFICATE block. */ +const PEM_CERT_RE = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/ + +/** + * Async analog of term-relay's sync `MtlsVerifier` (agent-listener.ts). See ASYNC IMPEDANCE above: + * a registry lookup is inherently async, so `verifyPeer` returns a Promise. + */ +export interface AsyncMtlsVerifier { + verifyPeer(peerCertDer: Uint8Array): Promise<{ hostId: string; accountId: string } | null> +} + +export interface MtlsVerifierDeps { + /** Pinned agent-CA bundle (PEM: intermediate(s) + root). NEVER the browser LE chain (INV14). */ + readonly caChainPem: string + /** Shared host registry (same port B1 builds over Postgres). accountId/hostId only from here (INV3). */ + readonly hosts: HostRegistryPort + /** Epoch-SECONDS provider (compared against the leaf's notBefore/notAfter). */ + readonly now: () => number + /** Optional observability seam for a registry/parse fault; the verifier still fails closed. */ + readonly onError?: (e: unknown) => void + /** Test seam: cert parser (defaults to production `defaultParseX509`). */ + readonly parse?: ParseCert +} + +/** + * Wrap raw DER bytes (`getPeerCertificate(true).raw`) into a PEM certificate block with 64-column + * base64, the shape node's `X509Certificate` (via `verifyAgentCert`) parses. + */ +export function derToPem(der: Uint8Array): string { + const b64 = Buffer.from(der).toString('base64') + const wrapped = b64.match(new RegExp(`.{1,${PEM_LINE_WIDTH}}`, 'g'))?.join('\n') ?? '' + return `-----BEGIN CERTIFICATE-----\n${wrapped}\n-----END CERTIFICATE-----\n` +} + +export function createMtlsVerifier(deps: MtlsVerifierDeps): AsyncMtlsVerifier { + const { caChainPem, hosts, now } = deps + const onError = deps.onError ?? (() => {}) + const parse = deps.parse ?? defaultParseX509 + + // Fail fast at construction on a misconfigured CA bundle (INV14: a real pinned CA is required; an + // empty/garbage bundle would otherwise silently deny every agent with an opaque `chain_invalid`). + if (typeof caChainPem !== 'string' || !PEM_CERT_RE.test(caChainPem)) { + throw new Error('createMtlsVerifier: caChainPem must contain at least one PEM CERTIFICATE block') + } + + return { + async verifyPeer(peerCertDer) { + if (peerCertDer === undefined || peerCertDer === null || peerCertDer.length === 0) { + return null // no client cert presented → fail closed + } + try { + const leafPem = derToPem(peerCertDer) + const result = await verifyAgentCert(leafPem, caChainPem, now(), hosts, parse) + if (!result.ok || result.hostId === undefined || result.accountId === undefined) { + return null // expired / chain-invalid / not-enrolled / revoked / account-mismatch → fail closed + } + return { hostId: result.hostId, accountId: result.accountId } + } catch (e: unknown) { + onError(e) // registry/DB fault: surface it, never throw out of verifyPeer → fail closed + return null + } + }, + } +} diff --git a/relay-run/src/wiring/revocation-subscriber.ts b/relay-run/src/wiring/revocation-subscriber.ts new file mode 100644 index 0000000..9fed248 --- /dev/null +++ b/relay-run/src/wiring/revocation-subscriber.ts @@ -0,0 +1,121 @@ +/** + * B4 · relay-run revocation subscriber (INV12 data-plane teardown). Bridges the frozen §4.2 + * `relay:revocations` Redis pub/sub bus onto the running relay node so a revocation tears the live + * tunnel(s) down within the INV12 budget. + * + * Flow: an injected ioredis subscriber-mode client SUBSCRIBEs the one named channel; each message is + * validated as a `KillSignal` with the relay-contracts schema (malformed → dropped + counted, never + * a teardown and never a bus crash — a poisoned message can neither kill the bus nor fire a spurious + * kill); then for every live tunnel THIS node serves the pure relay-auth predicate + * `killsScope(signal, hostAccountId, hostId)` decides coverage and, on a match, the whole host is + * torn down (revoked ⇒ grace forced to 0 upstream in T11). + * + * Reuse, don't re-derive: scope math is `killsScope` (relay-auth) and validation is `KillSignalSchema` + * (relay-contracts) — this module only wires those pure primitives to a concrete ioredis client and + * the relay node. Security: scope selection is blast-radius-bounded (host ⊂ account ⊂ global); a host + * this node does not serve is a no-op (no cross-tenant reach). `signal.reason` is metadata only and is + * never written as terminal payload or log content (INV10). The owning `accountId` used for + * account-scope fan-out comes only from the authenticated tunnel (INV3). + */ +import { KillSignalSchema, RELAY_REVOCATIONS_CHANNEL, type KillSignal } from 'relay-contracts' +import { killsScope } from 'relay-auth' + +/** One live tunnel this node currently serves: the host and the account that owns it. Both are + * authenticated material carried on the tunnel (INV3) — never derived from the untrusted signal. */ +export interface ActiveTunnelRef { + readonly hostId: string + readonly accountId: string +} + +/** + * What the subscriber needs from the running relay node: enumerate the tunnels it serves and tear a + * whole host's live streams/devices down (INV12). Kept narrow + injectable so the Phase-1 entry (B5) + * adapts the real data-plane {node, listener}: `activeTunnels` ← `listener.tunnels()` values, + * `closeStream(hostId)` ← `node.closeTunnel(hostId)` (the whole-host revocation lever). + */ +export interface RevocableNode { + /** Snapshot-friendly view of the tunnels currently attached to this node. */ + activeTunnels(): Iterable + /** Immediately tear down every live stream/device on `hostId` (whole-host revocation, INV12). */ + closeStream(hostId: string): void +} + +/** + * Minimal structural view of an ioredis client in subscriber mode — only the members used here — so + * this module carries no hard dependency on ioredis and stays unit-testable with a fake. A real + * ioredis `Redis` instance satisfies this shape. + */ +export interface RedisSubscriber { + subscribe(channel: string): Promise + unsubscribe(channel: string): Promise + on(event: 'message', listener: (channel: string, message: string) => void): unknown + off(event: 'message', listener: (channel: string, message: string) => void): unknown +} + +export interface RevocationSubscriberDeps { + readonly redisSubscriber: RedisSubscriber + readonly node: RevocableNode + /** Observability (metadata only, INV10): a valid signal was applied to `hostsAffected` hosts here. */ + readonly onApplied?: (signal: KillSignal, hostsAffected: number) => void + /** Malformed-message counter — a dropped signal never fires a teardown (INV12 safety). */ + readonly onDropped?: () => void + /** Boundary error routing for subscribe/unsubscribe failures — never silently swallowed. */ + readonly onError?: (error: unknown) => void +} + +export interface RevocationSubscription { + close(): void +} + +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): RevocationSubscription { + const { redisSubscriber, node, onApplied, onDropped, onError } = deps + + const onMessage = (channel: string, message: string): void => { + if (channel !== RELAY_REVOCATIONS_CHANNEL) return // this client may also carry other channels + const signal = parseKillSignal(message) + if (signal === null) { + onDropped?.() // dropped + counted; bus stays alive, no teardown fired + return + } + // Snapshot first: closeStream tears down (mutates) the node's live-tunnel set as we iterate. + const tunnels = [...node.activeTunnels()] + let hostsAffected = 0 + for (const { hostId, accountId } of tunnels) { + if (!killsScope(signal, accountId, hostId)) continue // blast-radius bounded; unrelated host = no-op + node.closeStream(hostId) + hostsAffected += 1 + } + onApplied?.(signal, hostsAffected) + } + + redisSubscriber.on('message', onMessage) + // Fire the SUBSCRIBE; route a rejected subscribe to onError (boundary I/O — never swallowed). + Promise.resolve(redisSubscriber.subscribe(RELAY_REVOCATIONS_CHANNEL)).catch((error: unknown) => { + onError?.(error) + }) + + let closed = false + return { + close(): void { + if (closed) return // idempotent teardown + closed = true + redisSubscriber.off('message', onMessage) + Promise.resolve(redisSubscriber.unsubscribe(RELAY_REVOCATIONS_CHANNEL)).catch( + (error: unknown) => { + onError?.(error) + }, + ) + }, + } +} diff --git a/relay-run/src/wiring/route-resolver.ts b/relay-run/src/wiring/route-resolver.ts new file mode 100644 index 0000000..c5cb48f --- /dev/null +++ b/relay-run/src/wiring/route-resolver.ts @@ -0,0 +1,40 @@ +/** + * B3 · Store-backed RouteResolver — the PRODUCTION replacement for the one-entry in-RAM + * `memoryRouteResolver` (wiring/data-plane.ts:110). It implements term-relay's `RouteResolver` + * interface EXACTLY (`resolveSubdomain(subdomain) -> ResolvedHost | null`) by looking the tenant + * label up in the control-plane Postgres `hosts` store via `hosts.getBySubdomain(subdomain)`. + * + * Fail-closed (returns null, → 403 at the T8 upgrade edge) on: + * - unknown subdomain: `getBySubdomain` returns null. + * - revoked host: the row still exists (status is versioned, not deleted — INV8/INV12), so we + * must reject `status === 'revoked'` here; a revoked host must never resolve to a route. + * + * The resolver is only a HINT for candidate lookup (subdomain-router.ts): the returned `hostId` + * is what `authorizeUpgrade` feeds to P5 as `requestedHostId`, and P5 gates the signed token's + * `host` against it (INV1). Identity (`accountId`/`hostId`) here comes solely from the CP store — + * the ownership source of truth — never from the caller (INV3). + */ +import type { HostStore } from 'control-plane/src/store/ports.js' +import type { RouteResolver, ResolvedHost } from 'term-relay/data-plane/subdomain-router.js' + +/** + * The single HostStore capability this resolver needs (interface segregation): a subdomain lookup. + * A full `createPgStores().hosts` (`HostStore`) satisfies it structurally. + */ +export type HostSubdomainLookup = Pick + +export interface StoreRouteResolverDeps { + readonly hosts: HostSubdomainLookup +} + +/** Build a RouteResolver that resolves subdomain → host from the control-plane `hosts` store. */ +export function createStoreRouteResolver({ hosts }: StoreRouteResolverDeps): RouteResolver { + return { + async resolveSubdomain(subdomain: string): Promise { + const host = await hosts.getBySubdomain(subdomain) + if (host === null) return null // unknown subdomain — fail closed + if (host.status === 'revoked') return null // revoked host — fail closed (INV12) + return { hostId: host.hostId, accountId: host.accountId, subdomain: host.subdomain } + }, + } +} diff --git a/relay-run/src/wiring/stores-pg.ts b/relay-run/src/wiring/stores-pg.ts new file mode 100644 index 0000000..9801e53 --- /dev/null +++ b/relay-run/src/wiring/stores-pg.ts @@ -0,0 +1,197 @@ +/** + * B1 — shared-store `EnforceDeps` for relay-auth's `onUpgrade` pipeline, backed by the SAME + * Postgres + Redis as the control-plane (P3). This replaces the Phase-0 in-RAM fakes + * (`memory-stores.ts`) so the data-plane and the control-plane read one world of truth + * (restart-safe, INV7). + * + * Design: + * - hosts / sessions / audit -> Postgres via the CP repository adapter (`createPgStores(query)`). + * The CP `HostRecord` IS the relay-auth `HostRecord` (both re-export the frozen relay-contracts + * §4.2 shape), so `getById` is a direct pass-through; sessions/audit remap to the port shapes. + * - revocation / buckets -> Redis. `consumeOnce` is a single-use `SET NX` burn (Finding-4); the + * token bucket is an atomic Lua script (no read-modify-write race). + * + * SECURITY: + * - INV3: `accountId` is only ever read from authenticated material (the CP registry rows / the + * principal), never from client input. This adapter surfaces stored rows; it never fabricates an + * accountId from a request field. + * - Fail-closed: store/Redis errors are NOT swallowed — they reject and the enforcement pipeline + * denies the upgrade (deny-by-default). + * - INV9/INV10: audit rows carry metadata only (no keys, no terminal payload). + */ +import { createPgStores } from 'control-plane/src/store/pg.js' +import type { QueryFn } from 'control-plane/src/db/pool.js' +import type { + AuditEvent, + EnforceDeps, + HostRegistryPort, + RevocationStore, + SessionRegistryPort, + TokenBucketStore, +} from 'relay-auth' +import { NO_STEPUP_POLICY } from 'relay-auth/src/human/stepup/stepup.js' + +// ── Redis surface (ioredis-compatible, injected loosely so tests can pass a mock) ──────────────── + +/** + * The minimal slice of an ioredis client this adapter uses. A real `ioredis` `Redis` instance + * structurally satisfies this (its methods are supersets), and a unit-test mock can implement it. + */ +export interface RedisLike { + exists(key: string): Promise + set(key: string, value: string, mode?: 'NX'): Promise + expireat(key: string, timestamp: number): Promise + eval(script: string, numKeys: number, ...args: (string | number)[]): Promise +} + +export interface RelayEnforceDepsConfig { + readonly query: QueryFn + readonly redis: RedisLike +} + +// ── Redis key namespaces (opaque to relay-auth) ────────────────────────────────────────────────── + +const REVOKED_PREFIX = 'revoked:' as const +const USED_PREFIX = 'used:' as const +const BUCKET_PREFIX = 'bucket:' as const + +/** Buffer added to the computed refill window before a fully-refilled bucket key may be reaped. */ +const BUCKET_TTL_BUFFER_SEC = 1 + +/** + * Atomic token bucket (lazy refill). One round-trip, no read-modify-write race: + * KEYS[1]=bucket key · ARGV: refillPerSec, burst(capacity), now(epoch sec), ttl(sec). + * Returns 1 when a token was available (and consumed), 0 when throttled. + */ +const TOKEN_BUCKET_LUA = ` +local key = KEYS[1] +local refill = tonumber(ARGV[1]) +local burst = tonumber(ARGV[2]) +local now = tonumber(ARGV[3]) +local ttl = tonumber(ARGV[4]) +local state = redis.call('HMGET', key, 't', 'ts') +local tokens = tonumber(state[1]) +local ts = tonumber(state[2]) +if tokens == nil then + tokens = burst + ts = now +end +local elapsed = now - ts +if elapsed > 0 then + tokens = math.min(burst, tokens + elapsed * refill) +end +local allowed = 0 +if tokens >= 1 then + tokens = tokens - 1 + allowed = 1 +end +redis.call('HSET', key, 't', tokens, 'ts', now) +redis.call('EXPIRE', key, ttl) +return allowed +` + +/** Seconds a bucket needs to fully refill from empty, plus a small reap buffer. */ +function bucketTtlSec(refillPerSec: number, burst: number): number { + const refillWindow = refillPerSec > 0 ? Math.ceil(burst / refillPerSec) : Math.ceil(burst) + return Math.max(1, refillWindow) + BUCKET_TTL_BUFFER_SEC +} + +// ── Redis-backed ports ─────────────────────────────────────────────────────────────────────────── + +function redisRevocationStore(redis: RedisLike): RevocationStore { + return { + async isRevoked(jti) { + return (await redis.exists(REVOKED_PREFIX + jti)) > 0 + }, + async revokeJti(jti, exp) { + await redis.set(REVOKED_PREFIX + jti, '1') + await redis.expireat(REVOKED_PREFIX + jti, exp) + }, + async consumeOnce(jti, exp) { + // First-use wins: SET NX returns 'OK' only when the key did not exist (Finding-4). + const set = await redis.set(USED_PREFIX + jti, '1', 'NX') + if (set !== 'OK') return false + await redis.expireat(USED_PREFIX + jti, exp) + return true + }, + } +} + +function redisTokenBucketStore(redis: RedisLike): TokenBucketStore { + return { + async take(key, refillPerSec, burst, now) { + const ttl = bucketTtlSec(refillPerSec, burst) + const allowed = await redis.eval( + TOKEN_BUCKET_LUA, + 1, + BUCKET_PREFIX + key, + refillPerSec, + burst, + now, + ttl, + ) + return Number(allowed) === 1 + }, + } +} + +// ── Postgres-backed ports (over the CP repository adapter) ──────────────────────────────────────── + +/** Map a relay-auth `AuditEvent` onto the CP `audit_log` row shape (metadata only, INV10). */ +function toAuditRow(e: AuditEvent): { + action: string + principalId: string + accountId: string + hostId: string | null + ts: string + meta: Record +} { + const meta: Record = { + outcome: e.outcome, + reason: e.reason, + remoteAddrHash: e.remoteAddrHash, + } + if (e.sessionId !== null) meta.sessionId = e.sessionId + if (e.jti !== null) meta.jti = e.jti + return { + action: e.action, + principalId: e.principalId, + accountId: e.accountId, + hostId: e.hostId, + ts: e.ts, + meta, + } +} + +// ── Assembly ────────────────────────────────────────────────────────────────────────────────────── + +/** + * Build the relay-auth `EnforceDeps` over the shared Postgres (`query`) + Redis (`redis`). + * `stepUpPolicyFor` returns `NO_STEPUP_POLICY` (staging single-operator; Phase 2 -> per-host WebAuthn). + */ +export function createRelayEnforceDeps({ query, redis }: RelayEnforceDepsConfig): EnforceDeps { + const stores = createPgStores(query) + + const hosts: HostRegistryPort = { + // CP HostRecord === relay-auth HostRecord (both = relay-contracts §4.2) — direct pass-through. + getById: (hostId) => stores.hosts.get(hostId), + } + + const sessions: SessionRegistryPort = { + getById: async (sessionId) => { + const rec = await stores.sessions.get(sessionId) + return rec === null ? null : { hostId: rec.hostId, accountId: rec.accountId } + }, + } + + return { + hosts, + sessions, + revocation: redisRevocationStore(redis), + buckets: redisTokenBucketStore(redis), + audit: { + append: (e) => stores.audit.append(toAuditRow(e)), + }, + stepUpPolicyFor: () => NO_STEPUP_POLICY, + } +} diff --git a/relay-run/tests/auth-mint.test.ts b/relay-run/tests/auth-mint.test.ts new file mode 100644 index 0000000..eba26c2 --- /dev/null +++ b/relay-run/tests/auth-mint.test.ts @@ -0,0 +1,260 @@ +/** + * B5 · unit tests for the STAGING operator token-mint (`POST /auth/mint`) and the P5 capability + * signing-key loader. Covers: the DPoP-bound short-lived token happy path (claims + PoP binding), + * the deny-by-default gates (bad password / unknown+revoked subdomain / malformed body / wrong + * method / oversized body), route claiming semantics, and PEM+base64 key loading round-trips. + */ +import { describe, it, expect, beforeAll } from 'vitest' +import { Readable } from 'node:stream' +import type { IncomingMessage, ServerResponse } from 'node:http' +import type { HostRecord } from 'control-plane/src/model/records.js' +import { verifyPaseto } from 'relay-auth/src/crypto/paseto.js' +import { + createAuthMintRoute, + loadSigningKeyFromEnv, + type SubdomainHostLookup, +} from '../src/servers/auth-mint.js' + +const subtle = globalThis.crypto.subtle +const NOW = 1_800_000_000 +const PASSWORD = 'staging-operator-secret' +// A syntactically valid base64url SHA-256 JWK thumbprint (exactly 43 chars). +const JKT = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNO12'.padEnd(43, 'X').slice(0, 43) + +interface Loaded { + readonly signingKey: CryptoKey + readonly publicKey: CryptoKey +} + +let keys: Loaded + +beforeAll(async () => { + const kp = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as { + publicKey: CryptoKey + privateKey: CryptoKey + } + keys = { signingKey: kp.privateKey, publicKey: kp.publicKey } +}) + +function mkHost(overrides: Partial = {}): HostRecord { + return { + hostId: 'host-1', + accountId: 'acct-1', + subdomain: 'alice', + agentPubkey: new Uint8Array(32), + enrollFpr: 'fpr-host-1', + status: 'online', + lastSeen: '2026-01-01T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + revokedAt: null, + ...overrides, + } +} + +function fakeHosts(rec: HostRecord | null): SubdomainHostLookup { + return { getBySubdomain: async () => rec } +} + +interface CapturedRes { + statusCode: number + headers: Record + body: string + headersSent: boolean + writeHead(status: number, headers: Record): CapturedRes + end(chunk?: string): void +} + +function fakeRes(): { res: ServerResponse; captured: CapturedRes; done: Promise } { + let resolveDone!: () => void + const done = new Promise((r) => (resolveDone = r)) + const captured: CapturedRes = { + statusCode: 0, + headers: {}, + body: '', + headersSent: false, + writeHead(status, headers) { + this.statusCode = status + this.headers = headers + this.headersSent = true + return this + }, + end(chunk?: string) { + if (chunk !== undefined) this.body += chunk + resolveDone() + }, + } + return { res: captured as unknown as ServerResponse, captured, done } +} + +function fakeReq(method: string, url: string, body?: string): IncomingMessage { + const chunks = body === undefined ? [] : [Buffer.from(body, 'utf8')] + const req = Readable.from(chunks) as unknown as IncomingMessage + ;(req as { method?: string }).method = method + ;(req as { url?: string }).url = url + return req +} + +function mkRoute(hosts: SubdomainHostLookup): (req: IncomingMessage, res: ServerResponse) => boolean { + return createAuthMintRoute({ + signingKey: keys.signingKey, + hosts, + operatorPassword: PASSWORD, + now: () => NOW, + onError: () => {}, + }) +} + +async function post( + hosts: SubdomainHostLookup, + bodyObj: unknown, +): Promise { + const route = mkRoute(hosts) + const { res, captured, done } = fakeRes() + const claimed = route(fakeReq('POST', '/auth/mint', JSON.stringify(bodyObj)), res) + expect(claimed).toBe(true) + await done + return captured +} + +describe('createAuthMintRoute — happy path', () => { + it('mints a short-lived capability token bound to the client jkt (INV3 identity from store)', async () => { + const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'alice' }) + + expect(captured.statusCode).toBe(200) + expect(captured.headers['cache-control']).toBe('no-store') + const parsedBody = JSON.parse(captured.body) as { token: string } + expect(typeof parsedBody.token).toBe('string') + + const claims = (await verifyPaseto(parsedBody.token, keys.publicKey)) as { + sub: string + aud: string + host: string + rights: string[] + iat: number + exp: number + cnf: { jkt: string } + } + // Identity is the STORE row's, never the request body's (INV3). + expect(claims.sub).toBe('acct-1') + expect(claims.host).toBe('host-1') + expect(claims.aud).toBe('alice') + expect(claims.rights).toContain('attach') + // DPoP proof-of-possession binding to the client-provided thumbprint. + expect(claims.cnf.jkt).toBe(JKT) + // Short-lived (<= 60 s). + expect(claims.iat).toBe(NOW) + expect(claims.exp - claims.iat).toBeLessThanOrEqual(60) + expect(claims.exp - claims.iat).toBeGreaterThan(0) + // The token itself must never leak into logs — asserted by construction (no console here). + }) +}) + +describe('createAuthMintRoute — deny by default', () => { + it('rejects a wrong password with 401 (no token)', async () => { + const captured = await post(fakeHosts(mkHost()), { password: 'wrong', jkt: JKT, subdomain: 'alice' }) + expect(captured.statusCode).toBe(401) + expect(captured.body).not.toContain('token') + }) + + it('rejects an unknown subdomain with 404', async () => { + const captured = await post(fakeHosts(null), { password: PASSWORD, jkt: JKT, subdomain: 'ghost' }) + expect(captured.statusCode).toBe(404) + }) + + it('rejects a revoked host with 403 (INV12: revoked never mints)', async () => { + const captured = await post( + fakeHosts(mkHost({ status: 'revoked', revokedAt: '2026-02-01T00:00:00.000Z' })), + { password: PASSWORD, jkt: JKT, subdomain: 'alice' }, + ) + expect(captured.statusCode).toBe(403) + }) + + it('rejects a malformed jkt with 400', async () => { + const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: 'too-short', subdomain: 'alice' }) + expect(captured.statusCode).toBe(400) + }) + + it('rejects a malformed subdomain with 400', async () => { + const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'Not_Valid!' }) + expect(captured.statusCode).toBe(400) + }) + + it('rejects invalid JSON with 400', async () => { + const route = mkRoute(fakeHosts(mkHost())) + const { res, captured, done } = fakeRes() + const claimed = route(fakeReq('POST', '/auth/mint', '{not json'), res) + expect(claimed).toBe(true) + await done + expect(captured.statusCode).toBe(400) + }) + + it('rejects an oversized body with 413', async () => { + const big = 'x'.repeat(5000) + const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'alice', pad: big }) + expect(captured.statusCode).toBe(413) + }) +}) + +describe('createAuthMintRoute — routing semantics', () => { + it('claims /auth/mint but 405s a non-POST method', () => { + const route = mkRoute(fakeHosts(mkHost())) + const { res, captured } = fakeRes() + const claimed = route(fakeReq('GET', '/auth/mint', undefined), res) + expect(claimed).toBe(true) + expect(captured.statusCode).toBe(405) + }) + + it('does NOT claim a non-matching path (returns false, response untouched)', () => { + const route = mkRoute(fakeHosts(mkHost())) + const { res, captured } = fakeRes() + const claimed = route(fakeReq('POST', '/index.html', undefined), res) + expect(claimed).toBe(false) + expect(captured.statusCode).toBe(0) + }) + + it('claims /auth/mint even with a query string', () => { + const route = mkRoute(fakeHosts(null)) + const { res } = fakeRes() + const claimed = route(fakeReq('GET', '/auth/mint?foo=1', undefined), res) + expect(claimed).toBe(true) + }) +}) + +describe('loadSigningKeyFromEnv', () => { + async function exportPkcs8Pem(): Promise<{ pem: string; b64: string; publicKey: CryptoKey }> { + const kp = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as { + publicKey: CryptoKey + privateKey: CryptoKey + } + const der = new Uint8Array(await subtle.exportKey('pkcs8', kp.privateKey)) + const b64 = Buffer.from(der).toString('base64') + const pem = `-----BEGIN PRIVATE KEY-----\n${b64.match(/.{1,64}/g)!.join('\n')}\n-----END PRIVATE KEY-----\n` + return { pem, b64, publicKey: kp.publicKey } + } + + async function canSign(priv: CryptoKey, pub: CryptoKey): Promise { + const data = new Uint8Array([1, 2, 3, 4]) + const sig = new Uint8Array(await subtle.sign({ name: 'Ed25519' }, priv, data)) + return subtle.verify({ name: 'Ed25519' }, pub, sig, data) + } + + it('loads a PKCS#8 PEM into a usable signing key', async () => { + const { pem, publicKey } = await exportPkcs8Pem() + const key = await loadSigningKeyFromEnv(pem) + expect(await canSign(key, publicKey)).toBe(true) + }) + + it('loads a bare base64 PKCS#8 DER into a usable signing key', async () => { + const { b64, publicKey } = await exportPkcs8Pem() + const key = await loadSigningKeyFromEnv(b64) + expect(await canSign(key, publicKey)).toBe(true) + }) + + it('throws on an empty value', async () => { + await expect(loadSigningKeyFromEnv(' ')).rejects.toThrow() + }) + + it('throws on a non-key value (never leaking material)', async () => { + await expect(loadSigningKeyFromEnv('not-a-real-key')).rejects.toThrow() + }) +}) diff --git a/relay-run/tests/static-web.test.ts b/relay-run/tests/static-web.test.ts new file mode 100644 index 0000000..9b4d254 --- /dev/null +++ b/relay-run/tests/static-web.test.ts @@ -0,0 +1,109 @@ +/** + * Unit tests for the pure static-file resolver (D1). Covers index resolution, MIME mapping, query + * stripping, and the STRICT path-traversal guard (raw `..`, percent-encoded `%2e%2e`, and NUL byte + * all reject — even when the escaped target file genuinely exists on disk). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { serveStatic } from '../src/servers/static-web.js' + +const INDEX_HTML = 'idx' +const PAIR_HTML = 'pair' +const INDEX_JS = 'export const x = 1\n' +const XTERM_CSS = 'body{margin:0}' +const SECRET = 'TOP SECRET — outside root' + +let base: string // temp parent dir (holds the secret sibling) +let root: string // the static root passed to serveStatic + +beforeAll(() => { + base = mkdtempSync(join(tmpdir(), 'relay-static-')) + root = join(base, 'public') + mkdirSync(join(root, 'build'), { recursive: true }) + writeFileSync(join(root, 'index.html'), INDEX_HTML) + writeFileSync(join(root, 'pair.html'), PAIR_HTML) + writeFileSync(join(root, 'build', 'index.js'), INDEX_JS) + writeFileSync(join(root, 'build', 'xterm.css'), XTERM_CSS) + // A real file OUTSIDE root — the traversal target the guard must never reach. + writeFileSync(join(base, 'secret.txt'), SECRET) +}) + +afterAll(() => { + rmSync(base, { recursive: true, force: true }) +}) + +describe('serveStatic — index resolution', () => { + it('serves index.html at "/"', () => { + const res = serveStatic(root, '/') + expect(res).not.toBeNull() + expect(res?.status).toBe(200) + expect(res?.headers['content-type']).toBe('text/html; charset=utf-8') + expect(res?.body.toString('utf8')).toBe(INDEX_HTML) + expect(res?.headers['content-length']).toBe(String(Buffer.byteLength(INDEX_HTML))) + }) + + it('serves an explicit .html page', () => { + const res = serveStatic(root, '/pair.html') + expect(res?.status).toBe(200) + expect(res?.headers['content-type']).toBe('text/html; charset=utf-8') + expect(res?.body.toString('utf8')).toBe(PAIR_HTML) + }) + + it('strips the query string before resolving', () => { + const res = serveStatic(root, '/index.html?join=abc123') + expect(res?.status).toBe(200) + expect(res?.body.toString('utf8')).toBe(INDEX_HTML) + }) + + it('strips the hash fragment before resolving', () => { + const res = serveStatic(root, '/#section') + expect(res?.status).toBe(200) + expect(res?.body.toString('utf8')).toBe(INDEX_HTML) + }) +}) + +describe('serveStatic — MIME types', () => { + it('maps /build/*.js to text/javascript', () => { + const res = serveStatic(root, '/build/index.js') + expect(res?.status).toBe(200) + expect(res?.headers['content-type']).toBe('text/javascript; charset=utf-8') + expect(res?.body.toString('utf8')).toBe(INDEX_JS) + }) + + it('maps .css to text/css', () => { + const res = serveStatic(root, '/build/xterm.css') + expect(res?.status).toBe(200) + expect(res?.headers['content-type']).toBe('text/css; charset=utf-8') + }) +}) + +describe('serveStatic — traversal guard (returns null)', () => { + it('blocks a raw ".." escape even though the target exists', () => { + // Sanity: the target file really is readable outside root, so a null result proves the guard. + expect(serveStatic(root, '/../secret.txt')).toBeNull() + }) + + it('blocks a deep ".." escape', () => { + expect(serveStatic(root, '/build/../../secret.txt')).toBeNull() + }) + + it('blocks percent-encoded ".." (%2e%2e)', () => { + expect(serveStatic(root, '/%2e%2e/secret.txt')).toBeNull() + }) + + it('rejects a NUL byte in the path', () => { + expect(serveStatic(root, '/index.html%00.js')).toBeNull() + }) +}) + +describe('serveStatic — misses (returns null)', () => { + it('returns null for a nonexistent file', () => { + expect(serveStatic(root, '/nope.html')).toBeNull() + }) + + it('returns null for a directory (no listing)', () => { + expect(serveStatic(root, '/build')).toBeNull() + }) +}) diff --git a/relay-run/tests/wiring/mtls-verifier.test.ts b/relay-run/tests/wiring/mtls-verifier.test.ts new file mode 100644 index 0000000..61e7cb7 --- /dev/null +++ b/relay-run/tests/wiring/mtls-verifier.test.ts @@ -0,0 +1,158 @@ +/** + * B2 tests — registry-backed MtlsVerifier (INV4/INV14, fail-closed). + * + * Two layers, mirroring relay-auth/test/mtls.test.ts: + * 1. REAL X.509 path: a self-signed root → leaf chain (relay-auth's committed mTLS fixtures) fed to + * `verifyPeer` as DER bytes, exercising the production `defaultParseX509` chain walk + DER→PEM + * conversion end-to-end against a fake host registry. + * 2. Deterministic seam: an injected `ParseCert` isolates the DER→PEM conversion + registry-gating + + * fail-closed mapping without depending on fixture contents. + */ +import { readFileSync } from 'node:fs' +import { X509Certificate } from 'node:crypto' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { describe, it, expect } from 'vitest' +import { defaultParseX509, spiffeIdFor, type ParseCert, type ParsedCert } from 'relay-auth' +import type { HostRegistryPort } from 'relay-auth' +import { createMtlsVerifier, derToPem } from '../../src/wiring/mtls-verifier.js' +import { fakeHostRegistry, makeHostRecord } from '../../src/wiring/memory-stores.js' + +// Reuse the committed real mTLS fixtures (leaf chains to ca-chain; SPIFFE account/acct-A/host/host-1). +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), '../../../relay-auth/test/fixtures/mtls') +const readFixture = (name: string): string => readFileSync(join(FIXTURES, name), 'utf8') + +const leafPem = readFixture('leaf.pem') +const caChainPem = readFixture('ca-chain.pem') +const foreignLeafPem = readFixture('foreign-leaf.pem') + +const leafDer = new Uint8Array(new X509Certificate(leafPem).raw) +const foreignDer = new Uint8Array(new X509Certificate(foreignLeafPem).raw) + +// A time strictly inside the leaf's validity window (fixtures are long-lived; do not hardcode). +const parsedLeaf = defaultParseX509(leafPem, caChainPem) +const IN_WINDOW = parsedLeaf.notBefore + 60 + +const enrolledHosts = (): HostRegistryPort => + fakeHostRegistry([makeHostRecord('acct-A', 'host-1', 'sub-host-1')]) + +describe('derToPem (DER → PEM)', () => { + it('wraps DER as a 64-column PEM CERTIFICATE block that round-trips back to the same DER', () => { + const pem = derToPem(leafDer) + expect(pem.startsWith('-----BEGIN CERTIFICATE-----\n')).toBe(true) + expect(pem.trimEnd().endsWith('-----END CERTIFICATE-----')).toBe(true) + // No base64 body line exceeds the PEM 64-column width. + const body = pem.split('\n').slice(1, -2) + for (const line of body) expect(line.length).toBeLessThanOrEqual(64) + // Parsing the produced PEM yields byte-identical DER. + expect(new Uint8Array(new X509Certificate(pem).raw)).toEqual(leafDer) + }) +}) + +describe('createMtlsVerifier — real X.509 path (INV14)', () => { + it('accepts an enrolled, in-date leaf chaining to the pinned CA → {hostId, accountId}', async () => { + const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW }) + expect(await v.verifyPeer(leafDer)).toEqual({ hostId: 'host-1', accountId: 'acct-A' }) + }) + + it('refuses a leaf signed by a foreign CA not in the pinned bundle → null', async () => { + const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW }) + expect(await v.verifyPeer(foreignDer)).toBeNull() + }) + + it('refuses a valid leaf whose host is NOT in the registry (INV4) → null', async () => { + const v = createMtlsVerifier({ caChainPem, hosts: fakeHostRegistry([]), now: () => IN_WINDOW }) + expect(await v.verifyPeer(leafDer)).toBeNull() + }) + + it('refuses when the registry account ≠ the cert SPIFFE account → null', async () => { + const hosts = fakeHostRegistry([makeHostRecord('acct-B', 'host-1', 'sub-host-1')]) + const v = createMtlsVerifier({ caChainPem, hosts, now: () => IN_WINDOW }) + expect(await v.verifyPeer(leafDer)).toBeNull() + }) + + it('refuses a revoked host (INV12/registry gate) → null', async () => { + const hosts = fakeHostRegistry([makeHostRecord('acct-A', 'host-1', 'sub-host-1', 'revoked')]) + const v = createMtlsVerifier({ caChainPem, hosts, now: () => IN_WINDOW }) + expect(await v.verifyPeer(leafDer)).toBeNull() + }) + + it('refuses an expired leaf → null', async () => { + const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => parsedLeaf.notAfter + 100 }) + expect(await v.verifyPeer(leafDer)).toBeNull() + }) + + it('refuses a not-yet-valid leaf → null', async () => { + const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => parsedLeaf.notBefore - 100 }) + expect(await v.verifyPeer(leafDer)).toBeNull() + }) +}) + +describe('createMtlsVerifier — fail-closed on malformed / absent input', () => { + it('returns null for an empty DER (no client cert presented)', async () => { + const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW }) + expect(await v.verifyPeer(new Uint8Array(0))).toBeNull() + }) + + it('returns null for garbage DER bytes that are not a certificate', async () => { + const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW }) + expect(await v.verifyPeer(new Uint8Array([1, 2, 3, 4, 5]))).toBeNull() + }) +}) + +describe('createMtlsVerifier — construction validation (INV14)', () => { + it('throws when the CA bundle is empty', () => { + expect(() => createMtlsVerifier({ caChainPem: '', hosts: enrolledHosts(), now: () => IN_WINDOW })).toThrow() + }) + + it('throws when the CA bundle has no PEM CERTIFICATE block', () => { + expect(() => + createMtlsVerifier({ caChainPem: 'not a certificate', hosts: enrolledHosts(), now: () => IN_WINDOW }), + ).toThrow() + }) +}) + +// ── Deterministic seam: isolate DER→PEM + registry gating from fixture contents ────────────────── +const okParsed = (spiffeUri: string): ParsedCert => ({ + spiffeUri, + notBefore: 0, + notAfter: 4_000_000_000, + chainValid: true, +}) + +describe('createMtlsVerifier — ParseCert seam', () => { + it('feeds verifyAgentCert the exact PEM produced by derToPem from the input DER', async () => { + let seenLeafPem: string | null = null + const capturingParse: ParseCert = (leaf) => { + seenLeafPem = leaf + return okParsed(spiffeIdFor('acct-A', 'host-1', 'example.com')) + } + const der = new Uint8Array([9, 8, 7, 6, 5, 4, 3, 2, 1]) + const v = createMtlsVerifier({ + caChainPem, + hosts: enrolledHosts(), + now: () => 1_000_000, + parse: capturingParse, + }) + expect(await v.verifyPeer(der)).toEqual({ hostId: 'host-1', accountId: 'acct-A' }) + expect(seenLeafPem).toBe(derToPem(der)) + }) + + it('fails closed (null) and reports to onError when the registry lookup throws', async () => { + const errors: unknown[] = [] + const throwingHosts: HostRegistryPort = { + getById: async () => { + throw new Error('registry unavailable') + }, + } + const v = createMtlsVerifier({ + caChainPem, + hosts: throwingHosts, + now: () => 1_000_000, + onError: (e) => errors.push(e), + parse: () => okParsed(spiffeIdFor('acct-A', 'host-1', 'example.com')), + }) + expect(await v.verifyPeer(new Uint8Array([1, 2, 3]))).toBeNull() + expect(errors).toHaveLength(1) + }) +}) diff --git a/relay-run/tests/wiring/revocation-subscriber.test.ts b/relay-run/tests/wiring/revocation-subscriber.test.ts new file mode 100644 index 0000000..3e8bf4b --- /dev/null +++ b/relay-run/tests/wiring/revocation-subscriber.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi } from 'vitest' +import { randomUUID } from 'node:crypto' +import { RELAY_REVOCATIONS_CHANNEL, type KillSignal } from 'relay-contracts' +import { + startRevocationSubscriber, + type ActiveTunnelRef, + type RedisSubscriber, +} from '../../src/wiring/revocation-subscriber.js' + +/** Fake ioredis subscriber-mode client: records subscribe/unsubscribe and lets a test emit frames. */ +class FakeRedisSubscriber implements RedisSubscriber { + readonly subscribed: string[] = [] + readonly unsubscribed: string[] = [] + private readonly handlers = new Set<(channel: string, message: string) => void>() + + async subscribe(channel: string): Promise { + this.subscribed.push(channel) + return this.subscribed.length + } + async unsubscribe(channel: string): Promise { + this.unsubscribed.push(channel) + return this.unsubscribed.length + } + on(_event: 'message', listener: (channel: string, message: string) => void): this { + this.handlers.add(listener) + return this + } + off(_event: 'message', listener: (channel: string, message: string) => void): this { + this.handlers.delete(listener) + return this + } + /** Test driver: deliver a raw pub/sub frame to every registered listener. */ + emit(channel: string, message: string): void { + for (const h of [...this.handlers]) h(channel, message) + } + get listenerCount(): number { + return this.handlers.size + } +} + +/** Fake relay node: a live-tunnel map + a closeStream that records + removes the host (models the + * real whole-host teardown mutating the tunnel set, so snapshot-safety is exercised). */ +function makeNode(initial: readonly ActiveTunnelRef[]) { + const tunnels = new Map(initial.map((t) => [t.hostId, t])) + const closed: string[] = [] + return { + closed, + // Live iterator on purpose: the subscriber must snapshot before tearing down. + activeTunnels: () => tunnels.values(), + closeStream: (hostId: string): void => { + closed.push(hostId) + tunnels.delete(hostId) + }, + } +} + +const AT = 1_700_000_000 + +function killMessage(signal: KillSignal): string { + return JSON.stringify(signal) +} + +describe('startRevocationSubscriber', () => { + it('subscribes to the relay:revocations channel on start', () => { + const redisSubscriber = new FakeRedisSubscriber() + startRevocationSubscriber({ redisSubscriber, node: makeNode([]) }) + + expect(redisSubscriber.subscribed).toEqual([RELAY_REVOCATIONS_CHANNEL]) + expect(redisSubscriber.listenerCount).toBe(1) + }) + + it('tears down only the host a host-scoped signal names', () => { + const hostA = randomUUID() + const hostB = randomUUID() + const redisSubscriber = new FakeRedisSubscriber() + const node = makeNode([ + { hostId: hostA, accountId: randomUUID() }, + { hostId: hostB, accountId: randomUUID() }, + ]) + const onApplied = vi.fn() + startRevocationSubscriber({ redisSubscriber, node, onApplied }) + + redisSubscriber.emit( + RELAY_REVOCATIONS_CHANNEL, + killMessage({ scope: { kind: 'host', hostId: hostA }, at: AT, reason: 'compromised' }), + ) + + expect(node.closed).toEqual([hostA]) + expect(onApplied).toHaveBeenCalledTimes(1) + expect(onApplied).toHaveBeenCalledWith(expect.objectContaining({ at: AT }), 1) + }) + + it('tears down every host under an account-scoped signal, leaving other accounts running', () => { + const acct1 = randomUUID() + const acct2 = randomUUID() + const hostA = randomUUID() + const hostB = randomUUID() + const hostC = randomUUID() + const redisSubscriber = new FakeRedisSubscriber() + const node = makeNode([ + { hostId: hostA, accountId: acct1 }, + { hostId: hostB, accountId: acct1 }, + { hostId: hostC, accountId: acct2 }, + ]) + startRevocationSubscriber({ redisSubscriber, node }) + + redisSubscriber.emit( + RELAY_REVOCATIONS_CHANNEL, + killMessage({ scope: { kind: 'account', accountId: acct1 }, at: AT, reason: 'billing' }), + ) + + expect(node.closed.sort()).toEqual([hostA, hostB].sort()) + expect(node.closed).not.toContain(hostC) + }) + + it('tears down every live host on a global-scoped signal', () => { + const hosts = [ + { hostId: randomUUID(), accountId: randomUUID() }, + { hostId: randomUUID(), accountId: randomUUID() }, + { hostId: randomUUID(), accountId: randomUUID() }, + ] + const redisSubscriber = new FakeRedisSubscriber() + const node = makeNode(hosts) + startRevocationSubscriber({ redisSubscriber, node }) + + redisSubscriber.emit( + RELAY_REVOCATIONS_CHANNEL, + killMessage({ scope: { kind: 'global' }, at: AT, reason: 'kill-switch' }), + ) + + expect(node.closed.sort()).toEqual(hosts.map((h) => h.hostId).sort()) + }) + + it('is a no-op for a host-scoped signal naming a host this node does not serve', () => { + const served = randomUUID() + const redisSubscriber = new FakeRedisSubscriber() + const node = makeNode([{ hostId: served, accountId: randomUUID() }]) + const onApplied = vi.fn() + startRevocationSubscriber({ redisSubscriber, node, onApplied }) + + redisSubscriber.emit( + RELAY_REVOCATIONS_CHANNEL, + killMessage({ scope: { kind: 'host', hostId: randomUUID() }, at: AT, reason: 'other-node' }), + ) + + expect(node.closed).toEqual([]) + expect(onApplied).toHaveBeenCalledWith(expect.anything(), 0) + }) + + it('drops a malformed (non-JSON) message: no teardown, counted as dropped', () => { + const redisSubscriber = new FakeRedisSubscriber() + const node = makeNode([{ hostId: randomUUID(), accountId: randomUUID() }]) + const onDropped = vi.fn() + const onApplied = vi.fn() + startRevocationSubscriber({ redisSubscriber, node, onDropped, onApplied }) + + redisSubscriber.emit(RELAY_REVOCATIONS_CHANNEL, '{ this is not json') + + expect(node.closed).toEqual([]) + expect(onDropped).toHaveBeenCalledTimes(1) + expect(onApplied).not.toHaveBeenCalled() + }) + + it('drops a schema-invalid signal (non-uuid host / missing fields): no teardown', () => { + const redisSubscriber = new FakeRedisSubscriber() + const node = makeNode([{ hostId: randomUUID(), accountId: randomUUID() }]) + const onDropped = vi.fn() + startRevocationSubscriber({ redisSubscriber, node, onDropped }) + + // hostId is not a UUID → RevocationScopeSchema rejects. + redisSubscriber.emit( + RELAY_REVOCATIONS_CHANNEL, + JSON.stringify({ scope: { kind: 'host', hostId: 'not-a-uuid' }, at: AT, reason: 'x' }), + ) + // Missing `at` → KillSignalSchema rejects. + redisSubscriber.emit( + RELAY_REVOCATIONS_CHANNEL, + JSON.stringify({ scope: { kind: 'global' }, reason: 'x' }), + ) + + expect(node.closed).toEqual([]) + expect(onDropped).toHaveBeenCalledTimes(2) + }) + + it('ignores messages published on a different channel', () => { + const hostA = randomUUID() + const redisSubscriber = new FakeRedisSubscriber() + const node = makeNode([{ hostId: hostA, accountId: randomUUID() }]) + const onDropped = vi.fn() + const onApplied = vi.fn() + startRevocationSubscriber({ redisSubscriber, node, onDropped, onApplied }) + + redisSubscriber.emit( + 'some:other:channel', + killMessage({ scope: { kind: 'host', hostId: hostA }, at: AT, reason: 'wrong-channel' }), + ) + + expect(node.closed).toEqual([]) + expect(onDropped).not.toHaveBeenCalled() + expect(onApplied).not.toHaveBeenCalled() + }) + + it('close() removes the message listener, unsubscribes, and is idempotent', () => { + const hostA = randomUUID() + const redisSubscriber = new FakeRedisSubscriber() + const node = makeNode([{ hostId: hostA, accountId: randomUUID() }]) + const sub = startRevocationSubscriber({ redisSubscriber, node }) + + sub.close() + sub.close() // idempotent — no throw, no double-unsubscribe + + expect(redisSubscriber.unsubscribed).toEqual([RELAY_REVOCATIONS_CHANNEL]) + expect(redisSubscriber.listenerCount).toBe(0) + + // A frame delivered after close must not tear anything down. + redisSubscriber.emit( + RELAY_REVOCATIONS_CHANNEL, + killMessage({ scope: { kind: 'host', hostId: hostA }, at: AT, reason: 'after-close' }), + ) + expect(node.closed).toEqual([]) + }) + + it('routes a rejected subscribe() to onError instead of swallowing it', async () => { + const failing: RedisSubscriber = { + subscribe: () => Promise.reject(new Error('redis down')), + unsubscribe: async () => 0, + on: () => failing, + off: () => failing, + } + const onError = vi.fn() + startRevocationSubscriber({ redisSubscriber: failing, node: makeNode([]), onError }) + + await Promise.resolve() // let the rejected subscribe() microtask settle + expect(onError).toHaveBeenCalledTimes(1) + expect(onError.mock.calls[0][0]).toBeInstanceOf(Error) + }) +}) diff --git a/relay-run/tests/wiring/route-resolver.test.ts b/relay-run/tests/wiring/route-resolver.test.ts new file mode 100644 index 0000000..06f01e2 --- /dev/null +++ b/relay-run/tests/wiring/route-resolver.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi } from 'vitest' +import { randomUUID } from 'node:crypto' +import type { HostRecord, HostStatus } from 'control-plane/src/model/records.js' +import { + createStoreRouteResolver, + type HostSubdomainLookup, +} from '../../src/wiring/route-resolver.js' + +const NOW_ISO = '2026-07-06T00:00:00.000Z' + +/** Build a full §4.2 HostRecord fixture keyed by its subdomain label. */ +function makeHost(subdomain: string, status: HostStatus = 'online'): HostRecord { + return { + hostId: randomUUID(), + accountId: randomUUID(), + subdomain, + agentPubkey: new Uint8Array([1, 2, 3]), + enrollFpr: 'fpr:' + subdomain, + status, + lastSeen: NOW_ISO, + createdAt: NOW_ISO, + revokedAt: status === 'revoked' ? NOW_ISO : null, + } +} + +/** A minimal HostStore lookup fake: exact-match subdomain → record, else null (matches pg.ts). */ +function fakeHosts(records: readonly HostRecord[]): HostSubdomainLookup { + const bySub = new Map(records.map((r) => [r.subdomain, r])) + return { + getBySubdomain: vi.fn(async (subdomain: string) => bySub.get(subdomain) ?? null), + } +} + +describe('createStoreRouteResolver', () => { + it('resolves a known online host to {hostId, accountId, subdomain}', async () => { + // Arrange + const host = makeHost('alice') + const resolver = createStoreRouteResolver({ hosts: fakeHosts([host]) }) + + // Act + const resolved = await resolver.resolveSubdomain('alice') + + // Assert + expect(resolved).toEqual({ + hostId: host.hostId, + accountId: host.accountId, + subdomain: 'alice', + }) + }) + + it('returns null (fail-closed) for an unknown subdomain', async () => { + // Arrange + const resolver = createStoreRouteResolver({ hosts: fakeHosts([makeHost('alice')]) }) + + // Act + const resolved = await resolver.resolveSubdomain('nobody') + + // Assert + expect(resolved).toBeNull() + }) + + it('fails closed (returns null) for a revoked host even though the row still exists', async () => { + // Arrange + const resolver = createStoreRouteResolver({ hosts: fakeHosts([makeHost('alice', 'revoked')]) }) + + // Act + const resolved = await resolver.resolveSubdomain('alice') + + // Assert + expect(resolved).toBeNull() + }) + + it('resolves offline and draining hosts (only revoked fails closed)', async () => { + // Arrange + const offline = makeHost('bob', 'offline') + const draining = makeHost('carol', 'draining') + const resolver = createStoreRouteResolver({ hosts: fakeHosts([offline, draining]) }) + + // Act + Assert + expect(await resolver.resolveSubdomain('bob')).toEqual({ + hostId: offline.hostId, + accountId: offline.accountId, + subdomain: 'bob', + }) + expect(await resolver.resolveSubdomain('carol')).toEqual({ + hostId: draining.hostId, + accountId: draining.accountId, + subdomain: 'carol', + }) + }) + + it('derives identity only from the store record, not the caller (INV3)', async () => { + // Arrange: the stored record carries the authoritative accountId/hostId. + const host = makeHost('alice') + const resolver = createStoreRouteResolver({ hosts: fakeHosts([host]) }) + + // Act + const resolved = await resolver.resolveSubdomain('alice') + + // Assert: values come from the record, never fabricated from the input label. + expect(resolved?.accountId).toBe(host.accountId) + expect(resolved?.hostId).toBe(host.hostId) + }) + + it('passes the exact subdomain label through to getBySubdomain', async () => { + // Arrange + const hosts = fakeHosts([makeHost('alice')]) + const resolver = createStoreRouteResolver({ hosts }) + + // Act + await resolver.resolveSubdomain('alice') + + // Assert + expect(hosts.getBySubdomain).toHaveBeenCalledWith('alice') + expect(hosts.getBySubdomain).toHaveBeenCalledTimes(1) + }) + + it('propagates store errors (no silent swallow)', async () => { + // Arrange: a store that throws (e.g. DB unavailable) must NOT be masked as a null resolve. + const boom = new Error('db down') + const resolver = createStoreRouteResolver({ + hosts: { getBySubdomain: vi.fn(async () => { throw boom }) }, + }) + + // Act + Assert + await expect(resolver.resolveSubdomain('alice')).rejects.toThrow('db down') + }) +}) diff --git a/relay-run/tests/wiring/stores-pg.test.ts b/relay-run/tests/wiring/stores-pg.test.ts new file mode 100644 index 0000000..cb02a7f --- /dev/null +++ b/relay-run/tests/wiring/stores-pg.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect } from 'vitest' +import type { AuditEvent } from 'relay-auth' +import { NO_STEPUP_POLICY } from 'relay-auth/src/human/stepup/stepup.js' +import type { QueryFn } from 'control-plane/src/db/pool.js' +import { createRelayEnforceDeps, type RedisLike } from '../../src/wiring/stores-pg.js' + +// ── Fakes ───────────────────────────────────────────────────────────────────────────────────── + +interface QueryCall { + readonly sql: string + readonly params: readonly unknown[] +} + +/** A `QueryFn` that records every call and returns rows chosen by `rowsFor`. */ +function makeFakeQuery( + rowsFor: (sql: string, params: readonly unknown[]) => unknown[], +): { query: QueryFn; calls: QueryCall[] } { + const calls: QueryCall[] = [] + const query: QueryFn = async (sql: string, params: readonly unknown[]): Promise => { + calls.push({ sql, params }) + return rowsFor(sql, params) as T[] + } + return { query, calls } +} + +interface RedisCall { + readonly method: string + readonly args: readonly unknown[] +} + +/** A `RedisLike` mock whose methods can be overridden per test; every call is recorded. */ +function makeMockRedis(over: Partial = {}): RedisLike & { calls: RedisCall[] } { + const calls: RedisCall[] = [] + const record = (method: string, args: unknown[]): void => void calls.push({ method, args }) + const redis: RedisLike = { + exists: over.exists ?? (async (key) => (record('exists', [key]), 0)), + set: over.set ?? (async (key, value, mode) => (record('set', [key, value, mode]), 'OK')), + expireat: over.expireat ?? (async (key, ts) => (record('expireat', [key, ts]), 1)), + eval: over.eval ?? (async (...args) => (record('eval', args), 1)), + } + return Object.assign(redis, { calls }) +} + +const NOOP_QUERY: QueryFn = async () => [] + +// ── hosts (Postgres pass-through) ─────────────────────────────────────────────────────────────── + +describe('createRelayEnforceDeps.hosts', () => { + it('maps a CP host row to a relay-auth HostRecord', async () => { + const { query, calls } = makeFakeQuery(() => [ + { + host_id: 'host-1', + account_id: 'acct-1', + subdomain: 'demo', + agent_pubkey: Buffer.from([1, 2, 3]), + enroll_fpr: 'fpr-1', + status: 'online', + last_seen: '2026-07-06T00:00:00.000Z', + created_at: '2026-07-05T00:00:00.000Z', + revoked_at: null, + }, + ]) + const deps = createRelayEnforceDeps({ query, redis: makeMockRedis() }) + + const host = await deps.hosts.getById('host-1') + + expect(host).not.toBeNull() + expect(host?.hostId).toBe('host-1') + expect(host?.accountId).toBe('acct-1') + expect(host?.subdomain).toBe('demo') + expect(host?.status).toBe('online') + expect(host?.revokedAt).toBeNull() + expect(Array.from(host?.agentPubkey ?? [])).toEqual([1, 2, 3]) + // accountId came from the stored row, never fabricated (INV3). + expect(calls[0]?.params).toEqual(['host-1']) + }) + + it('returns null for an unknown host', async () => { + const { query } = makeFakeQuery(() => []) + const deps = createRelayEnforceDeps({ query, redis: makeMockRedis() }) + expect(await deps.hosts.getById('nope')).toBeNull() + }) +}) + +// ── sessions (Postgres, remapped to the port shape) ────────────────────────────────────────────── + +describe('createRelayEnforceDeps.sessions', () => { + it('maps a session row to { hostId, accountId }', async () => { + const { query } = makeFakeQuery(() => [ + { + session_id: 'sess-1', + host_id: 'host-1', + account_id: 'acct-1', + created_at: '2026-07-06T00:00:00.000Z', + last_attach_at: '2026-07-06T00:00:00.000Z', + }, + ]) + const deps = createRelayEnforceDeps({ query, redis: makeMockRedis() }) + + expect(await deps.sessions.getById('sess-1')).toEqual({ hostId: 'host-1', accountId: 'acct-1' }) + }) + + it('returns null for an unknown session', async () => { + const { query } = makeFakeQuery(() => []) + const deps = createRelayEnforceDeps({ query, redis: makeMockRedis() }) + expect(await deps.sessions.getById('nope')).toBeNull() + }) +}) + +// ── revocation (Redis) ─────────────────────────────────────────────────────────────────────────── + +describe('createRelayEnforceDeps.revocation', () => { + it('isRevoked is true iff the revoked: key exists', async () => { + const present = createRelayEnforceDeps({ + query: NOOP_QUERY, + redis: makeMockRedis({ exists: async () => 1 }), + }) + const absent = createRelayEnforceDeps({ + query: NOOP_QUERY, + redis: makeMockRedis({ exists: async () => 0 }), + }) + expect(await present.revocation.isRevoked('j1')).toBe(true) + expect(await absent.revocation.isRevoked('j1')).toBe(false) + }) + + it('revokeJti sets revoked: and pins its expiry to exp', async () => { + const redis = makeMockRedis() + const deps = createRelayEnforceDeps({ query: NOOP_QUERY, redis }) + + await deps.revocation.revokeJti('j1', 1_800_000_000) + + expect(redis.calls).toEqual([ + { method: 'set', args: ['revoked:j1', '1', undefined] }, + { method: 'expireat', args: ['revoked:j1', 1_800_000_000] }, + ]) + }) + + it('consumeOnce burns the jti: first use wins (SET NX), replays lose', async () => { + let existing = false + const redis = makeMockRedis({ + set: async (_k, _v, mode) => { + if (mode !== 'NX') return 'OK' + if (existing) return null + existing = true + return 'OK' + }, + }) + const deps = createRelayEnforceDeps({ query: NOOP_QUERY, redis }) + + expect(await deps.revocation.consumeOnce('j1', 1_800_000_000)).toBe(true) + expect(await deps.revocation.consumeOnce('j1', 1_800_000_000)).toBe(false) + // expiry is only pinned on the winning first use. + expect(redis.calls.filter((c) => c.method === 'expireat')).toEqual([ + { method: 'expireat', args: ['used:j1', 1_800_000_000] }, + ]) + }) +}) + +// ── buckets (Redis token bucket) ───────────────────────────────────────────────────────────────── + +describe('createRelayEnforceDeps.buckets', () => { + it('namespaces the key and forwards refill/burst/now + a computed ttl to the Lua script', async () => { + let evalArgs: readonly unknown[] = [] + const redis = makeMockRedis({ + eval: async (...args) => { + evalArgs = args + return 1 + }, + }) + const deps = createRelayEnforceDeps({ query: NOOP_QUERY, redis }) + + const ok = await deps.buckets.take('connect:acct:a1', 1, 60, 1000) + + expect(ok).toBe(true) + // eval(script, numKeys, key, refillPerSec, burst, now, ttl) + expect(evalArgs[1]).toBe(1) // numKeys + expect(evalArgs[2]).toBe('bucket:connect:acct:a1') + expect(evalArgs[3]).toBe(1) // refillPerSec + expect(evalArgs[4]).toBe(60) // burst + expect(evalArgs[5]).toBe(1000) // now + expect(evalArgs[6]).toBe(61) // ttl = ceil(60/1) + 1 + }) + + it('maps a 0 result to throttled (false)', async () => { + const deps = createRelayEnforceDeps({ + query: NOOP_QUERY, + redis: makeMockRedis({ eval: async () => 0 }), + }) + expect(await deps.buckets.take('preauth:ip:x', 1, 60, 1000)).toBe(false) + }) +}) + +// ── audit (Postgres audit_log, metadata only) ──────────────────────────────────────────────────── + +describe('createRelayEnforceDeps.audit', () => { + it('maps an AuditEvent onto the audit_log row, folding non-column fields into meta (INV10)', async () => { + const { query, calls } = makeFakeQuery(() => []) + const deps = createRelayEnforceDeps({ query, redis: makeMockRedis() }) + const event: AuditEvent = { + ts: '2026-07-06T00:00:00.000Z', + action: 'attach', + principalId: 'cred-1', + accountId: 'acct-1', + hostId: 'host-1', + sessionId: 'sess-1', + jti: 'jti-1', + outcome: 'allow', + reason: 'ok', + remoteAddrHash: 'iphash', + } + + await deps.audit.append(event) + + const params = calls[0]?.params ?? [] + expect(params[0]).toBe('attach') // action + expect(params[1]).toBe('cred-1') // principal_id + expect(params[2]).toBe('acct-1') // account_id + expect(params[3]).toBe('host-1') // host_id + expect(params[4]).toBe('2026-07-06T00:00:00.000Z') // ts + expect(JSON.parse(params[5] as string)).toEqual({ + outcome: 'allow', + reason: 'ok', + remoteAddrHash: 'iphash', + sessionId: 'sess-1', + jti: 'jti-1', + }) + }) + + it('omits null sessionId/jti from meta', async () => { + const { query, calls } = makeFakeQuery(() => []) + const deps = createRelayEnforceDeps({ query, redis: makeMockRedis() }) + const event: AuditEvent = { + ts: '2026-07-06T00:00:00.000Z', + action: 'attach', + principalId: '', + accountId: '', + hostId: null, + sessionId: null, + jti: null, + outcome: 'deny', + reason: 'bad_origin', + remoteAddrHash: 'iphash', + } + + await deps.audit.append(event) + + expect(calls[0]?.params[3]).toBeNull() // host_id + expect(JSON.parse((calls[0]?.params[5] as string) ?? '{}')).toEqual({ + outcome: 'deny', + reason: 'bad_origin', + remoteAddrHash: 'iphash', + }) + }) +}) + +// ── stepUpPolicyFor (staging) ──────────────────────────────────────────────────────────────────── + +describe('createRelayEnforceDeps.stepUpPolicyFor', () => { + it('returns NO_STEPUP_POLICY (staging single-operator)', () => { + const deps = createRelayEnforceDeps({ query: NOOP_QUERY, redis: makeMockRedis() }) + // host argument is irrelevant in staging; the policy is never-required. + expect(deps.stepUpPolicyFor({} as never)).toBe(NO_STEPUP_POLICY) + expect(deps.stepUpPolicyFor({} as never).required).toBe(false) + }) +}) diff --git a/relay-web/src/dpop.ts b/relay-web/src/dpop.ts new file mode 100644 index 0000000..1c12fd8 --- /dev/null +++ b/relay-web/src/dpop.ts @@ -0,0 +1,136 @@ +/** + * B6 (Phase-1 STAGING) — browser-side DPoP proof-of-possession for the §4.3 capability-token WS + * upgrade. + * + * The operator's browser mints an EPHEMERAL Ed25519 keypair (WebCrypto), computes its RFC 7638/8037 + * JWK thumbprint `jkt`, and binds the minted capability token to it (`cnf.jkt`) at `POST /auth/mint`. + * On connect it signs a DPoP proof over (htu, htm, jti, iat) with the SAME private key; the relay + * recomputes the thumbprint from the proof's embedded JWK and requires it to equal the token's + * `cnf.jkt` (relay-auth `verifyDpopProof`). The private key NEVER leaves the page and is NEVER + * logged (INV9); it is generated per successful login and thrown away when the tab closes. + * + * Wire format is byte-for-byte identical to relay-auth's `buildDpopProof` / `jwkThumbprint` + * (cross-validated against relay-auth `verifyDpopProof`): header + * `{typ:'dpop+ed25519', jwk:{crv:'Ed25519', kty:'OKP', x}}`, payload `{htu, htm, jti, iat}`, + * proof = `b64u(header).b64u(payload).b64u(Ed25519sig("h.p"))`; jkt = `b64u(SHA-256(canonical JWK))` + * with members in the REQUIRED lexicographic order `crv,kty,x` and no whitespace. base64url comes + * from relay-contracts (the shared, isomorphic helper — never hand-rolled here). + * + * DELIVERY (Phase-1 gap): the browser's native WebSocket API cannot set request headers, but the + * relay reads the DPoP proof from the `dpop` request header (relay-run browser-server.ts). Mirroring + * the §4.3 token (which rides `Sec-WebSocket-Protocol` for exactly this reason), the proof is offered + * as an ADDITIONAL subprotocol entry (`term.dpop.`) via {@link encodeDpopSubprotocol}. + * The current relay ignores unknown subprotocol entries (its `handleProtocols` echoes only + * `APP_SUBPROTOCOL`), so this is handshake-safe today; a real browser connect is nonetheless denied + * at DPoP until the relay is taught to read the proof from this entry (a relay/server-lane change, + * validated on the VPS). See the returned notes in the B6 log entry. + */ +import { encodeBase64UrlBytes, encodeBase64UrlString, decodeBase64UrlString } from 'relay-contracts' +import { RelayWebError } from './errors' + +/** DPoP HTTP method bound into every proof — a WS upgrade is a GET (mirrors relay-run `DPOP_HTM`). */ +export const DPOP_HTM = 'GET' as const + +/** Subprotocol entry prefix carrying the DPoP proof on the upgrade (browsers can't set headers). */ +export const DPOP_SUBPROTOCOL_PREFIX = 'term.dpop.' as const + +/** + * Canonical DPoP `htu` for an audience — mirrors relay-run `htuFor` (the SINGLE definition both + * sides use so issuance and verification never drift). `aud` is the tenant subdomain the token is + * bound to; the relay re-derives the identical string from its own resolved authority. + */ +export function htuFor(aud: string): string { + return `https://${aud}/ws` +} + +/** A base64url SHA-256 JWK thumbprint is exactly 43 chars — matches the server's `JKT_RE`. */ +const JKT_LENGTH = 43 + +export interface DpopKey { + /** RFC 7638 JWK thumbprint (base64url SHA-256, 43 chars) — the `cnf.jkt` binding sent to /auth/mint. */ + readonly jkt: string + /** Sign a FRESH DPoP proof JWS for one upgrade (unique jti; `iat = nowSec`). Never logs the key. */ + proof(htu: string, htm: string, nowSec: number): Promise +} + +/** DI seams so tests run deterministically (inject Node webcrypto under jsdom + a fixed jti). */ +export interface DpopDeps { + /** SubtleCrypto to use; defaults to the page's `globalThis.crypto.subtle`. */ + readonly subtle?: SubtleCrypto + /** DPoP `jti` generator; defaults to `crypto.randomUUID()`. */ + readonly randomJti?: () => string +} + +const ED25519 = { name: 'Ed25519' } as const + +function defaultJti(): string { + const c = globalThis.crypto + if (!c || typeof c.randomUUID !== 'function') { + throw new RelayWebError('crypto.randomUUID is unavailable — cannot mint a DPoP jti') + } + return c.randomUUID() +} + +/** Canonical Ed25519 public JWK JSON (member order crv, kty, x) → base64url(SHA-256) thumbprint. */ +async function computeJkt(subtle: SubtleCrypto, x: string): Promise { + const json = JSON.stringify({ crv: 'Ed25519', kty: 'OKP', x }) + const digest = new Uint8Array(await subtle.digest('SHA-256', new TextEncoder().encode(json))) + return encodeBase64UrlBytes(digest) +} + +/** + * Generate an ephemeral browser DPoP key and expose its `jkt` + a per-upgrade proof signer. + * Fails fast (typed {@link RelayWebError}) when WebCrypto/Ed25519 is unavailable. + */ +export async function createDpopKey(deps: DpopDeps = {}): Promise { + const subtle = deps.subtle ?? globalThis.crypto?.subtle + if (!subtle) { + throw new RelayWebError('WebCrypto SubtleCrypto is unavailable — DPoP requires Ed25519') + } + const randomJti = deps.randomJti ?? defaultJti + + let pair: CryptoKeyPair + let rawPub: Uint8Array + try { + pair = (await subtle.generateKey(ED25519, true, ['sign', 'verify'])) as CryptoKeyPair + rawPub = new Uint8Array(await subtle.exportKey('raw', pair.publicKey)) + } catch { + // Never surface key material in the error (INV9). + throw new RelayWebError('failed to generate an Ed25519 DPoP key (unsupported by this browser?)') + } + + const x = encodeBase64UrlBytes(rawPub) + const jkt = await computeJkt(subtle, x) + if (jkt.length !== JKT_LENGTH) { + throw new RelayWebError(`unexpected DPoP thumbprint length ${jkt.length} (expected ${JKT_LENGTH})`) + } + + const proof = async (htu: string, htm: string, nowSec: number): Promise => { + const header = { typ: 'dpop+ed25519', jwk: { crv: 'Ed25519', kty: 'OKP', x } } + const payload = { htu, htm, jti: randomJti(), iat: nowSec } + const h = encodeBase64UrlString(JSON.stringify(header)) + const p = encodeBase64UrlString(JSON.stringify(payload)) + const sig = new Uint8Array( + await subtle.sign(ED25519, pair.privateKey, new TextEncoder().encode(`${h}.${p}`)), + ) + return `${h}.${p}.${encodeBase64UrlBytes(sig)}` + } + + return { jkt, proof } +} + +/** Build the DPoP subprotocol entry: `term.dpop.` + base64url(proofJws). */ +export function encodeDpopSubprotocol(proofJws: string): string { + return DPOP_SUBPROTOCOL_PREFIX + encodeBase64UrlString(proofJws) +} + +/** + * Extract the DPoP proof from a subprotocol list (the future relay-side inverse of + * {@link encodeDpopSubprotocol}). Returns null when absent. Exposed for round-trip tests and to + * document the exact wire contract the relay must read once it consumes the proof from the handshake. + */ +export function extractDpopFromSubprotocols(values: readonly string[]): string | null { + const entry = values.find((v) => v.startsWith(DPOP_SUBPROTOCOL_PREFIX)) + if (entry === undefined) return null + return decodeBase64UrlString(entry.slice(DPOP_SUBPROTOCOL_PREFIX.length)) +} diff --git a/relay-web/src/entry/index-page.ts b/relay-web/src/entry/index-page.ts index 3b1e1e3..cebebe9 100644 --- a/relay-web/src/entry/index-page.ts +++ b/relay-web/src/entry/index-page.ts @@ -1,11 +1,37 @@ /** - * index.html entry — v0.8 password gate → terminal view over the passthrough transport. - * No inline script (strict-CSP friendly): this bundle is loaded via