Files
web-terminal/relay-run/src/main-phase1.ts
Yaojia Wang 89678c7949 fix(relay-run): agent-TLS ca must be full chain (intermediate+root), not intermediate-only
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.
2026-07-07 04:49:38 +02:00

304 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
}
/**
* 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)
})