Files
web-terminal/relay-run/src/servers/browser-server.ts
Yaojia Wang ba85871227 feat(relay-run): Phase 0 single-host relay — real data-plane wiring + integration test
New relay-run/ package boots the REAL term-relay relay-node between a browser WSS
listener and an agent mTLS listener, delegating every authz verdict to the real
relay-auth onUpgrade (Origin/CSWSH + capability verify + DPoP PoP + single-use jti),
with the real P4 E2E crypto. No audited package modified.

- P1 (done): in-process integration test round-trips a sealed payload both ways
  through the real createRelayNode splice + real createMuxSession; INV2 asserted
  (relay sees only ciphertext); negative controls (foreign Origin / missing DPoP -> 401).
- P2 (boots): main.ts brings up self-signed TLS + 3 listeners + in-memory control-plane.
- P3 (not landed): no agent dial / node-pty / relay-web serve yet.
- 4 tests green, tsc clean. node_modules via symlinks (gitignored).

Two real seam drifts surfaced (documented in PLAN_RELAY_RUN_PHASE0, not hacked):
(1) term-relay authz-port DpopContext shape vs relay-auth — reconciled (server-derived htu/htm);
(2) control-plane CapabilityVerifier.verify is sync but relay-auth verify is async — blocks
    HTTP account/pairing seeding until a 1-line async widen.
2026-07-04 14:13:04 +02:00

78 lines
3.0 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'
export interface BrowserServerOptions {
readonly certPath: string
readonly keyPath: string
readonly bindHost: string
readonly bindPort: number
readonly node: RelayNode
readonly landingHtml: string
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
}
function buildUpgradeRequest(req: IncomingMessage): 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']
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: typeof dpopHeader === 'string' ? dpopHeader : null, publicKeyThumbprint: null },
activeSessionCount: 0,
}
}
export function startBrowserServer(opts: BrowserServerOptions): Server {
const server = createServer(
{ cert: readFileSync(opts.certPath), key: readFileSync(opts.keyPath) },
(_req: IncomingMessage, res: ServerResponse) => {
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,
})
wss.on('connection', (ws: WsWebSocket, req: IncomingMessage) => {
opts.node
.handleBrowserUpgrade(buildUpgradeRequest(req), wsToWebSocketLike(ws))
.catch((e) => opts.onError?.(e))
})
server.on('error', (e) => opts.onError?.(e))
server.listen(opts.bindPort, opts.bindHost, () => opts.onListening?.())
return server
}