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:
Yaojia Wang
2026-07-06 16:13:34 +02:00
parent 95b9cccf07
commit aa1912b962
40 changed files with 4783 additions and 19 deletions

View File

@@ -0,0 +1,282 @@
/**
* Phase 1 PRODUCTION entrypoint — `npm run start:phase1`. Composes the SHARED-STORE data plane
* (B1B4) 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 165535 (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 165535 (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)
})