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:
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)
|
||||
})
|
||||
Reference in New Issue
Block a user