Node/OpenSSL rejects an agent client leaf whose only trust anchor is a non-self-signed intermediate (no PARTIAL_CHAIN flag) → every agent was reset at the TLS layer before attach(), surfacing as a silent 1006. Pass the full agent-ca bundle and stop swallowing tlsClientError.
304 lines
13 KiB
TypeScript
304 lines
13 KiB
TypeScript
/**
|
||
* 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
|
||
}
|
||
|
||
/**
|
||
* 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<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]', 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<typeof createAuthMintRoute> | 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: '<!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]', 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<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]', 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)
|
||
})
|