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",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --format=esm --outfile=dist/cli.js --banner:js='#!/usr/bin/env node\nimport{createRequire as __cjs}from\"node:module\";const require=__cjs(import.meta.url);'",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
@@ -26,6 +27,7 @@
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"esbuild": "^0.28.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user