/** * Long-running tunnel supervisor — PLAN_RELAY_PHASE1 C2. Ports the PROVEN cafeDemo assembly * (dialRelay → holdTunnel → createStreamRouter → dialLoopback, plus heartbeat) into a supervised * loop that survives disconnects: exponential backoff reconnect (T10 policy), heartbeat liveness * (T9), and GOAWAY/revocation-aware teardown (T14, INV12 — a revoked host NEVER reconnects). * * All IO is injectable via `RunTunnelDeps` so the loop is unit-testable with fakes (see cafeDemo); * the two-arg `runTunnel(cfg, ks)` default path wires the real `ws` sockets. INV2 is preserved: the * router splices OPAQUE bytes (identityTransform) — no terminal parsing happens here. */ import { WebSocket } from 'ws' import type { AgentConfig } from '../config/agentConfig.js' import type { Keystore } from '../keys/keystore.js' import { createLogger, type Logger } from '../log/logger.js' import { createRevocationState, applyGoAway } from '../lifecycle/revocation.js' import { dialRelay, type TlsWsConstructor } from './dial.js' import { dialLoopback, type DialLoopback, type WsConstructor } from './loopback.js' import { holdTunnel, type Tunnel } from './tunnel.js' import { createStreamRouter, identityTransform } from './streamRouter.js' import { createHeartbeat } from './heartbeat.js' import { createBackoff, reconnectLoop, type BackoffPolicy, type Sleep } from './backoff.js' import type { TimerLike, WsLike } from './seams.js' /** Handle returned by `runTunnel`: stop the supervisor, or await its terminal exit code. */ export interface TunnelHandle { /** Request graceful shutdown; resolves once the supervisor loop has fully stopped. */ stop(): Promise /** Resolves with a process exit code when the loop ends (stopped or host revoked ⇒ 0). */ readonly done: Promise } /** Injectable seams for the supervisor. All optional; unset fields default to real `ws` IO. */ export interface RunTunnelDeps { connectRelay(): Promise dialLoopback: DialLoopback logger: Logger timer: TimerLike sleep: Sleep backoff: BackoffPolicy } const realTimer: TimerLike = { setTimeout: (cb, ms) => setTimeout(cb, ms), clearTimeout: (h) => clearTimeout(h as ReturnType), setInterval: (cb, ms) => setInterval(cb, ms), clearInterval: (h) => clearInterval(h as ReturnType), } const realSleep: Sleep = (ms) => new Promise((r) => setTimeout(r, ms)) function resolveDeps(cfg: AgentConfig, ks: Keystore, o?: Partial): RunTunnelDeps { return { connectRelay: o?.connectRelay ?? (() => dialRelay(cfg, ks, { Ctor: WebSocket as unknown as TlsWsConstructor })), dialLoopback: o?.dialLoopback ?? dialLoopback(cfg.localTargetUrl, WebSocket as unknown as WsConstructor), logger: o?.logger ?? createLogger('info'), timer: o?.timer ?? realTimer, sleep: o?.sleep ?? realSleep, backoff: o?.backoff ?? createBackoff({ jitter: true }), } } /** * Start the supervised tunnel. Returns immediately with a handle; the reconnect loop runs in the * background. Never awaits the first connection (an unreachable relay would otherwise hang the * caller with no way to `stop()`). */ export async function runTunnel( cfg: AgentConfig, ks: Keystore, overrides?: Partial, ): Promise { const { connectRelay, dialLoopback: dialLb, logger, timer, sleep, backoff } = resolveDeps(cfg, ks, overrides) let stopped = false let currentTunnel: Tunnel | null = null let currentSocket: WsLike | null = null // INV12: revocation tears the live tunnel down immediately and suppresses all reconnects. const revocation = createRevocationState(() => currentTunnel?.close()) const shouldStop = (): boolean => stopped || revocation.isRevoked() async function dialTunnel(): Promise { const socket = await connectRelay() currentSocket = socket return holdTunnel(socket) } /** Run ONE tunnel session; resolves when this session ends (dead heartbeat / close / GOAWAY). */ function runSession(tunnel: Tunnel, socket: WsLike): Promise { return new Promise((resolve) => { const router = createStreamRouter(cfg, tunnel, dialLb, identityTransform, logger) const heartbeat = createHeartbeat(tunnel, { timer }) tunnel.dispatchTo(router, heartbeat) let settled = false const endSession = (): void => { if (settled) return settled = true heartbeat.stop() resolve() } heartbeat.onDead(() => { logger.log('warn', 'heartbeat missed — tunnel presumed down, will reconnect') tunnel.close() endSession() }) socket.on('close', () => endSession()) socket.on('error', () => { tunnel.close() endSession() }) tunnel.onGoAway((reason) => { const action = applyGoAway(reason, revocation) // 'revoked' ⇒ no reconnect (INV12) logger.log('info', 'received GOAWAY', { action }) tunnel.close() endSession() }) heartbeat.start() }) } async function supervise(): Promise { while (!shouldStop()) { const tunnel = await reconnectLoop(dialTunnel, backoff, shouldStop, sleep) if (tunnel === null || currentSocket === null) break if (shouldStop()) { // stop()/revoke raced with the in-flight dial — discard the fresh tunnel. tunnel.close() break } currentTunnel = tunnel logger.log('info', 'relay tunnel established') await runSession(tunnel, currentSocket) currentTunnel = null currentSocket = null } return 0 } const done = supervise() return { async stop(): Promise { stopped = true currentTunnel?.close() await done }, done, } }