feat(relay): B7 — close browser DPoP-subprotocol loop + harden staging mint

- Read DPoP proof from term.dpop.<b64u> WS subprotocol (browsers can't set WS headers);
  header wins, else subprotocol. Fail-closed decoder. Unblocks real browser connect.
- F1: rate-limit /auth/mint per-IP via Redis token bucket (salted-hash key, 429 on burst,
  before password compare).
- F2: wire real per-tenant active WS count into activeSessionCount (was hardcoded 0).
- F5: scrub error logs to e.message/.code (no DSN leak, INV9).
relay-run: tsc clean, 92 tests pass (+18). F3/F4 -> Phase 2 backlog.
This commit is contained in:
Yaojia Wang
2026-07-06 16:26:05 +02:00
parent aa1912b962
commit bfe1be1dfe
8 changed files with 378 additions and 17 deletions

View File

@@ -71,6 +71,19 @@ function intEnv(name: string, fallback: number): number {
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 {
@@ -169,9 +182,9 @@ async function main(): Promise<void> {
caChainPem: readFileSync(agentCaChainPath, 'utf8'),
hosts: deps.hosts,
now,
onError: (e) => console.error('[mtls-verify]', e),
onError: (e) => console.error('[mtls-verify]', errText(e)),
})
const mtlsBridge = bridgeAsyncMtls(asyncMtls, (e) => console.error('[mtls-bridge]', e))
const mtlsBridge = bridgeAsyncMtls(asyncMtls, (e) => console.error('[mtls-bridge]', errText(e)))
const authorizer = createAuthorizer({ deps, allowedOrigins, now })
@@ -183,7 +196,7 @@ async function main(): Promise<void> {
bindHost,
bindPort: agentBindPort,
onListening: () => console.log(`[agent-mtls] listening wss://${bindHost}:${agentBindPort}`),
onError: (e) => console.error('[agent-mtls]', e),
onError: (e) => console.error('[agent-mtls]', errText(e)),
})
const dp = buildDataPlane({
@@ -193,7 +206,7 @@ async function main(): Promise<void> {
mtls: mtlsBridge.sync,
now,
caBundle: [readFileSync(agentCaCertPath)],
onError: (e) => console.error('[data-plane]', e),
onError: (e) => console.error('[data-plane]', errText(e)),
tlsServerFactory: mtlsBridge.wrap(agentTlsFactory),
})
@@ -205,12 +218,16 @@ async function main(): Promise<void> {
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,
onError: (e) => console.error('[auth-mint]', e),
rateLimit: { buckets: deps.buckets, salt: mintRateSalt },
onError: (e) => console.error('[auth-mint]', errText(e)),
})
mintEnabled = true
} else {
@@ -229,7 +246,7 @@ async function main(): Promise<void> {
staticRoot: webRoot,
...(onRequest ? { onRequest } : {}),
onListening: () => console.log(`[browser-wss] listening https://${bindHost}:${bindPort}`),
onError: (e) => console.error('[browser-wss]', e),
onError: (e) => console.error('[browser-wss]', errText(e)),
})
// INV12: a Redis relay:revocations kill-signal tears matching live tunnel(s) down on this node.
@@ -245,7 +262,7 @@ async function main(): Promise<void> {
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),
onError: (e) => console.error('[revocation]', errText(e)),
})
console.log('\n=== relay-run Phase 1 READY ===')
@@ -267,7 +284,7 @@ async function main(): Promise<void> {
dp.listener.close()
await Promise.allSettled([redis.quit(), redisSubscriber.quit(), pool.end()])
} catch (e) {
console.error('[shutdown]', e)
console.error('[shutdown]', errText(e))
} finally {
process.exit(0)
}
@@ -277,6 +294,6 @@ async function main(): Promise<void> {
}
main().catch((e) => {
console.error('fatal:', e instanceof Error ? e.message : e)
console.error('fatal:', errText(e))
process.exit(1)
})