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).
100 lines
4.0 KiB
TypeScript
100 lines
4.0 KiB
TypeScript
/**
|
|
* A2 — P3 control-plane server entrypoint. Wires validated env → Postgres pool/stores → migrations →
|
|
* ioredis revocation bus → real capability verifier → Fastify app, then binds the ADMIN provisioning
|
|
* API to a LOOPBACK host by default (CP_BIND_HOST). This process owns no terminal/PTY state, so
|
|
* PTY!=WS restart-safety (INV7) is unaffected: it only serves provisioning/pairing/revocation.
|
|
*
|
|
* Config is env-only (no hardcoded hosts/ports/secrets): control-plane secrets via `loadEnv`
|
|
* (fail-fast, never echoes values — INV9); bind address via CP_BIND_HOST / CP_BIND_PORT.
|
|
* accountId is only ever derived from the authenticated capability token inside buildControlPlane
|
|
* (INV3) — this entrypoint never fabricates identity.
|
|
*/
|
|
import { loadEnv } from './env.js'
|
|
import { createPgPool, createQuery } from './db/pool.js'
|
|
import { createPgStores } from './store/pg.js'
|
|
import { runMigrations } from './db/migrate.js'
|
|
import { createRedisRevocationBus } from './routing/bus.js'
|
|
import { createCapabilityVerifier, configureCapabilityVerifyKey } from './boot/verifier.js'
|
|
import { createRedisClient, createRedisPublisher } from './boot/redis.js'
|
|
import { buildControlPlane } from './main.js'
|
|
|
|
/** Admin API binds to loopback by default — never expose the provisioning API on a public interface. */
|
|
const DEFAULT_CP_BIND_HOST = '127.0.0.1'
|
|
const DEFAULT_CP_BIND_PORT = 8080
|
|
const MAX_TCP_PORT = 65535
|
|
|
|
function resolveBindHost(source: NodeJS.ProcessEnv): string {
|
|
const raw = source.CP_BIND_HOST?.trim()
|
|
return raw === undefined || raw === '' ? DEFAULT_CP_BIND_HOST : raw
|
|
}
|
|
|
|
function resolveBindPort(source: NodeJS.ProcessEnv): number {
|
|
const raw = source.CP_BIND_PORT?.trim()
|
|
if (raw === undefined || raw === '') return DEFAULT_CP_BIND_PORT
|
|
const port = Number(raw)
|
|
if (!Number.isInteger(port) || port < 1 || port > MAX_TCP_PORT) {
|
|
throw new Error('Invalid CP_BIND_PORT: must be an integer 1-65535')
|
|
}
|
|
return port
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const env = loadEnv(process.env)
|
|
|
|
// Postgres: parameterized-only query wrapper + PG-backed repository ports, migrations applied
|
|
// idempotently at boot.
|
|
const pool = createPgPool(env.pgUrl)
|
|
const query = createQuery(pool)
|
|
const stores = createPgStores(query)
|
|
await runMigrations(query)
|
|
|
|
// Redis: single client drives the revocation-bus publish side (relay:revocations).
|
|
const redis = createRedisClient(env.redisUrl)
|
|
const bus = createRedisRevocationBus(createRedisPublisher(redis))
|
|
|
|
// Capability verifier: load the §4.3 verifying key into relay-auth's registry BEFORE serving,
|
|
// then delegate to relay-auth's async verifyCapabilityToken (INV3).
|
|
await configureCapabilityVerifyKey(env.capabilitySignPubkey)
|
|
const verifier = createCapabilityVerifier()
|
|
|
|
const { app } = await buildControlPlane(env, { stores, bus, verifier })
|
|
|
|
const host = resolveBindHost(process.env)
|
|
const port = resolveBindPort(process.env)
|
|
|
|
let shuttingDown = false
|
|
const shutdown = async (signal: string): Promise<void> => {
|
|
if (shuttingDown) return
|
|
shuttingDown = true
|
|
process.stdout.write(`control-plane received ${signal}, shutting down\n`)
|
|
try {
|
|
await app.close()
|
|
} finally {
|
|
// Best-effort Redis close; force-disconnect if a graceful QUIT cannot complete.
|
|
await redis.quit().catch(() => redis.disconnect())
|
|
await pool.end()
|
|
}
|
|
}
|
|
|
|
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
process.once(signal, () => {
|
|
void shutdown(signal).then(
|
|
() => process.exit(0),
|
|
(err: unknown) => {
|
|
process.stderr.write(`shutdown failed: ${err instanceof Error ? err.message : String(err)}\n`)
|
|
process.exit(1)
|
|
},
|
|
)
|
|
})
|
|
}
|
|
|
|
await app.listen({ host, port })
|
|
// Startup breadcrumb — bind address only, never secrets (INV9).
|
|
process.stdout.write(`control-plane listening on ${host}:${port}\n`)
|
|
}
|
|
|
|
main().catch((err: unknown) => {
|
|
process.stderr.write(`control-plane failed to start: ${err instanceof Error ? err.message : String(err)}\n`)
|
|
process.exit(1)
|
|
})
|