feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)
Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.
Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay: native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent: host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e: browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth: Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web: browser login + Web Crypto E2E + client-side preview rendering
Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.
NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
This commit is contained in:
129
relay-web/src/ws-transport.ts
Normal file
129
relay-web/src/ws-transport.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
/** 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
|
||||
/** 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 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)]
|
||||
|
||||
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 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) {
|
||||
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()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user