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