feat(relay): Phase1 waves A2-E — server entry, shared-store data plane, agent runtime, deploy artifacts
RELAY-PHASE1 Wave A2/B/C/D/E (12-agent workflow, all tsc-clean, 314/314 tests pass): - A2: control-plane server.ts entry + boot/redis.ts revocation-bus wiring + start script. - B1: relay-run shared-store EnforceDeps (relay-auth ports over the SAME Postgres+Redis as P3). - B2: registry-backed MtlsVerifier (verifyAgentCert, fail-closed, INV14). - B3: store-backed RouteResolver (subdomain->hostId). - B4: Redis relay:revocations subscriber -> tunnel teardown (INV12). - B5: main-phase1.ts production entry (public bind, real TLS, async-mTLS prefetch bridge) + staging /auth/mint. - B6 (PARTIAL): relay-web operator login + browser DPoP; proof offered via term.dpop.<b64u> subprotocol. - C: agent dist/cli.js build (esbuild) + runTunnel run-loop + CliDeps. - D1: same-origin static serve of relay-web/public from the browser WSS. - E: systemd units + gen-ca/gen-capability-key/issue-tls-cert scripts + deploy/RUNBOOK.md. Adversarial review: all hard invariants PASS. Follow-ups (B7): close DPoP-subprotocol read on browser-server (blocks browser connect); rate-limit /auth/mint (F1); wire activeSessionCount (F2); scrub error logs (F5). Excludes unrelated public/style.css (concurrent iOS job).
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"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": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
"@types/ws": "^8.5.12",
|
"@types/ws": "^8.5.12",
|
||||||
"@vitest/coverage-v8": "^4.1.9",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
|
"esbuild": "^0.28.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
|
|||||||
83
agent/src/cli/deps.ts
Normal file
83
agent/src/cli/deps.ts
Normal file
@@ -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<void> {
|
||||||
|
return new Promise<void>((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`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
30
agent/src/main.ts
Normal file
30
agent/src/main.ts
Normal file
@@ -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<number> {
|
||||||
|
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
|
||||||
|
})
|
||||||
152
agent/src/transport/runTunnel.ts
Normal file
152
agent/src/transport/runTunnel.ts
Normal file
@@ -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<void>
|
||||||
|
/** Resolves with a process exit code when the loop ends (stopped or host revoked ⇒ 0). */
|
||||||
|
readonly done: Promise<number>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Injectable seams for the supervisor. All optional; unset fields default to real `ws` IO. */
|
||||||
|
export interface RunTunnelDeps {
|
||||||
|
connectRelay(): Promise<WsLike>
|
||||||
|
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<typeof setTimeout>),
|
||||||
|
setInterval: (cb, ms) => setInterval(cb, ms),
|
||||||
|
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||||
|
}
|
||||||
|
const realSleep: Sleep = (ms) => new Promise<void>((r) => setTimeout(r, ms))
|
||||||
|
|
||||||
|
function resolveDeps(cfg: AgentConfig, ks: Keystore, o?: Partial<RunTunnelDeps>): 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<RunTunnelDeps>,
|
||||||
|
): Promise<TunnelHandle> {
|
||||||
|
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<Tunnel> {
|
||||||
|
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<void> {
|
||||||
|
return new Promise<void>((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<number> {
|
||||||
|
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<void> {
|
||||||
|
stopped = true
|
||||||
|
currentTunnel?.close()
|
||||||
|
await done
|
||||||
|
},
|
||||||
|
done,
|
||||||
|
}
|
||||||
|
}
|
||||||
128
agent/test/runTunnel.test.ts
Normal file
128
agent/test/runTunnel.test.ts
Normal file
@@ -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<void> => 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<RunTunnelDeps>): Partial<RunTunnelDeps> {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
525
control-plane/package-lock.json
generated
525
control-plane/package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
|
"relay-auth": "file:../relay-auth",
|
||||||
"relay-contracts": "file:../relay-contracts",
|
"relay-contracts": "file:../relay-contracts",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
@@ -18,6 +19,23 @@
|
|||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"@vitest/coverage-v8": "^4.1.9",
|
"@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",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9"
|
||||||
},
|
},
|
||||||
@@ -133,6 +151,448 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@fastify/ajv-compiler": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
|
||||||
@@ -884,6 +1344,48 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/estree-walker": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
@@ -1827,6 +2329,10 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/relay-auth": {
|
||||||
|
"resolved": "../relay-auth",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/relay-contracts": {
|
"node_modules/relay-contracts": {
|
||||||
"resolved": "../relay-contracts",
|
"resolved": "../relay-contracts",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -2079,6 +2585,25 @@
|
|||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"optional": true
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"main": "src/main.ts",
|
"main": "src/main.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"start": "tsx src/server.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"@vitest/coverage-v8": "^4.1.9",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
|
|||||||
32
control-plane/src/boot/redis.ts
Normal file
32
control-plane/src/boot/redis.ts
Normal file
@@ -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<number>).
|
||||||
|
*/
|
||||||
|
export function createRedisPublisher(redis: Redis): RedisPublisher {
|
||||||
|
return {
|
||||||
|
publish: (channel: string, message: string): Promise<number> => redis.publish(channel, message),
|
||||||
|
}
|
||||||
|
}
|
||||||
99
control-plane/src/server.ts
Normal file
99
control-plane/src/server.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
59
control-plane/test/boot-redis.test.ts
Normal file
59
control-plane/test/boot-redis.test.ts
Normal file
@@ -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<number> => {
|
||||||
|
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 : '' } })
|
||||||
|
})
|
||||||
|
})
|
||||||
277
deploy/RUNBOOK.md
Normal file
277
deploy/RUNBOOK.md
Normal file
@@ -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 `<placeholder>` 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 <REPO_URL> /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
|
||||||
|
`<SUBDOMAIN>.<BASE_DOMAIN>` → `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="<SUBDOMAIN>.<BASE_DOMAIN>" \
|
||||||
|
AGENT_FACING_IP="8.138.1.192" \
|
||||||
|
RELAY_TRUST_DOMAIN="<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="<BASE_DOMAIN>" SUBDOMAIN="<SUBDOMAIN>" ACME_EMAIL="<you@example.com>" \
|
||||||
|
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:<POSTGRES_PASSWORD>@127.0.0.1:5432/relay
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379
|
||||||
|
CAPABILITY_SIGN_PUBKEY_B64=<base64 from step 2b>
|
||||||
|
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=<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=<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=<base64url from step 2b — SAME key as CAPABILITY_SIGN_PUBKEY_B64>
|
||||||
|
RELAY_TRUST_DOMAIN=<RELAY_TRUST_DOMAIN>
|
||||||
|
PG_URL=postgres://relay:<POSTGRES_PASSWORD>@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=<BASE_DOMAIN>`. Send it as `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
|
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=<the new accountId>`, 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 <MANAGE_TOKEN aud=BASE_DOMAIN>" \
|
||||||
|
-H 'content-type: application/json' -d '{"plan":"personal"}'
|
||||||
|
# -> { "id": "<ACCOUNT_ID>", ... }
|
||||||
|
|
||||||
|
# 2) pairing code (token.sub must equal <ACCOUNT_ID>)
|
||||||
|
curl -sS -X POST http://127.0.0.1:8080/accounts/<ACCOUNT_ID>/pairing-codes \
|
||||||
|
-H "Authorization: Bearer <MANAGE_TOKEN sub=ACCOUNT_ID aud=BASE_DOMAIN>"
|
||||||
|
# -> { "code": "<PAIRING_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 <repo>/agent
|
||||||
|
npm ci && npm run build # emits dist/cli.js (C1)
|
||||||
|
export RELAY_URL="wss://<SUBDOMAIN>.<BASE_DOMAIN>:8444" # AGENT_BIND_PORT
|
||||||
|
export ENROLL_URL="https://<SUBDOMAIN>.<BASE_DOMAIN>/enroll"
|
||||||
|
export HOST_ID="<pick-a-host-id>"
|
||||||
|
export SUBDOMAIN="<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 <PAIRING_CODE> # 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://<SUBDOMAIN>.<BASE_DOMAIN>` (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/<HOST_ID> \
|
||||||
|
-H "Authorization: Bearer <MANAGE_TOKEN sub=ACCOUNT_ID aud=BASE_DOMAIN>"
|
||||||
|
# -> 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://<SUBDOMAIN>.<BASE_DOMAIN>` (not the raw IP, not `http:`, no stray port). `allowedOrigins` is the port-less `https://<sub>.<BASE_DOMAIN>` 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 <CODE>` 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=<BASE_DOMAIN>`, 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=<ACCOUNT_ID>` (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. |
|
||||||
145
deploy/scripts/gen-agent-ca.sh
Executable file
145
deploy/scripts/gen-agent-ca.sh
Executable file
@@ -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. "<sub>.<BASE_DOMAIN>" (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://<AGENT_FACING_HOST>:<AGENT_BIND_PORT> 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 <<SUMMARY
|
||||||
|
|
||||||
|
Enrollment CA ready in ${CA_DIR}. Wire these env paths (relay.env / control-plane.env):
|
||||||
|
|
||||||
|
CA_INTERMEDIATE_CERT_PATH = ${CA_DIR}/intermediate.cert.pem
|
||||||
|
NODE_MTLS_TRUST_BUNDLE_PATH = ${CA_DIR}/agent-ca.bundle.pem
|
||||||
|
AGENT_CA_CERT_PATH = ${CA_DIR}/agent-ca.cert.pem
|
||||||
|
AGENT_CA_CHAIN_PATH = ${CA_DIR}/agent-ca.bundle.pem
|
||||||
|
|
||||||
|
relay agent-server cert = ${CA_DIR}/relay-agent-server.cert.pem
|
||||||
|
relay agent-server key = ${CA_DIR}/relay-agent-server.key.pem (0600)
|
||||||
|
intermediate signing key = ${CA_DIR}/intermediate.key.pem (0600 — the CP dev signer)
|
||||||
|
|
||||||
|
NOTE (staging integration seam, owned by the A-wave CP boot/ca-wiring, NOT by this script):
|
||||||
|
The control-plane's dev in-process signer must sign agent leaves with intermediate.key.pem so the
|
||||||
|
leaves chain to this bundle. CA_INTERMEDIATE_KMS_KEY_REF=dev-local selects the dev signer; point it
|
||||||
|
at ${CA_DIR}/intermediate.key.pem. Phase 2 replaces this with a KMS/HSM ref (§3.1).
|
||||||
|
SPIFFE trust domain for agent leaves = ${RELAY_TRUST_DOMAIN:-<RELAY_TRUST_DOMAIN unset>}.
|
||||||
|
|
||||||
|
*** root.key.pem is the offline trust anchor — after first use, move it OFF this host. ***
|
||||||
|
SUMMARY
|
||||||
67
deploy/scripts/gen-capability-key.sh
Executable file
67
deploy/scripts/gen-capability-key.sh
Executable file
@@ -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 <<SUMMARY
|
||||||
|
|
||||||
|
P5 capability keypair ready.
|
||||||
|
|
||||||
|
PRIVATE key (B6 token-mint only; 0600; NEVER commit / NEVER on the relay):
|
||||||
|
${PRIV}
|
||||||
|
|
||||||
|
Put the SAME public key in BOTH env files (they are one key, two encodings):
|
||||||
|
|
||||||
|
# control-plane.env
|
||||||
|
CAPABILITY_SIGN_PUBKEY_B64=${PUB_STD_B64}
|
||||||
|
|
||||||
|
# relay.env
|
||||||
|
RELAY_AUTH_VERIFY_PUBKEY=${PUB_B64URL}
|
||||||
|
|
||||||
|
Sanity check they decode to the same 32 bytes:
|
||||||
|
diff <(openssl pkey -in "${PRIV}" -pubout -outform DER | tail -c 32 | xxd -p) \\
|
||||||
|
<(printf '%s' "${PUB_STD_B64}" | openssl base64 -d -A | xxd -p) && echo OK
|
||||||
|
SUMMARY
|
||||||
115
deploy/scripts/issue-tls-cert.sh
Executable file
115
deploy/scripts/issue-tls-cert.sh
Executable file
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# RELAY-PHASE1 · E (TASK E) — PUBLIC-web TLS cert for the browser :443 endpoint (Let's Encrypt).
|
||||||
|
#
|
||||||
|
# STAGING TEMPLATE. This is the ONLY public-CA cert in the deployment: the browser hits
|
||||||
|
# https://<SUBDOMAIN>.<BASE_DOMAIN> (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
|
||||||
|
# <SUBDOMAIN>.<BASE_DOMAIN> -> 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.<SUBDOMAIN>.<BASE_DOMAIN>. 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 = <SUBDOMAIN>.<BASE_DOMAIN>)
|
||||||
|
# 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 <<SUMMARY
|
||||||
|
|
||||||
|
Installed:
|
||||||
|
TLS_CERT_PATH = ${TLS_CERT_PATH}
|
||||||
|
TLS_KEY_PATH = ${TLS_KEY_PATH}
|
||||||
|
|
||||||
|
RENEWAL (LE certs last ~90 days):
|
||||||
|
- acme.sh installs its OWN cron on install ('acme.sh --cron'); --install-cert registered the
|
||||||
|
reload hook above, so renewals auto-reload the data-plane. Verify: 'crontab -l | grep acme'.
|
||||||
|
- certbot: add a cron/timer, e.g.
|
||||||
|
0 3 * * * certbot renew --quiet --deploy-hook 'systemctl try-reload-or-restart relay-data-plane.service'
|
||||||
|
- http-01 renewals need :80 reachable at renew time; dns-01 does not.
|
||||||
|
SUMMARY
|
||||||
52
deploy/systemd/relay-control-plane.service
Normal file
52
deploy/systemd/relay-control-plane.service
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# RELAY-PHASE1 · E (TASK E) — control-plane (P3) systemd unit. STAGING TEMPLATE.
|
||||||
|
#
|
||||||
|
# Runs the P3 admin/registry/CA/pairing API (control-plane/src/server.ts, added in A2) on the
|
||||||
|
# loopback admin port (CP_BIND_HOST/CP_BIND_PORT in the env file). It is NEVER exposed publicly —
|
||||||
|
# the security group keeps 8080 loopback-only; operators reach it via SSH tunnel.
|
||||||
|
#
|
||||||
|
# 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-control-plane.service /etc/systemd/system/
|
||||||
|
# 3) put the filled env at /etc/relay/control-plane.env (0600, owned by the service user)
|
||||||
|
# 4) sudo systemctl daemon-reload && sudo systemctl enable --now relay-control-plane.service
|
||||||
|
#
|
||||||
|
# The env file supplies PG_URL/REDIS_URL/CAPABILITY_SIGN_PUBKEY_B64/CA_* /BASE_DOMAIN (see .env.example).
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=web-terminal rendezvous-relay CONTROL PLANE (P3)
|
||||||
|
Documentation=file:///opt/web-terminal/docs/PLAN_RELAY_PHASE1.md
|
||||||
|
# Postgres + Redis run under Docker Compose (deploy/docker-compose.yml); wait for docker + network.
|
||||||
|
After=network-online.target docker.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Dedicated non-root service user (create once: sudo useradd --system --home /opt/web-terminal relay).
|
||||||
|
User=relay
|
||||||
|
Group=relay
|
||||||
|
WorkingDirectory=/opt/web-terminal
|
||||||
|
# Secrets/config come ONLY from the env file (INV9 — no secrets inline in the unit).
|
||||||
|
EnvironmentFile=/etc/relay/control-plane.env
|
||||||
|
# /usr/bin/env resolves npm via PATH (works for NodeSource /usr/bin/npm and nvm shims alike).
|
||||||
|
# Runs the "start" script (control-plane/package.json -> 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
|
||||||
53
deploy/systemd/relay-data-plane.service
Normal file
53
deploy/systemd/relay-data-plane.service
Normal file
@@ -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
|
||||||
@@ -26,8 +26,11 @@
|
|||||||
|
|
||||||
### 🚧 RELAY-PHASE1 — 把原生 rendezvous-relay 部署到单台 VPS(8.138.1.192,阿里云;2026-07-06)
|
### 🚧 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)。
|
- **目标**: 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 测试并存无冲突)。
|
- **进度**: **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 入口(`loadEnv→createPgStores→runMigrations→ioredis→createRedisRevocationBus→buildControlPlane→listen`)+ Redis 撤销总线接线(`main.ts:93` 现为 inert)。然后 Wave B(relay 数据面读同一持久 store)。
|
- **各任务**: 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.<b64u>` 子协议,**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`)、通配多租户。
|
- **未做/推迟到 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)
|
#### [x] A1 — Postgres store 适配器(P3)+ 迁移运行器(2026-07-06)
|
||||||
@@ -43,6 +46,13 @@
|
|||||||
- **验证(实测)**: `npx tsc --noEmit` 全项目干净;`npx vitest run` **15 files / 101 tests pass**。
|
- **验证(实测)**: `npx tsc --noEmit` 全项目干净;`npx vitest run` **15 files / 101 tests pass**。
|
||||||
- **偏差**: 手建 `control-plane/node_modules/relay-auth` 符号链接以配合新 file: 依赖(`npm install` 会重建)。**阻塞**: 无。
|
- **偏差**: 手建 `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`)
|
### ✅ 修复:项目面板把父文件夹当项目 & 会话点亮所有祖先项目(2026-07-06,分支 `feat/ios-client`)
|
||||||
- **现象(用户截图)**: 只在 `web-terminal` 跑了一个会话,但 "Active now" 同时显示 `web-terminal`/`Documents`/`yiukai` 三张卡,且父文件夹本身被列为项目。
|
- **现象(用户截图)**: 只在 `web-terminal` 跑了一个会话,但 "Active now" 同时显示 `web-terminal`/`Documents`/`yiukai` 三张卡,且父文件夹本身被列为项目。
|
||||||
- **根因(`src/http/projects.ts`)**: ① `belongsTo` 纯前缀匹配 → 会话按 cwd 归属到**每一个**祖先项目;② 历史合并(`mergeHistory`)把曾经跑过会话的 cwd(如 `~`、`~/Documents`)原样列为项目。
|
- **根因(`src/http/projects.ts`)**: ① `belongsTo` 纯前缀匹配 → 会话按 cwd 归属到**每一个**祖先项目;② 历史合并(`mergeHistory`)把曾经跑过会话的 cwd(如 `~`、`~/Documents`)原样列为项目。
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "tsx src/main.ts",
|
"start": "tsx src/main.ts",
|
||||||
|
"start:phase1": "tsx src/main-phase1.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|||||||
282
relay-run/src/main-phase1.ts
Normal file
282
relay-run/src/main-phase1.ts
Normal file
@@ -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)) // <repo>/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<string, { hostId: string; accountId: string } | null>()
|
||||||
|
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<void> {
|
||||||
|
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<typeof createAuthMintRoute> | 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: '<!doctype html><title>relay</title>', // 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<void> => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
262
relay-run/src/servers/auth-mint.ts
Normal file
262
relay-run/src/servers/auth-mint.ts
Normal file
@@ -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<HostStore, 'getBySubdomain'>
|
||||||
|
|
||||||
|
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<string, unknown>
|
||||||
|
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<string> {
|
||||||
|
return new Promise<string>((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<void> {
|
||||||
|
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<ArrayBuffer>`). */
|
||||||
|
function toArrayBufferView(u: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||||
|
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<CryptoKey> {
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { APP_SUBPROTOCOL } from 'relay-contracts'
|
|||||||
import type { UpgradeRequest } from 'term-relay/data-plane/upgrade.js'
|
import type { UpgradeRequest } from 'term-relay/data-plane/upgrade.js'
|
||||||
import type { RelayNode } from 'term-relay/data-plane/relay-node.js'
|
import type { RelayNode } from 'term-relay/data-plane/relay-node.js'
|
||||||
import { wsToWebSocketLike } from '../wiring/socket-pipe.js'
|
import { wsToWebSocketLike } from '../wiring/socket-pipe.js'
|
||||||
|
import { serveStatic } from './static-web.js'
|
||||||
|
|
||||||
export interface BrowserServerOptions {
|
export interface BrowserServerOptions {
|
||||||
readonly certPath: string
|
readonly certPath: string
|
||||||
@@ -20,6 +21,20 @@ export interface BrowserServerOptions {
|
|||||||
readonly bindPort: number
|
readonly bindPort: number
|
||||||
readonly node: RelayNode
|
readonly node: RelayNode
|
||||||
readonly landingHtml: string
|
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 onListening?: () => void
|
||||||
readonly onError?: (e: unknown) => void
|
readonly onError?: (e: unknown) => void
|
||||||
}
|
}
|
||||||
@@ -56,7 +71,24 @@ function buildUpgradeRequest(req: IncomingMessage): UpgradeRequest {
|
|||||||
export function startBrowserServer(opts: BrowserServerOptions): Server {
|
export function startBrowserServer(opts: BrowserServerOptions): Server {
|
||||||
const server = createServer(
|
const server = createServer(
|
||||||
{ cert: readFileSync(opts.certPath), key: readFileSync(opts.keyPath) },
|
{ 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.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
||||||
res.end(opts.landingHtml)
|
res.end(opts.landingHtml)
|
||||||
},
|
},
|
||||||
|
|||||||
96
relay-run/src/servers/static-web.ts
Normal file
96
relay-run/src/servers/static-web.ts
Normal file
@@ -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<string, string>
|
||||||
|
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<Record<string, string>> = {
|
||||||
|
'.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
|
||||||
|
}
|
||||||
|
}
|
||||||
93
relay-run/src/wiring/mtls-verifier.ts
Normal file
93
relay-run/src/wiring/mtls-verifier.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
121
relay-run/src/wiring/revocation-subscriber.ts
Normal file
121
relay-run/src/wiring/revocation-subscriber.ts
Normal file
@@ -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<ActiveTunnelRef>
|
||||||
|
/** 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<unknown>
|
||||||
|
unsubscribe(channel: string): Promise<unknown>
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
40
relay-run/src/wiring/route-resolver.ts
Normal file
40
relay-run/src/wiring/route-resolver.ts
Normal file
@@ -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<HostStore, 'getBySubdomain'>
|
||||||
|
|
||||||
|
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<ResolvedHost | null> {
|
||||||
|
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 }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
197
relay-run/src/wiring/stores-pg.ts
Normal file
197
relay-run/src/wiring/stores-pg.ts
Normal file
@@ -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<number>
|
||||||
|
set(key: string, value: string, mode?: 'NX'): Promise<string | null>
|
||||||
|
expireat(key: string, timestamp: number): Promise<number>
|
||||||
|
eval(script: string, numKeys: number, ...args: (string | number)[]): Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string>
|
||||||
|
} {
|
||||||
|
const meta: Record<string, string> = {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
260
relay-run/tests/auth-mint.test.ts
Normal file
260
relay-run/tests/auth-mint.test.ts
Normal file
@@ -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> = {}): 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<string, string>
|
||||||
|
body: string
|
||||||
|
headersSent: boolean
|
||||||
|
writeHead(status: number, headers: Record<string, string>): CapturedRes
|
||||||
|
end(chunk?: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeRes(): { res: ServerResponse; captured: CapturedRes; done: Promise<void> } {
|
||||||
|
let resolveDone!: () => void
|
||||||
|
const done = new Promise<void>((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<CapturedRes> {
|
||||||
|
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<boolean> {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
109
relay-run/tests/static-web.test.ts
Normal file
109
relay-run/tests/static-web.test.ts
Normal file
@@ -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 = '<!doctype html><title>idx</title>'
|
||||||
|
const PAIR_HTML = '<!doctype html><title>pair</title>'
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
158
relay-run/tests/wiring/mtls-verifier.test.ts
Normal file
158
relay-run/tests/wiring/mtls-verifier.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
237
relay-run/tests/wiring/revocation-subscriber.test.ts
Normal file
237
relay-run/tests/wiring/revocation-subscriber.test.ts
Normal file
@@ -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<number> {
|
||||||
|
this.subscribed.push(channel)
|
||||||
|
return this.subscribed.length
|
||||||
|
}
|
||||||
|
async unsubscribe(channel: string): Promise<number> {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
128
relay-run/tests/wiring/route-resolver.test.ts
Normal file
128
relay-run/tests/wiring/route-resolver.test.ts
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
265
relay-run/tests/wiring/stores-pg.test.ts
Normal file
265
relay-run/tests/wiring/stores-pg.test.ts
Normal file
@@ -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 <T>(sql: string, params: readonly unknown[]): Promise<readonly T[]> => {
|
||||||
|
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> = {}): 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:<jti> 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:<jti> 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
136
relay-web/src/dpop.ts
Normal file
136
relay-web/src/dpop.ts
Normal file
@@ -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.<b64url(proofJws)>`) 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<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<string> {
|
||||||
|
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<DpopKey> {
|
||||||
|
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<string> => {
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -1,11 +1,37 @@
|
|||||||
/**
|
/**
|
||||||
* index.html entry — v0.8 password gate → terminal view over the passthrough transport.
|
* index.html entry — Phase-1 STAGING operator login → terminal view.
|
||||||
* No inline script (strict-CSP friendly): this bundle is loaded via <script type="module">.
|
*
|
||||||
|
* The deployed Phase-1 relay serves this bundle and enables `POST /auth/mint` (B5). Flow:
|
||||||
|
* password → mint a §4.3 capability token bound to a fresh browser DPoP key → open the WS carrying
|
||||||
|
* the token (+ a DPoP proof signed by the SAME key) via the subprotocol → drive the terminal view.
|
||||||
|
* No inline script (strict-CSP friendly): loaded via <script type="module">.
|
||||||
*/
|
*/
|
||||||
import { readConfig } from '../config'
|
import { readConfig, type RelayWebConfig } from '../config'
|
||||||
import { mountPasswordLogin } from '../login-password'
|
import { mountStagingLogin, type StagingSession } from '../login-staging'
|
||||||
import { createPassthroughTransport } from '../ws-transport'
|
import { createPassthroughTransport } from '../ws-transport'
|
||||||
import { mountTerminalView } from '../terminal-view'
|
import { mountTerminalView } from '../terminal-view'
|
||||||
|
import { DPOP_HTM, htuFor } from '../dpop'
|
||||||
|
|
||||||
|
async function connect(
|
||||||
|
cfg: RelayWebConfig,
|
||||||
|
session: StagingSession,
|
||||||
|
loginRoot: HTMLElement,
|
||||||
|
termRoot: HTMLElement,
|
||||||
|
): Promise<void> {
|
||||||
|
// Sign a fresh DPoP proof for THIS upgrade with the key the token binds (`cnf.jkt`).
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000)
|
||||||
|
const dpopProof = await session.dpop.proof(htuFor(session.aud), DPOP_HTM, nowSec)
|
||||||
|
|
||||||
|
const transport = createPassthroughTransport(cfg, {
|
||||||
|
capabilityToken: session.token,
|
||||||
|
dpopProof,
|
||||||
|
})
|
||||||
|
// Reveal the terminal only after the socket is open; on failure the login screen stays visible.
|
||||||
|
await transport.open()
|
||||||
|
loginRoot.hidden = true
|
||||||
|
termRoot.hidden = false
|
||||||
|
mountTerminalView(termRoot, transport)
|
||||||
|
}
|
||||||
|
|
||||||
function boot(): void {
|
function boot(): void {
|
||||||
const cfg = readConfig(window.location)
|
const cfg = readConfig(window.location)
|
||||||
@@ -13,12 +39,13 @@ function boot(): void {
|
|||||||
const termRoot = document.getElementById('terminal')
|
const termRoot = document.getElementById('terminal')
|
||||||
if (!loginRoot || !termRoot) return
|
if (!loginRoot || !termRoot) return
|
||||||
|
|
||||||
mountPasswordLogin(loginRoot, cfg, {
|
mountStagingLogin(loginRoot, cfg, {
|
||||||
onSuccess: () => {
|
onSuccess: (session) => {
|
||||||
loginRoot.hidden = true
|
void connect(cfg, session, loginRoot, termRoot).catch(() => {
|
||||||
termRoot.hidden = false
|
// Connection denied/dropped — keep the operator on the login screen (no bearer in logs).
|
||||||
const transport = createPassthroughTransport(cfg)
|
loginRoot.hidden = false
|
||||||
void transport.open().then(() => mountTerminalView(termRoot, transport))
|
termRoot.hidden = true
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,24 @@ export * from './api-schemas'
|
|||||||
// v0.8 password gate (T3)
|
// v0.8 password gate (T3)
|
||||||
export { mountPasswordLogin, type PasswordLogin } from './login-password'
|
export { mountPasswordLogin, type PasswordLogin } from './login-password'
|
||||||
|
|
||||||
|
// Phase-1 STAGING operator login (mint → §4.3 token) + browser DPoP PoP (B6)
|
||||||
|
export {
|
||||||
|
mountStagingLogin,
|
||||||
|
type StagingLogin,
|
||||||
|
type StagingLoginDeps,
|
||||||
|
type StagingSession,
|
||||||
|
} from './login-staging'
|
||||||
|
export {
|
||||||
|
createDpopKey,
|
||||||
|
encodeDpopSubprotocol,
|
||||||
|
extractDpopFromSubprotocols,
|
||||||
|
htuFor,
|
||||||
|
DPOP_HTM,
|
||||||
|
DPOP_SUBPROTOCOL_PREFIX,
|
||||||
|
type DpopKey,
|
||||||
|
type DpopDeps,
|
||||||
|
} from './dpop'
|
||||||
|
|
||||||
// Terminal view + transport seam (T4)
|
// Terminal view + transport seam (T4)
|
||||||
export {
|
export {
|
||||||
createPassthroughTransport,
|
createPassthroughTransport,
|
||||||
|
|||||||
142
relay-web/src/login-staging.ts
Normal file
142
relay-web/src/login-staging.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* B6 (Phase-1 STAGING) — operator password gate → `POST /auth/mint` → §4.3 capability token.
|
||||||
|
*
|
||||||
|
* This is the STAGING login the deployed Phase-1 relay actually serves (`main-phase1.ts` enables
|
||||||
|
* `POST /auth/mint`, B5/auth-mint.ts). It is a deliberate shortcut: a single shared operator
|
||||||
|
* password (constant-time compared server-side) stands in for the full WebAuthn stack (Phase 2 →
|
||||||
|
* T7 `login-passkey.ts`). The flow, per mint:
|
||||||
|
*
|
||||||
|
* 1. mint an EPHEMERAL browser DPoP key (WebCrypto Ed25519) and take its `jkt` thumbprint;
|
||||||
|
* 2. `POST /auth/mint {password, jkt, subdomain}` (subdomain comes from the same-origin `location`,
|
||||||
|
* never a caller-supplied accountId — INV3); on 200 receive `{token}` bound to that `jkt`;
|
||||||
|
* 3. hand the caller `{token, aud, dpop}` so it can open the WS carrying the token (+ a DPoP proof
|
||||||
|
* signed by the SAME key) — see entry/index-page.ts.
|
||||||
|
*
|
||||||
|
* Security (mirrors T3 login-password.ts):
|
||||||
|
* - the password is NEVER stored in localStorage and NEVER logged; all error text is set via
|
||||||
|
* `textContent` (never `innerHTML`); empty input disables submit (fail-fast at the boundary);
|
||||||
|
* - the request body carries only `{password, jkt, subdomain}` — a NAME + the client's own DPoP
|
||||||
|
* thumbprint, never an accountId/hostId (INV3, deny-by-default; the CP resolves identity);
|
||||||
|
* - the `{token}` response is Zod-validated before use (never a torn object).
|
||||||
|
*/
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { RelayWebConfig } from './config'
|
||||||
|
import { createDpopKey, type DpopKey } from './dpop'
|
||||||
|
|
||||||
|
/** Same-origin staging mint endpoint (B5 auth-mint.ts owns `POST /auth/mint`). */
|
||||||
|
const MINT_PATH = '/auth/mint'
|
||||||
|
|
||||||
|
/** The `{token}` response shape (lenient: ignore any additive fields, require a non-empty token). */
|
||||||
|
const MintResponseSchema = z.object({ token: z.string().min(1) })
|
||||||
|
|
||||||
|
/** What a successful staging login yields — everything the connect step needs, nothing more. */
|
||||||
|
export interface StagingSession {
|
||||||
|
/** §4.3 capability token (a bearer secret — pass to the transport, NEVER log or persist it). */
|
||||||
|
readonly token: string
|
||||||
|
/** Tenant subdomain the token's `aud` is bound to; used to derive the DPoP `htu` (`htuFor`). */
|
||||||
|
readonly aud: string
|
||||||
|
/** The SAME ephemeral key whose `jkt` the token binds — signs the connect DPoP proof. */
|
||||||
|
readonly dpop: DpopKey
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StagingLogin {
|
||||||
|
/** Attempt a mint with `password`. 'ok' fires `onSuccess`; 'rejected' surfaces an error in the UI. */
|
||||||
|
submit(password: string): Promise<'ok' | 'rejected'>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StagingLoginDeps {
|
||||||
|
readonly fetchImpl?: typeof fetch
|
||||||
|
/** Invoked once per successful mint so the shell wrapper can open the terminal connection. */
|
||||||
|
readonly onSuccess?: (session: StagingSession) => void
|
||||||
|
/** DI seam: the DPoP key factory (defaults to WebCrypto); tests inject a deterministic fake. */
|
||||||
|
readonly createKey?: () => Promise<DpopKey>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountStagingLogin(
|
||||||
|
root: HTMLElement,
|
||||||
|
cfg: RelayWebConfig,
|
||||||
|
deps: StagingLoginDeps = {},
|
||||||
|
): StagingLogin {
|
||||||
|
const fetchImpl = deps.fetchImpl ?? fetch
|
||||||
|
const createKey = deps.createKey ?? (() => createDpopKey())
|
||||||
|
|
||||||
|
const form = document.createElement('form')
|
||||||
|
form.className = 'login-form'
|
||||||
|
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'password'
|
||||||
|
input.autocomplete = 'current-password'
|
||||||
|
input.placeholder = 'Operator password'
|
||||||
|
input.className = 'login-input'
|
||||||
|
|
||||||
|
const button = document.createElement('button')
|
||||||
|
button.type = 'submit'
|
||||||
|
button.textContent = 'Connect'
|
||||||
|
button.disabled = true // empty input → disabled (fail-fast)
|
||||||
|
|
||||||
|
const error = document.createElement('p')
|
||||||
|
error.className = 'login-error'
|
||||||
|
error.setAttribute('role', 'alert')
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
button.disabled = input.value.length === 0
|
||||||
|
error.textContent = '' // clear stale error on edit
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit(password: string): Promise<'ok' | 'rejected'> {
|
||||||
|
if (password.length === 0) return 'rejected'
|
||||||
|
|
||||||
|
let dpop: DpopKey
|
||||||
|
try {
|
||||||
|
dpop = await createKey()
|
||||||
|
} catch {
|
||||||
|
error.textContent = 'Secure crypto is unavailable in this browser.'
|
||||||
|
return 'rejected'
|
||||||
|
}
|
||||||
|
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await fetchImpl(`${cfg.apiBase}${MINT_PATH}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
// INV3: only a NAME (subdomain) + the client's own DPoP thumbprint — never an accountId.
|
||||||
|
body: JSON.stringify({ password, jkt: dpop.jkt, subdomain: cfg.subdomain }),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
error.textContent = 'Network error — please retry.'
|
||||||
|
return 'rejected'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
error.textContent = res.status === 401 ? 'Invalid operator password.' : 'Sign-in failed.'
|
||||||
|
return 'rejected'
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = await res.json()
|
||||||
|
} catch {
|
||||||
|
error.textContent = 'Malformed response from the relay.'
|
||||||
|
return 'rejected'
|
||||||
|
}
|
||||||
|
const parsed = MintResponseSchema.safeParse(body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
error.textContent = 'Malformed response from the relay.'
|
||||||
|
return 'rejected'
|
||||||
|
}
|
||||||
|
|
||||||
|
error.textContent = ''
|
||||||
|
deps.onSuccess?.({ token: parsed.data.token, aud: cfg.subdomain, dpop })
|
||||||
|
return 'ok'
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', (ev) => {
|
||||||
|
ev.preventDefault()
|
||||||
|
void submit(input.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
form.append(input, button, error)
|
||||||
|
root.append(form)
|
||||||
|
|
||||||
|
return { submit }
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts'
|
import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts'
|
||||||
import type { RelayWebConfig } from './config'
|
import type { RelayWebConfig } from './config'
|
||||||
|
import { encodeDpopSubprotocol } from './dpop'
|
||||||
|
|
||||||
/** The seam every transport implements — plaintext in the browser, encoded by the impl. */
|
/** The seam every transport implements — plaintext in the browser, encoded by the impl. */
|
||||||
export interface TerminalTransport {
|
export interface TerminalTransport {
|
||||||
@@ -41,6 +42,14 @@ export type WebSocketCtor = new (url: string, protocols?: string | readonly stri
|
|||||||
export interface PassthroughOpts {
|
export interface PassthroughOpts {
|
||||||
/** v0.9+ populates this; ABSENT in v0.8 (cookie-only auth). */
|
/** v0.9+ populates this; ABSENT in v0.8 (cookie-only auth). */
|
||||||
readonly capabilityToken?: string
|
readonly capabilityToken?: string
|
||||||
|
/**
|
||||||
|
* Phase-1 STAGING (B6): a DPoP proof-of-possession JWS bound to the token's `cnf.jkt`. Browsers
|
||||||
|
* can't set the `dpop` request header on a native WS upgrade, so — mirroring the §4.3 token — it
|
||||||
|
* rides an extra `term.dpop.<b64u>` subprotocol entry (see dpop.ts `encodeDpopSubprotocol`). The
|
||||||
|
* current relay ignores it (its `handleProtocols` echoes only `APP_SUBPROTOCOL`), so the handshake
|
||||||
|
* is unaffected; true DPoP enforcement needs the relay to read this entry (server-lane, VPS-gated).
|
||||||
|
*/
|
||||||
|
readonly dpopProof?: string
|
||||||
/** DI seam: inject a mock WebSocket constructor in tests; defaults to global `WebSocket`. */
|
/** DI seam: inject a mock WebSocket constructor in tests; defaults to global `WebSocket`. */
|
||||||
readonly wsCtor?: WebSocketCtor
|
readonly wsCtor?: WebSocketCtor
|
||||||
}
|
}
|
||||||
@@ -71,15 +80,21 @@ export function createPassthroughTransport(
|
|||||||
): TerminalTransport {
|
): TerminalTransport {
|
||||||
const Ctor: WebSocketCtor = opts.wsCtor ?? (globalThis.WebSocket as unknown as WebSocketCtor)
|
const Ctor: WebSocketCtor = opts.wsCtor ?? (globalThis.WebSocket as unknown as WebSocketCtor)
|
||||||
const token = opts.capabilityToken
|
const token = opts.capabilityToken
|
||||||
|
const dpopProof = opts.dpopProof
|
||||||
|
const hasBearer = token !== undefined || dpopProof !== undefined
|
||||||
const url = cfg.wsUrl('/term') // SAME-ORIGIN, scheme-following; NEVER carries a token (T4 §)
|
const url = cfg.wsUrl('/term') // SAME-ORIGIN, scheme-following; NEVER carries a token (T4 §)
|
||||||
|
|
||||||
let ws: WebSocketLike | null = null
|
let ws: WebSocketLike | null = null
|
||||||
let messageCb: ((bytes: Uint8Array) => void) | null = null
|
let messageCb: ((bytes: Uint8Array) => void) | null = null
|
||||||
let closeCb: ((reason: string) => void) | null = null
|
let closeCb: ((reason: string) => void) | null = null
|
||||||
|
|
||||||
// App subprotocol FIRST; token entry (v0.9+) second — the frozen §4.3 order.
|
// App subprotocol FIRST; token entry (v0.9+) second; DPoP proof entry (Phase-1 B6) last — a
|
||||||
const protocols: readonly string[] =
|
// bearer NEVER touches the URL/query (would leak it to proxy/access logs, history, and Referer).
|
||||||
token === undefined ? [APP_SUBPROTOCOL] : [APP_SUBPROTOCOL, encodeTokenSubprotocol(token)]
|
const protocols: readonly string[] = [
|
||||||
|
APP_SUBPROTOCOL,
|
||||||
|
...(token === undefined ? [] : [encodeTokenSubprotocol(token)]),
|
||||||
|
...(dpopProof === undefined ? [] : [encodeDpopSubprotocol(dpopProof)]),
|
||||||
|
]
|
||||||
|
|
||||||
function open(): Promise<void> {
|
function open(): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -88,9 +103,10 @@ export function createPassthroughTransport(
|
|||||||
ws = socket
|
ws = socket
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
// Echo rule (v0.9+): with a token attached, accept ONLY the app subprotocol back — a relay
|
// Echo rule (v0.9+): with a bearer attached (token and/or DPoP), accept ONLY the app
|
||||||
// echoing the token entry, an empty value, or a foreign one is rejected (bearer-leak guard).
|
// subprotocol back — a relay echoing a bearer entry, an empty value, or a foreign one is
|
||||||
if (token !== undefined && socket.protocol !== APP_SUBPROTOCOL) {
|
// rejected (bearer-leak guard).
|
||||||
|
if (hasBearer && socket.protocol !== APP_SUBPROTOCOL) {
|
||||||
socket.close(4400, 'bad-subprotocol')
|
socket.close(4400, 'bad-subprotocol')
|
||||||
reject(new Error(`unexpected echoed subprotocol: '${socket.protocol}'`))
|
reject(new Error(`unexpected echoed subprotocol: '${socket.protocol}'`))
|
||||||
return
|
return
|
||||||
|
|||||||
106
relay-web/test/dpop.test.ts
Normal file
106
relay-web/test/dpop.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { webcrypto } from 'node:crypto'
|
||||||
|
import { decodeBase64UrlBytes, decodeBase64UrlString } from 'relay-contracts'
|
||||||
|
import {
|
||||||
|
createDpopKey,
|
||||||
|
encodeDpopSubprotocol,
|
||||||
|
extractDpopFromSubprotocols,
|
||||||
|
htuFor,
|
||||||
|
DPOP_HTM,
|
||||||
|
DPOP_SUBPROTOCOL_PREFIX,
|
||||||
|
} from '../src/dpop'
|
||||||
|
|
||||||
|
// jsdom's global crypto lacks a full SubtleCrypto/Ed25519; inject Node's webcrypto (DI seam).
|
||||||
|
const subtle = webcrypto.subtle as unknown as SubtleCrypto
|
||||||
|
/** Copy into a fresh ArrayBuffer-backed view (WebCrypto's BufferSource wants Uint8Array<ArrayBuffer>). */
|
||||||
|
const ab = (u: Uint8Array): Uint8Array<ArrayBuffer> => {
|
||||||
|
const out = new Uint8Array(u.byteLength)
|
||||||
|
out.set(u)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
const utf8 = (s: string): Uint8Array<ArrayBuffer> => ab(new TextEncoder().encode(s))
|
||||||
|
const bytes = (b64u: string): Uint8Array<ArrayBuffer> => ab(decodeBase64UrlBytes(b64u))
|
||||||
|
const decodeJson = (b64u: string): unknown =>
|
||||||
|
JSON.parse(new TextDecoder().decode(decodeBase64UrlBytes(b64u)))
|
||||||
|
|
||||||
|
describe('createDpopKey (B6) — browser DPoP proof-of-possession', () => {
|
||||||
|
it('produces a 43-char base64url jkt (matches the server JKT_RE)', async () => {
|
||||||
|
const key = await createDpopKey({ subtle })
|
||||||
|
expect(key.jkt).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("jkt equals base64url(SHA-256(canonical JWK)) with member order crv,kty,x", async () => {
|
||||||
|
let jti = 0
|
||||||
|
const key = await createDpopKey({ subtle, randomJti: () => `jti-${jti++}` })
|
||||||
|
const proof = await key.proof('https://alice/ws', DPOP_HTM, 1000)
|
||||||
|
const [h] = proof.split('.') as [string, string, string]
|
||||||
|
const header = decodeJson(h) as { jwk: { crv: string; kty: string; x: string } }
|
||||||
|
// Recompute the thumbprint from the proof's embedded JWK exactly as relay-auth does.
|
||||||
|
const canonical = JSON.stringify({ crv: 'Ed25519', kty: 'OKP', x: header.jwk.x })
|
||||||
|
const digest = new Uint8Array(await subtle.digest('SHA-256', utf8(canonical)))
|
||||||
|
const recomputed = Buffer.from(digest).toString('base64url')
|
||||||
|
expect(recomputed).toBe(key.jkt)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds a 3-part DPoP JWS whose header/payload match relay-auth buildDpopProof', async () => {
|
||||||
|
const key = await createDpopKey({ subtle, randomJti: () => 'fixed-jti' })
|
||||||
|
const proof = await key.proof('https://alice/ws', 'GET', 1234)
|
||||||
|
const parts = proof.split('.')
|
||||||
|
expect(parts).toHaveLength(3)
|
||||||
|
const [h, p] = parts as [string, string, string]
|
||||||
|
const header = decodeJson(h) as { typ: string; jwk: { crv: string; kty: string; x: string } }
|
||||||
|
const payload = decodeJson(p) as Record<string, unknown>
|
||||||
|
expect(header.typ).toBe('dpop+ed25519')
|
||||||
|
expect(header.jwk.kty).toBe('OKP')
|
||||||
|
expect(header.jwk.crv).toBe('Ed25519')
|
||||||
|
expect(payload).toEqual({ htu: 'https://alice/ws', htm: 'GET', jti: 'fixed-jti', iat: 1234 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the DPoP signature verifies under the embedded public JWK (self-consistent proof)', async () => {
|
||||||
|
const key = await createDpopKey({ subtle })
|
||||||
|
const proof = await key.proof('https://alice/ws', 'GET', 2000)
|
||||||
|
const [h, p, s] = proof.split('.') as [string, string, string]
|
||||||
|
const header = decodeJson(h) as { jwk: { x: string } }
|
||||||
|
const pub = await subtle.importKey('raw', bytes(header.jwk.x), { name: 'Ed25519' }, true, ['verify'])
|
||||||
|
const ok = await subtle.verify({ name: 'Ed25519' }, pub, bytes(s), utf8(`${h}.${p}`))
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mints a FRESH jti per proof (no DPoP replay across upgrades)', async () => {
|
||||||
|
const key = await createDpopKey({ subtle })
|
||||||
|
const a = await key.proof('https://alice/ws', 'GET', 3000)
|
||||||
|
const b = await key.proof('https://alice/ws', 'GET', 3000)
|
||||||
|
const jtiOf = (proof: string): unknown =>
|
||||||
|
(decodeJson(proof.split('.')[1] as string) as { jti: unknown }).jti
|
||||||
|
expect(jtiOf(a)).not.toEqual(jtiOf(b))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails fast (typed RelayWebError, no key material leaked) when Ed25519 keygen is unsupported', async () => {
|
||||||
|
const brokenSubtle = {
|
||||||
|
generateKey: async () => {
|
||||||
|
throw new Error('Ed25519 not supported')
|
||||||
|
},
|
||||||
|
} as unknown as SubtleCrypto
|
||||||
|
await expect(createDpopKey({ subtle: brokenSubtle })).rejects.toThrow(
|
||||||
|
/failed to generate an Ed25519 DPoP key/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('htuFor mirrors relay-run: https://<aud>/ws', () => {
|
||||||
|
expect(htuFor('alice')).toBe('https://alice/ws')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DPoP subprotocol carrier (encode/extract round-trip)', () => {
|
||||||
|
it('encodes term.dpop.<base64url(proofJws)> and extracts it back verbatim', () => {
|
||||||
|
const proof = 'aGVhZGVy.cGF5bG9hZA.c2ln'
|
||||||
|
const entry = encodeDpopSubprotocol(proof)
|
||||||
|
expect(entry.startsWith(DPOP_SUBPROTOCOL_PREFIX)).toBe(true)
|
||||||
|
expect(decodeBase64UrlString(entry.slice(DPOP_SUBPROTOCOL_PREFIX.length))).toBe(proof)
|
||||||
|
expect(extractDpopFromSubprotocols(['term.relay.v1', entry])).toBe(proof)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extract returns null when no DPoP entry is present', () => {
|
||||||
|
expect(extractDpopFromSubprotocols(['term.relay.v1', 'term.token.QUJD'])).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
126
relay-web/test/login-staging.test.ts
Normal file
126
relay-web/test/login-staging.test.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { readConfig } from '../src/config'
|
||||||
|
import { mountStagingLogin } from '../src/login-staging'
|
||||||
|
import type { DpopKey } from '../src/dpop'
|
||||||
|
|
||||||
|
const cfg = readConfig({
|
||||||
|
protocol: 'https:',
|
||||||
|
host: 'alice.term.example.com',
|
||||||
|
hostname: 'alice.term.example.com',
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Deterministic DPoP key so the login test never touches WebCrypto. */
|
||||||
|
const fakeKey = (jkt: string): DpopKey => ({ jkt, proof: async () => 'h.p.s' })
|
||||||
|
const FAKE_JKT = 'A'.repeat(43)
|
||||||
|
|
||||||
|
function mintFetch(body: unknown, ok = true, status = 200): typeof fetch {
|
||||||
|
return vi.fn(async () => ({ ok, status, json: async () => body }) as Response) as unknown as typeof fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mountStagingLogin (B6) — operator password → POST /auth/mint', () => {
|
||||||
|
let root: HTMLElement
|
||||||
|
beforeEach(() => {
|
||||||
|
root = document.createElement('div')
|
||||||
|
document.body.append(root)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('correct password → "ok" and POSTs {password, jkt, subdomain} (INV3: a NAME, not an accountId)', async () => {
|
||||||
|
const fetchImpl = mintFetch({ token: 'CAPTOKEN' })
|
||||||
|
const onSuccess = vi.fn()
|
||||||
|
const login = mountStagingLogin(root, cfg, {
|
||||||
|
fetchImpl,
|
||||||
|
onSuccess,
|
||||||
|
createKey: async () => fakeKey(FAKE_JKT),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(login.submit('s3cret')).resolves.toBe('ok')
|
||||||
|
|
||||||
|
const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]!
|
||||||
|
expect(call[0]).toBe('/auth/mint')
|
||||||
|
const init = call[1] as RequestInit
|
||||||
|
expect(init.method).toBe('POST')
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({
|
||||||
|
password: 's3cret',
|
||||||
|
jkt: FAKE_JKT,
|
||||||
|
subdomain: 'alice',
|
||||||
|
})
|
||||||
|
// onSuccess receives the token + aud + the SAME dpop key whose jkt was minted.
|
||||||
|
const session = onSuccess.mock.calls[0]![0]
|
||||||
|
expect(session.token).toBe('CAPTOKEN')
|
||||||
|
expect(session.aud).toBe('alice')
|
||||||
|
expect(session.dpop.jkt).toBe(FAKE_JKT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wrong password (401) → "rejected", no onSuccess, error via textContent (no innerHTML)', async () => {
|
||||||
|
const onSuccess = vi.fn()
|
||||||
|
const login = mountStagingLogin(root, cfg, {
|
||||||
|
fetchImpl: mintFetch({}, false, 401),
|
||||||
|
onSuccess,
|
||||||
|
createKey: async () => fakeKey(FAKE_JKT),
|
||||||
|
})
|
||||||
|
await expect(login.submit('nope')).resolves.toBe('rejected')
|
||||||
|
expect(onSuccess).not.toHaveBeenCalled()
|
||||||
|
const err = root.querySelector('.login-error') as HTMLElement
|
||||||
|
expect(err.textContent).toBe('Invalid operator password.')
|
||||||
|
expect(err.innerHTML).toBe('Invalid operator password.') // textContent-set, no markup
|
||||||
|
})
|
||||||
|
|
||||||
|
it('empty input keeps submit disabled and submit("") rejects without a fetch or a key', async () => {
|
||||||
|
const fetchImpl = mintFetch({ token: 'X' })
|
||||||
|
const createKey = vi.fn(async () => fakeKey(FAKE_JKT))
|
||||||
|
const login = mountStagingLogin(root, cfg, { fetchImpl, createKey })
|
||||||
|
expect((root.querySelector('button') as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
await expect(login.submit('')).resolves.toBe('rejected')
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled()
|
||||||
|
expect(createKey).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('network error → "rejected" with a retry message', async () => {
|
||||||
|
const fetchImpl = vi.fn(async () => {
|
||||||
|
throw new Error('offline')
|
||||||
|
}) as unknown as typeof fetch
|
||||||
|
const login = mountStagingLogin(root, cfg, { fetchImpl, createKey: async () => fakeKey(FAKE_JKT) })
|
||||||
|
await expect(login.submit('s3cret')).resolves.toBe('rejected')
|
||||||
|
expect((root.querySelector('.login-error') as HTMLElement).textContent).toBe(
|
||||||
|
'Network error — please retry.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('malformed mint response (no token) → "rejected", no onSuccess', async () => {
|
||||||
|
const onSuccess = vi.fn()
|
||||||
|
const login = mountStagingLogin(root, cfg, {
|
||||||
|
fetchImpl: mintFetch({ nope: true }),
|
||||||
|
onSuccess,
|
||||||
|
createKey: async () => fakeKey(FAKE_JKT),
|
||||||
|
})
|
||||||
|
await expect(login.submit('s3cret')).resolves.toBe('rejected')
|
||||||
|
expect(onSuccess).not.toHaveBeenCalled()
|
||||||
|
expect((root.querySelector('.login-error') as HTMLElement).textContent).toBe(
|
||||||
|
'Malformed response from the relay.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('crypto failure (key gen throws) → "rejected", no fetch issued', async () => {
|
||||||
|
const fetchImpl = mintFetch({ token: 'X' })
|
||||||
|
const login = mountStagingLogin(root, cfg, {
|
||||||
|
fetchImpl,
|
||||||
|
createKey: async () => {
|
||||||
|
throw new Error('no webcrypto')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await expect(login.submit('s3cret')).resolves.toBe('rejected')
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled()
|
||||||
|
expect((root.querySelector('.login-error') as HTMLElement).textContent).toBe(
|
||||||
|
'Secure crypto is unavailable in this browser.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not persist the password to localStorage', async () => {
|
||||||
|
const login = mountStagingLogin(root, cfg, {
|
||||||
|
fetchImpl: mintFetch({ token: 'X' }),
|
||||||
|
createKey: async () => fakeKey(FAKE_JKT),
|
||||||
|
})
|
||||||
|
await login.submit('s3cret')
|
||||||
|
expect(JSON.stringify(localStorage)).not.toContain('s3cret')
|
||||||
|
})
|
||||||
|
})
|
||||||
81
relay-web/test/ws-transport-dpop.test.ts
Normal file
81
relay-web/test/ws-transport-dpop.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts'
|
||||||
|
import { readConfig } from '../src/config'
|
||||||
|
import { createPassthroughTransport, type WebSocketLike } from '../src/ws-transport'
|
||||||
|
import { encodeDpopSubprotocol } from '../src/dpop'
|
||||||
|
|
||||||
|
const cfg = readConfig({
|
||||||
|
protocol: 'https:',
|
||||||
|
host: 'alice.term.example.com',
|
||||||
|
hostname: 'alice.term.example.com',
|
||||||
|
})
|
||||||
|
|
||||||
|
class MockWs implements WebSocketLike {
|
||||||
|
static last: MockWs | null = null
|
||||||
|
binaryType = ''
|
||||||
|
protocol = ''
|
||||||
|
readonly url: string
|
||||||
|
readonly protocols: readonly string[]
|
||||||
|
closed: { code?: number; reason?: string } | null = null
|
||||||
|
onopen: ((ev: unknown) => void) | null = null
|
||||||
|
onmessage: ((ev: { data: unknown }) => void) | null = null
|
||||||
|
onclose: ((ev: { code?: number; reason?: string }) => void) | null = null
|
||||||
|
onerror: ((ev: unknown) => void) | null = null
|
||||||
|
constructor(url: string, protocols?: string | readonly string[]) {
|
||||||
|
this.url = url
|
||||||
|
this.protocols = protocols === undefined ? [] : typeof protocols === 'string' ? [protocols] : protocols
|
||||||
|
MockWs.last = this
|
||||||
|
}
|
||||||
|
send(): void {}
|
||||||
|
close(code?: number, reason?: string): void {
|
||||||
|
const ev: { code?: number; reason?: string } = {}
|
||||||
|
if (code !== undefined) ev.code = code
|
||||||
|
if (reason !== undefined) ev.reason = reason
|
||||||
|
this.closed = ev
|
||||||
|
this.onclose?.(ev)
|
||||||
|
}
|
||||||
|
fireOpen(echoedProtocol: string): void {
|
||||||
|
this.protocol = echoedProtocol
|
||||||
|
this.onopen?.({})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createPassthroughTransport — B6 DPoP proof attachment', () => {
|
||||||
|
const PROOF = 'aGVhZGVy.cGF5bG9hZA.c2ln'
|
||||||
|
|
||||||
|
it('token + DPoP both ride Sec-WebSocket-Protocol in the frozen order, NEVER the URL', async () => {
|
||||||
|
const t = createPassthroughTransport(cfg, {
|
||||||
|
wsCtor: MockWs,
|
||||||
|
capabilityToken: 'RAWTOK',
|
||||||
|
dpopProof: PROOF,
|
||||||
|
})
|
||||||
|
const opened = t.open()
|
||||||
|
MockWs.last!.fireOpen(APP_SUBPROTOCOL)
|
||||||
|
await opened
|
||||||
|
expect(MockWs.last!.protocols).toEqual([
|
||||||
|
APP_SUBPROTOCOL,
|
||||||
|
encodeTokenSubprotocol('RAWTOK'),
|
||||||
|
encodeDpopSubprotocol(PROOF),
|
||||||
|
])
|
||||||
|
expect(MockWs.last!.url).not.toContain('RAWTOK')
|
||||||
|
expect(MockWs.last!.url).not.toContain(PROOF)
|
||||||
|
expect(MockWs.last!.url).not.toContain(encodeDpopSubprotocol(PROOF))
|
||||||
|
expect(MockWs.last!.url).not.toMatch(/\?/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('echo rule holds with a DPoP bearer: only APP_SUBPROTOCOL back is accepted', async () => {
|
||||||
|
const t = createPassthroughTransport(cfg, { wsCtor: MockWs, dpopProof: PROOF })
|
||||||
|
const opened = t.open()
|
||||||
|
MockWs.last!.fireOpen(encodeDpopSubprotocol(PROOF)) // relay wrongly echoes the DPoP entry
|
||||||
|
await expect(opened).rejects.toThrow()
|
||||||
|
expect(MockWs.last!.closed).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('no dpopProof → protocols unchanged from the v0.9 token-only path (backward compatible)', async () => {
|
||||||
|
const t = createPassthroughTransport(cfg, { wsCtor: MockWs, capabilityToken: 'RAWTOK' })
|
||||||
|
const opened = t.open()
|
||||||
|
MockWs.last!.fireOpen(APP_SUBPROTOCOL)
|
||||||
|
await opened
|
||||||
|
expect(MockWs.last!.protocols).toEqual([APP_SUBPROTOCOL, encodeTokenSubprotocol('RAWTOK')])
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user