/** * Phase 1 PRODUCTION entrypoint — `npm run start:phase1`. Composes the SHARED-STORE data plane * (B1–B4) behind a publicly-bound TLS listener, serves the relay-web bundle same-origin as the * browser WSS (D1), and hosts a STAGING operator token-mint (`POST /auth/mint`, B5/auth-mint.ts). * * Unlike Phase-0 `main.ts` (in-RAM fakes + self-signed dev CA), everything here is real and * env-configured — one world of truth over the SAME Postgres + Redis as the control-plane (INV7, * restart-safe): the host registry that gates mTLS (INV14) and route resolution, the Redis * revocation bus that tears live tunnels down (INV12), and the shared P5 verify key (INV9). * * browser ──WSS(:BIND_PORT)──▶ relay-node ──opaque splice(INV2)──▶ agent tunnel ◀──mTLS(:AGENT_BIND_PORT)── agent * │ P5 onUpgrade: Origin/CSWSH + capability verify + DPoP │ registry-gated verifyAgentCert * └ same-origin: static bundle (D1) + POST /auth/mint (B5) └ Redis relay:revocations → teardown * * All configuration is from ENV (no hardcoded hosts/ports/secrets). Phase-0 `main.ts` is UNTOUCHED. */ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { loadVerifyKeyFromEnv, KeyConfigError } from 'relay-auth/src/config/keys.js' import { createPgPool, createQuery } from 'control-plane/src/db/pool.js' import { createPgStores } from 'control-plane/src/store/pg.js' import { createRedisClient } from 'control-plane/src/boot/redis.js' import type { MtlsVerifier, TlsServerFactory, } from 'term-relay/data-plane/agent-listener.js' import { createRelayEnforceDeps } from './wiring/stores-pg.js' import { createMtlsVerifier, type AsyncMtlsVerifier } from './wiring/mtls-verifier.js' import { createStoreRouteResolver } from './wiring/route-resolver.js' import { startRevocationSubscriber, type RevocableNode } from './wiring/revocation-subscriber.js' import { createAuthorizer } from './wiring/authorizer.js' import { buildDataPlane, makeDataPlaneConfig } from './wiring/data-plane.js' import { makeAgentTlsServerFactory } from './servers/agent-tls.js' import { startBrowserServer } from './servers/browser-server.js' import { createAuthMintRoute, loadSigningKeyFromEnv } from './servers/auth-mint.js' const DEFAULT_BIND_HOST = '0.0.0.0' const DEFAULT_BIND_PORT = 443 const HERE = dirname(fileURLToPath(import.meta.url)) // /relay-run/src const DEFAULT_WEB_ROOT = join(HERE, '..', '..', 'relay-web', 'public') // ── env helpers (fail-fast on misconfiguration) ───────────────────────────────────────────────── function requireEnv(name: string): string { const v = process.env[name] if (v === undefined || v.length === 0) { throw new KeyConfigError(`required env ${name} is not set`) } return v } function requirePort(name: string): number { const raw = requireEnv(name) const n = Number(raw) if (!Number.isInteger(n) || n < 1 || n > 65535) { throw new KeyConfigError(`env ${name} must be an integer port 1–65535 (got ${JSON.stringify(raw)})`) } return n } function intEnv(name: string, fallback: number): number { const raw = process.env[name] if (raw === undefined || raw.length === 0) return fallback const n = Number(raw) if (!Number.isInteger(n) || n < 1 || n > 65535) { throw new KeyConfigError(`env ${name} must be an integer port 1–65535 (got ${JSON.stringify(raw)})`) } return n } /** * F5/INV9 · render an error for logs WITHOUT leaking the raw object. A raw PG/Redis error can carry * the connection DSN (with password) in its properties, so we log only `.message` (+ `.code` when * present, e.g. 'ECONNREFUSED') and never the object itself. */ function errText(e: unknown): string { if (e instanceof Error) { const code = (e as { code?: unknown }).code return code === undefined ? e.message : `${e.message} (code=${String(code)})` } return String(e) } // ── async mTLS → sync-slot bridge ─────────────────────────────────────────────────────────────── interface MtlsBridge { /** Sync `MtlsVerifier` for the data plane; reads the pre-computed verdict for this connection. */ readonly sync: MtlsVerifier /** Wrap the real TLS factory so each peer is registry-verified (async) BEFORE `attach` runs. */ wrap(base: TlsServerFactory): TlsServerFactory } /** * term-relay's `MtlsVerifier.verifyPeer` is SYNC, but a registry-backed verifier (B2) is inherently * async (Postgres lookup) — the ASYNC IMPEDANCE flagged in mtls-verifier.ts. We bridge it WITHOUT * editing term-relay (outside our lane) by doing the async verify in the TLS `onPeer` hook and * caching the verdict keyed by the peer's DER, which the sync `verifyPeer` (called synchronously by * `attach`, immediately after `onPeer` fires) then reads. The set→onPeer→get sequence runs * synchronously inside one `.then` callback, so a single-slot cache per DER is race-free. Fail-closed * throughout: a rejected/failed verify caches `null`, so `attach` closes the peer with 4401 (INV14). */ function bridgeAsyncMtls( asyncMtls: AsyncMtlsVerifier, onError: (e: unknown) => void, ): MtlsBridge { const pending = new Map() const keyOf = (der: Uint8Array): string => Buffer.from(der).toString('base64') const sync: MtlsVerifier = { verifyPeer(peerCert) { const k = keyOf(peerCert) const verdict = pending.get(k) ?? null pending.delete(k) // one-shot: consumed by the attach() that triggered this onPeer return verdict }, } const wrap = (base: TlsServerFactory): TlsServerFactory => (opts, onPeer) => base(opts, (ws, der) => { asyncMtls .verifyPeer(der) .then((verdict) => { pending.set(keyOf(der), verdict) onPeer(ws, der) // sync attach() → sync.verifyPeer(der) reads + consumes the verdict }) .catch((e: unknown) => { onError(e) pending.set(keyOf(der), null) // fail-closed → attach() closes 4401 onPeer(ws, der) }) }) return { sync, wrap } } // ── boot ──────────────────────────────────────────────────────────────────────────────────────── async function main(): Promise { const now = (): number => Math.floor(Date.now() / 1000) // Config (fail-fast; secrets are read but never logged — INV9). const bindHost = process.env.BIND_HOST || DEFAULT_BIND_HOST const bindPort = intEnv('BIND_PORT', DEFAULT_BIND_PORT) const agentBindPort = requirePort('AGENT_BIND_PORT') const tlsCertPath = requireEnv('TLS_CERT_PATH') const tlsKeyPath = requireEnv('TLS_KEY_PATH') const agentServerCertPath = requireEnv('AGENT_SERVER_CERT_PATH') const agentServerKeyPath = requireEnv('AGENT_SERVER_KEY_PATH') const agentCaCertPath = requireEnv('AGENT_CA_CERT_PATH') const agentCaChainPath = requireEnv('AGENT_CA_CHAIN_PATH') const baseDomain = requireEnv('BASE_DOMAIN') const relayNodeId = requireEnv('RELAY_NODE_ID') const trustDomain = requireEnv('RELAY_TRUST_DOMAIN') const allowedOrigins = requireEnv('ALLOWED_ORIGINS') .split(',') .map((o) => o.trim()) .filter((o) => o.length > 0) if (allowedOrigins.length === 0) { throw new KeyConfigError('ALLOWED_ORIGINS must contain at least one origin (CSWSH exact-match)') } const pgUrl = requireEnv('PG_URL') const redisUrl = requireEnv('REDIS_URL') const webRoot = process.env.WEB_ROOT || DEFAULT_WEB_ROOT // Shared P5 verify key (RELAY_AUTH_VERIFY_PUBKEY) — configured process-wide, never logged (INV9). await loadVerifyKeyFromEnv() // Shared stores: SAME Postgres + Redis as the control-plane (INV7). const pool = createPgPool(pgUrl) const query = createQuery(pool) const stores = createPgStores(query) const redis = createRedisClient(redisUrl) const redisSubscriber = createRedisClient(redisUrl) // dedicated subscriber-mode connection const deps = createRelayEnforceDeps({ query, redis }) const resolver = createStoreRouteResolver({ hosts: stores.hosts }) const asyncMtls = createMtlsVerifier({ caChainPem: readFileSync(agentCaChainPath, 'utf8'), hosts: deps.hosts, now, onError: (e) => console.error('[mtls-verify]', errText(e)), }) const mtlsBridge = bridgeAsyncMtls(asyncMtls, (e) => console.error('[mtls-bridge]', errText(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]', errText(e)), }) const dp = buildDataPlane({ config, authorizer, resolver, mtls: mtlsBridge.sync, now, // Agent-TLS trust store for validating the agent's CLIENT leaf (requestCert+rejectUnauthorized). // MUST be the FULL chain (intermediate + self-signed root): Node/OpenSSL rejects a leaf whose only // anchor is a non-self-signed intermediate (no PARTIAL_CHAIN flag). Intermediate-only ⇒ every agent // is reset at the TLS layer before attach() runs. caBundle: [readFileSync(agentCaChainPath)], onError: (e) => console.error('[data-plane]', errText(e)), tlsServerFactory: mtlsBridge.wrap(agentTlsFactory), }) // STAGING operator token-mint (B5). Enabled only when BOTH the password gate and the signing key // are configured; otherwise the endpoint stays off (fail-closed) and static-only mode serves. const operatorPassword = process.env.OPERATOR_PASSWORD ?? '' const signPrivRaw = process.env.CAPABILITY_SIGN_PRIVKEY ?? '' let onRequest: ReturnType | undefined let mintEnabled = false if (operatorPassword.length > 0 && signPrivRaw.length > 0) { const signingKey = await loadSigningKeyFromEnv(signPrivRaw) // F1: the public :443 mint MUST be rate-limited (brute-force of OPERATOR_PASSWORD → full shell). // Reuse the shared Redis token bucket keyed on a salted hash of the client IP (raw IP never stored). const mintRateSalt = process.env.MINT_RATE_SALT || 'relay-run-mint-rate-salt' onRequest = createAuthMintRoute({ signingKey, hosts: stores.hosts, operatorPassword, now, rateLimit: { buckets: deps.buckets, salt: mintRateSalt }, onError: (e) => console.error('[auth-mint]', errText(e)), }) mintEnabled = true } else { console.warn( '[auth-mint] STAGING mint disabled — set OPERATOR_PASSWORD and CAPABILITY_SIGN_PRIVKEY to enable POST /auth/mint', ) } const browserServer = startBrowserServer({ certPath: tlsCertPath, keyPath: tlsKeyPath, bindHost, bindPort, node: dp.node, landingHtml: 'relay', // unused when staticRoot is set staticRoot: webRoot, ...(onRequest ? { onRequest } : {}), onListening: () => console.log(`[browser-wss] listening https://${bindHost}:${bindPort}`), onError: (e) => console.error('[browser-wss]', errText(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]', errText(e)), }) console.log('\n=== relay-run Phase 1 READY ===') console.log(`Base domain : ${baseDomain} trustDomain: ${trustDomain} node: ${relayNodeId}`) console.log(`Browser WSS : https://${bindHost}:${bindPort} (static root: ${webRoot})`) console.log(`Agent mTLS : wss://${bindHost}:${agentBindPort}`) console.log(`Allowed origins : ${allowedOrigins.join(', ')}`) console.log(`Operator mint : ${mintEnabled ? 'ENABLED (STAGING /auth/mint)' : 'disabled'}`) console.log('Ctrl-C to stop.\n') let shuttingDown = false const shutdown = async (): Promise => { if (shuttingDown) return shuttingDown = true console.log('\nshutting down…') try { revsub.close() browserServer.close() dp.listener.close() await Promise.allSettled([redis.quit(), redisSubscriber.quit(), pool.end()]) } catch (e) { console.error('[shutdown]', errText(e)) } finally { process.exit(0) } } process.on('SIGINT', () => void shutdown()) process.on('SIGTERM', () => void shutdown()) } main().catch((e) => { console.error('fatal:', errText(e)) process.exit(1) })