Files
web-terminal/relay-web/src/ws-transport.ts
Yaojia Wang aa1912b962 feat(relay): Phase1 waves A2-E — server entry, shared-store data plane, agent runtime, deploy artifacts
RELAY-PHASE1 Wave A2/B/C/D/E (12-agent workflow, all tsc-clean, 314/314 tests pass):
- A2: control-plane server.ts entry + boot/redis.ts revocation-bus wiring + start script.
- B1: relay-run shared-store EnforceDeps (relay-auth ports over the SAME Postgres+Redis as P3).
- B2: registry-backed MtlsVerifier (verifyAgentCert, fail-closed, INV14).
- B3: store-backed RouteResolver (subdomain->hostId).
- B4: Redis relay:revocations subscriber -> tunnel teardown (INV12).
- B5: main-phase1.ts production entry (public bind, real TLS, async-mTLS prefetch bridge) + staging /auth/mint.
- B6 (PARTIAL): relay-web operator login + browser DPoP; proof offered via term.dpop.<b64u> subprotocol.
- C: agent dist/cli.js build (esbuild) + runTunnel run-loop + CliDeps.
- D1: same-origin static serve of relay-web/public from the browser WSS.
- E: systemd units + gen-ca/gen-capability-key/issue-tls-cert scripts + deploy/RUNBOOK.md.
Adversarial review: all hard invariants PASS. Follow-ups (B7): close DPoP-subprotocol read on
browser-server (blocks browser connect); rate-limit /auth/mint (F1); wire activeSessionCount (F2);
scrub error logs (F5). Excludes unrelated public/style.css (concurrent iOS job).
2026-07-06 16:13:34 +02:00

146 lines
5.9 KiB
TypeScript

/**
* T4 — `TerminalTransport` seam + `PassthroughTransport` (v0.8, no E2E).
*
* The abstraction that lets browser-side E2E (T8) "drop in" without reshaping the view: both the
* passthrough and the E2E transport implement `TerminalTransport`, so `terminal-view.ts` never
* changes. In v0.8 there is NO capability token (INDEX §1) — the same-origin signed cookie (T3)
* authenticates the upgrade and the base-app Origin/CSWSH check (M6) is retained.
*
* Token attachment (v0.9+): the §4.3 capability token rides the `Sec-WebSocket-Protocol`
* subprotocol list (frozen §4.3 wire format via `encodeTokenSubprotocol`), NEVER the URL/query
* string (which would leak the bearer credential into proxy/access logs, history, and `Referer`).
* After open, the client asserts the echoed subprotocol is exactly `APP_SUBPROTOCOL` and tears the
* socket down otherwise (echo rule).
*/
import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts'
import type { RelayWebConfig } from './config'
import { encodeDpopSubprotocol } from './dpop'
/** The seam every transport implements — plaintext in the browser, encoded by the impl. */
export interface TerminalTransport {
open(): Promise<void>
send(bytes: Uint8Array): void
onMessage(cb: (bytes: Uint8Array) => void): void
onClose(cb: (reason: string) => void): void
close(): void
}
/** Minimal structural WebSocket surface (so tests can inject a mock, no jsdom WS dependency). */
export interface WebSocketLike {
binaryType: string
protocol: string
send(data: ArrayBufferView | ArrayBufferLike | string): void
close(code?: number, reason?: string): void
onopen: ((ev: unknown) => void) | null
onmessage: ((ev: { data: unknown }) => void) | null
onclose: ((ev: { code?: number; reason?: string }) => void) | null
onerror: ((ev: unknown) => void) | null
}
export type WebSocketCtor = new (url: string, protocols?: string | readonly string[]) => WebSocketLike
export interface PassthroughOpts {
/** v0.9+ populates this; ABSENT in v0.8 (cookie-only auth). */
readonly capabilityToken?: string
/**
* Phase-1 STAGING (B6): a DPoP proof-of-possession JWS bound to the token's `cnf.jkt`. Browsers
* can't set the `dpop` request header on a native WS upgrade, so — mirroring the §4.3 token — it
* rides an extra `term.dpop.<b64u>` subprotocol entry (see dpop.ts `encodeDpopSubprotocol`). The
* current relay ignores it (its `handleProtocols` echoes only `APP_SUBPROTOCOL`), so the handshake
* is unaffected; true DPoP enforcement needs the relay to read this entry (server-lane, VPS-gated).
*/
readonly dpopProof?: string
/** DI seam: inject a mock WebSocket constructor in tests; defaults to global `WebSocket`. */
readonly wsCtor?: WebSocketCtor
}
/** Map a WS close event to a stable reason string the UI can render (no silent swallow). */
function closeReason(ev: { code?: number; reason?: string }): string {
if (ev.code === 4403) return 'forbidden' // INV1/INV6 upgrade denial mirror
if (ev.code === 4401) return 'unauthenticated'
if (ev.reason && ev.reason.length > 0) return ev.reason
return 'closed'
}
/** Coerce a WS message payload (ArrayBuffer | ArrayBufferView | string) to bytes. */
function toBytes(data: unknown): Uint8Array {
if (data instanceof Uint8Array) return data
if (data instanceof ArrayBuffer) return new Uint8Array(data)
if (ArrayBuffer.isView(data)) {
const view = data as ArrayBufferView
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
}
if (typeof data === 'string') return new TextEncoder().encode(data)
return new Uint8Array(0)
}
export function createPassthroughTransport(
cfg: RelayWebConfig,
opts: PassthroughOpts = {},
): TerminalTransport {
const Ctor: WebSocketCtor = opts.wsCtor ?? (globalThis.WebSocket as unknown as WebSocketCtor)
const token = opts.capabilityToken
const dpopProof = opts.dpopProof
const hasBearer = token !== undefined || dpopProof !== undefined
const url = cfg.wsUrl('/term') // SAME-ORIGIN, scheme-following; NEVER carries a token (T4 §)
let ws: WebSocketLike | null = null
let messageCb: ((bytes: Uint8Array) => void) | null = null
let closeCb: ((reason: string) => void) | null = null
// App subprotocol FIRST; token entry (v0.9+) second; DPoP proof entry (Phase-1 B6) last — a
// bearer NEVER touches the URL/query (would leak it to proxy/access logs, history, and Referer).
const protocols: readonly string[] = [
APP_SUBPROTOCOL,
...(token === undefined ? [] : [encodeTokenSubprotocol(token)]),
...(dpopProof === undefined ? [] : [encodeDpopSubprotocol(dpopProof)]),
]
function open(): Promise<void> {
return new Promise((resolve, reject) => {
const socket = new Ctor(url, protocols)
socket.binaryType = 'arraybuffer'
ws = socket
socket.onopen = () => {
// Echo rule (v0.9+): with a bearer attached (token and/or DPoP), accept ONLY the app
// subprotocol back — a relay echoing a bearer entry, an empty value, or a foreign one is
// rejected (bearer-leak guard).
if (hasBearer && socket.protocol !== APP_SUBPROTOCOL) {
socket.close(4400, 'bad-subprotocol')
reject(new Error(`unexpected echoed subprotocol: '${socket.protocol}'`))
return
}
resolve()
}
socket.onmessage = (ev) => {
if (messageCb) messageCb(toBytes(ev.data))
}
socket.onclose = (ev) => {
if (closeCb) closeCb(closeReason(ev))
}
socket.onerror = () => {
// Surface as a close so callers never hang; open() rejection is handled by onclose too.
reject(new Error('websocket error'))
}
})
}
return {
open,
send(bytes: Uint8Array): void {
if (!ws) throw new Error('transport not open')
ws.send(bytes)
},
onMessage(cb: (bytes: Uint8Array) => void): void {
messageCb = cb
},
onClose(cb: (reason: string) => void): void {
closeCb = cb
},
close(): void {
if (ws) ws.close()
},
}
}