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).
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
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 {
|
||||
@@ -41,6 +42,14 @@ export type WebSocketCtor = new (url: string, protocols?: string | readonly stri
|
||||
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
|
||||
}
|
||||
@@ -71,15 +80,21 @@ export function createPassthroughTransport(
|
||||
): 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 — the frozen §4.3 order.
|
||||
const protocols: readonly string[] =
|
||||
token === undefined ? [APP_SUBPROTOCOL] : [APP_SUBPROTOCOL, encodeTokenSubprotocol(token)]
|
||||
// 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) => {
|
||||
@@ -88,9 +103,10 @@ export function createPassthroughTransport(
|
||||
ws = socket
|
||||
|
||||
socket.onopen = () => {
|
||||
// Echo rule (v0.9+): with a token attached, accept ONLY the app subprotocol back — a relay
|
||||
// echoing the token entry, an empty value, or a foreign one is rejected (bearer-leak guard).
|
||||
if (token !== undefined && socket.protocol !== APP_SUBPROTOCOL) {
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user