- 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.
140 lines
6.7 KiB
TypeScript
140 lines
6.7 KiB
TypeScript
/**
|
|
* Browser-facing WSS server for `main.ts`. Terminates HTTPS on the tenant origin and, on WS
|
|
* upgrade, builds the P1 `UpgradeRequest` from the raw request headers and hands it to the REAL
|
|
* relay-node (→ real `authorizeUpgrade` → P5 `onUpgrade`: Origin/CSWSH + capability verify + DPoP).
|
|
* The handshake echoes ONLY `APP_SUBPROTOCOL` — never the token entry (INV15).
|
|
*/
|
|
import { readFileSync } from 'node:fs'
|
|
import { createServer, type Server } from 'node:https'
|
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
import { WebSocketServer, type WebSocket as WsWebSocket } from 'ws'
|
|
import { APP_SUBPROTOCOL } from 'relay-contracts'
|
|
import type { UpgradeRequest } from 'term-relay/data-plane/upgrade.js'
|
|
import type { RelayNode } from 'term-relay/data-plane/relay-node.js'
|
|
import { wsToWebSocketLike } from '../wiring/socket-pipe.js'
|
|
import { serveStatic } from './static-web.js'
|
|
import { extractDpopProofFromSubprotocols } from './dpop-subprotocol.js'
|
|
|
|
export interface BrowserServerOptions {
|
|
readonly certPath: string
|
|
readonly keyPath: string
|
|
readonly bindHost: string
|
|
readonly bindPort: number
|
|
readonly node: RelayNode
|
|
readonly landingHtml: string
|
|
/**
|
|
* When set, the HTTP handler serves the built relay-web bundle from this directory (D1), SAME
|
|
* ORIGIN as the WSS (so Origin/CSP stay aligned). When unset, `landingHtml` is served — the
|
|
* Phase-0 dev fallback. The WS upgrade is unaffected either way (it rides the `upgrade` event).
|
|
*/
|
|
readonly staticRoot?: string
|
|
/**
|
|
* Optional pre-router (B5): consulted BEFORE `staticRoot`/`landingHtml` on every non-upgrade HTTP
|
|
* request. Return `true` to claim the request (the hook owns the response — it may finish it
|
|
* asynchronously); return `false` to fall through to the static/landing behavior below. Default
|
|
* (undefined) preserves D1's behavior exactly. WS upgrades never reach this hook (they ride the
|
|
* `upgrade` event), so same-origin `POST /auth/mint` can coexist with the WSS.
|
|
*/
|
|
readonly onRequest?: (req: IncomingMessage, res: ServerResponse) => boolean
|
|
readonly onListening?: () => void
|
|
readonly onError?: (e: unknown) => void
|
|
}
|
|
|
|
function parseCookies(header: string | undefined): Record<string, string> {
|
|
const out: Record<string, string> = {}
|
|
if (!header) return out
|
|
for (const part of header.split(';')) {
|
|
const idx = part.indexOf('=')
|
|
if (idx === -1) continue
|
|
out[part.slice(0, idx).trim()] = part.slice(idx + 1).trim()
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Build the P1 `UpgradeRequest` from the raw upgrade request.
|
|
*
|
|
* DPoP transport (B7): the `dpop` REQUEST HEADER is read first (a proxy or native client MAY set it),
|
|
* but a browser's native WebSocket API cannot set headers, so relay-web offers the proof as an extra
|
|
* `term.dpop.<b64u>` subprotocol entry. The header WINS when both are present; otherwise the proof
|
|
* falls back to the subprotocol (fail-closed → null when absent/malformed). htu/htm are NOT set here:
|
|
* the authorizer re-derives them from the request's own resolved authority (`expectedAud`), never from
|
|
* client-supplied claims — so both transports feed the SAME downstream DPoP binding.
|
|
*
|
|
* `activeSessionCount` is supplied by the caller (the live per-tenant connection count, F2).
|
|
*/
|
|
export function buildUpgradeRequest(req: IncomingMessage, activeSessionCount: number): UpgradeRequest {
|
|
const proto = req.headers['sec-websocket-protocol']
|
|
const subprotocols = (typeof proto === 'string' ? proto.split(',') : [])
|
|
.map((v) => v.trim())
|
|
.filter((v) => v.length > 0)
|
|
const dpopHeader = req.headers['dpop']
|
|
const headerProof = typeof dpopHeader === 'string' && dpopHeader.length > 0 ? dpopHeader : null
|
|
const proof = headerProof ?? extractDpopProofFromSubprotocols(subprotocols)
|
|
return {
|
|
host: req.headers.host ?? '',
|
|
origin: typeof req.headers.origin === 'string' ? req.headers.origin : undefined,
|
|
url: req.url ?? '/',
|
|
subprotocols,
|
|
cookies: parseCookies(req.headers.cookie),
|
|
remoteAddr: req.socket.remoteAddress ?? '',
|
|
dpop: { proof, publicKeyThumbprint: null },
|
|
activeSessionCount,
|
|
}
|
|
}
|
|
|
|
export function startBrowserServer(opts: BrowserServerOptions): Server {
|
|
const server = createServer(
|
|
{ cert: readFileSync(opts.certPath), key: readFileSync(opts.keyPath) },
|
|
(req: IncomingMessage, res: ServerResponse) => {
|
|
// Pre-router (B5): a claimed request is fully owned by the hook (e.g. POST /auth/mint) and
|
|
// must NOT fall through to static/landing (which would double-write the response).
|
|
if (opts.onRequest !== undefined && opts.onRequest(req, res)) return
|
|
// Static-bundle mode (Phase 1): serve relay-web from `staticRoot`, SAME-ORIGIN as the WSS.
|
|
// Non-upgrade HTTP requests only — WS upgrades never reach this handler (see `upgrade` event).
|
|
if (opts.staticRoot !== undefined) {
|
|
const file = serveStatic(opts.staticRoot, req.url ?? '/')
|
|
if (file) {
|
|
res.writeHead(file.status, file.headers)
|
|
res.end(file.body)
|
|
} else {
|
|
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
res.end('Not Found')
|
|
}
|
|
return
|
|
}
|
|
// Phase-0 fallback: a single landing page.
|
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
res.end(opts.landingHtml)
|
|
},
|
|
)
|
|
const wss = new WebSocketServer({
|
|
server,
|
|
handleProtocols: (protocols: Set<string>) =>
|
|
protocols.has(APP_SUBPROTOCOL) ? APP_SUBPROTOCOL : false,
|
|
})
|
|
// F2: best-effort concurrent-session count per tenant. The relay-node keeps its browser↔agent
|
|
// splice index PRIVATE (no public per-host stream count), so we approximate the account's "active
|
|
// sessions" with the number of browser WS connections currently open to the SAME tenant origin
|
|
// (single-tenant staging ⇒ subdomain↔host/account is 1:1). We pass the count of the OTHER live
|
|
// connections (this one excluded) so P5's `checkConcurrentSessions` sees only prior sessions. This
|
|
// over-counts unauthorized/failing connects until they close, which fails SAFE (toward the cap).
|
|
const liveByTenant = new Map<string, number>()
|
|
wss.on('connection', (ws: WsWebSocket, req: IncomingMessage) => {
|
|
const tenantKey = req.headers.host ?? ''
|
|
const priorCount = liveByTenant.get(tenantKey) ?? 0
|
|
liveByTenant.set(tenantKey, priorCount + 1)
|
|
ws.once('close', () => {
|
|
const next = (liveByTenant.get(tenantKey) ?? 1) - 1
|
|
if (next <= 0) liveByTenant.delete(tenantKey)
|
|
else liveByTenant.set(tenantKey, next)
|
|
})
|
|
opts.node
|
|
.handleBrowserUpgrade(buildUpgradeRequest(req, priorCount), wsToWebSocketLike(ws))
|
|
.catch((e) => opts.onError?.(e))
|
|
})
|
|
server.on('error', (e) => opts.onError?.(e))
|
|
server.listen(opts.bindPort, opts.bindHost, () => opts.onListening?.())
|
|
return server
|
|
}
|