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.
125 lines
5.4 KiB
TypeScript
125 lines
5.4 KiB
TypeScript
/**
|
|
* T8 · Upgrade authorization edge — a THIN ADAPTER over P5 (FIX 6a). Holds NO independent authz
|
|
* logic: it (a) parses the upgrade (subdomain + FIX-5 token extraction + salted remoteAddr hash),
|
|
* (b) resolves the subdomain → requestedHostId via P3's RouteResolver, (c) builds P5's
|
|
* UpgradeContext, (d) calls the injected P5 Authorizer, (e) maps AuthzOutcome → §4.1 MuxOpen or a
|
|
* 401/403. Every accept/reject verdict is P5's; the only LOCAL failures are the three parse guards.
|
|
*
|
|
* Security: capability token rides ONLY the two FIX-5 carriers (subprotocol PREFERRED, cookie
|
|
* FALLBACK) — NEVER the query string (bearer-equivalent secrets must not leak into logs/history/
|
|
* Referer). The accepted subprotocol echoed back is ALWAYS `APP_SUBPROTOCOL`, never the token
|
|
* entry (INV15). Identity (`host_id`/`account_id`) derives only from the signed token + registry
|
|
* via P5 (INV3). Logging discipline (INV9): callers MUST NOT log req.url / Authorization /
|
|
* Sec-WebSocket-Protocol — only `{subdomain, hostId, jti, decision, ts}`.
|
|
*/
|
|
import { createHmac } from 'node:crypto'
|
|
import { APP_SUBPROTOCOL, extractTokenFromSubprotocols, type MuxOpen, type CapabilityRight } from 'relay-contracts'
|
|
import { extractSubdomain, type RouteResolver } from './subdomain-router.js'
|
|
import type { Authorizer, UpgradeContext } from './authz-port.js'
|
|
|
|
/** Short-lived HttpOnly; Secure; SameSite=Strict cookie carrying the capability token (fallback). */
|
|
export const TOKEN_COOKIE_NAME = 'term_cap'
|
|
|
|
export interface UpgradeRequest {
|
|
readonly host: string
|
|
readonly origin: string | undefined
|
|
readonly url: string // requestPath ONLY (opaque passthrough) — NEVER a token source
|
|
readonly subprotocols: readonly string[] // parsed Sec-WebSocket-Protocol values (FIX-5 transport)
|
|
readonly cookies: Readonly<Record<string, string>> // parsed Cookie header (fallback transport)
|
|
readonly remoteAddr: string
|
|
readonly dpop: UpgradeContext['dpop']
|
|
readonly activeSessionCount: number
|
|
readonly sessionId?: string // present ⇒ reattach path (→ onReattach)
|
|
}
|
|
|
|
export type UpgradeDecision =
|
|
| {
|
|
readonly ok: true
|
|
readonly open: MuxOpen
|
|
readonly hostId: string
|
|
readonly acceptedSubprotocol: string
|
|
}
|
|
| { readonly ok: false; readonly status: 401 | 403 }
|
|
|
|
export interface AuthorizeDeps {
|
|
authorizer: Authorizer
|
|
resolver: RouteResolver
|
|
baseDomain: string
|
|
now: () => number
|
|
remoteAddrSalt: string
|
|
requiredRight: CapabilityRight
|
|
}
|
|
|
|
/**
|
|
* Extract the capability token from EXACTLY the two allowed FIX-5 carriers, in order:
|
|
* (1) `Sec-WebSocket-Protocol` `term.token.<b64u>` entry, (2) the `term_cap` cookie. Returns the
|
|
* opaque token + which carrier, or null. NEVER reads `req.url` (query-string tokens forbidden).
|
|
*/
|
|
export function extractCapabilityToken(
|
|
req: UpgradeRequest,
|
|
): { token: string; via: 'subprotocol' | 'cookie' } | null {
|
|
const fromSub = extractTokenFromSubprotocols(req.subprotocols)
|
|
if (fromSub !== null && fromSub.length > 0) return { token: fromSub, via: 'subprotocol' }
|
|
const fromCookie = req.cookies[TOKEN_COOKIE_NAME]
|
|
if (fromCookie !== undefined && fromCookie.length > 0) return { token: fromCookie, via: 'cookie' }
|
|
return null
|
|
}
|
|
|
|
/** Salted HMAC-SHA256 of the client IP, truncated — audit-only, never reversible (INV10). */
|
|
function hashRemoteAddr(salt: string, remoteAddr: string): string {
|
|
return createHmac('sha256', salt).update(remoteAddr).digest('hex').slice(0, 32)
|
|
}
|
|
|
|
/** The opaque request path (everything the relay forwards but NEVER parses for authz). */
|
|
function pathOf(url: string): string {
|
|
return url
|
|
}
|
|
|
|
/**
|
|
* Parse → resolve → build ctx → delegate to P5 → map. No decision branches beyond the three
|
|
* LOCAL parse guards (unparseable Host → 401, no token carrier → 401, unknown subdomain → 403).
|
|
*/
|
|
export async function authorizeUpgrade(
|
|
req: UpgradeRequest,
|
|
deps: AuthorizeDeps,
|
|
): Promise<UpgradeDecision> {
|
|
const subdomain = extractSubdomain(req.host, deps.baseDomain)
|
|
if (subdomain === null) return { ok: false, status: 401 } // unparseable Host (local)
|
|
|
|
const extracted = extractCapabilityToken(req)
|
|
if (extracted === null) return { ok: false, status: 401 } // no token carrier present (local)
|
|
|
|
const resolved = await deps.resolver.resolveSubdomain(subdomain)
|
|
if (resolved === null) return { ok: false, status: 403 } // unknown subdomain (local)
|
|
|
|
const remoteAddrHash = hashRemoteAddr(deps.remoteAddrSalt, req.remoteAddr)
|
|
const ctx: UpgradeContext = {
|
|
capabilityRaw: extracted.token,
|
|
originHeader: req.origin ?? '',
|
|
expectedAud: subdomain,
|
|
requestedHostId: resolved.hostId, // P5 gates token.host against this (INV1)
|
|
requiredRight: deps.requiredRight,
|
|
remoteAddrHash,
|
|
activeSessionCount: req.activeSessionCount,
|
|
dpop: req.dpop,
|
|
principal: null, // P5 resolves from the signed token (INV3)
|
|
}
|
|
|
|
const outcome =
|
|
req.sessionId !== undefined
|
|
? await deps.authorizer.onReattach({ ...ctx, sessionId: req.sessionId }, deps.now())
|
|
: await deps.authorizer.onUpgrade(ctx, deps.now())
|
|
|
|
if (!outcome.ok) return { ok: false, status: outcome.status }
|
|
|
|
const open: MuxOpen = {
|
|
streamId: 0, // allocated by MuxSession
|
|
subdomain,
|
|
requestPath: pathOf(req.url),
|
|
originHeader: req.origin ?? '',
|
|
remoteAddrHash,
|
|
capabilityTokenRef: outcome.jti, // OQ5 resolved: P5 surfaces the verified jti
|
|
}
|
|
return { ok: true, open, hostId: outcome.hostId, acceptedSubprotocol: APP_SUBPROTOCOL }
|
|
}
|