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:
147
term-relay/data-plane/agent-listener.ts
Normal file
147
term-relay/data-plane/agent-listener.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* T9 · Agent tunnel listener (INV4/INV7/INV12/INV14). Accepts the agent's mTLS dial-out, builds
|
||||
* a relay-side MuxSession per tunnel, and registers the route (P3) with a heartbeat TTL.
|
||||
*
|
||||
* Two-tier defense (Finding-4): the agent-facing TLS server is constructed with
|
||||
* `requestCert: true` AND `rejectUnauthorized: true` and a `ca` from config — so the TLS handshake
|
||||
* itself rejects a no-cert / non-chaining peer BEFORE `attach()`/`verifyPeer()` runs. `verifyPeer`
|
||||
* (P5) then binds the already-CA-validated cert's pubkey to the registry (INV14/INV4). No bearer/
|
||||
* shared-secret path exists. The node holds only an in-RAM Map (INV7); a crash loses only that.
|
||||
*/
|
||||
import type { DataPlaneConfig } from './config.js'
|
||||
import type { WebSocketLike } from './ws-like.js'
|
||||
import type { ScheduleHandle } from '../mux/heartbeat.js'
|
||||
import { createMuxSession, type MuxSession } from '../mux/mux-session.js'
|
||||
|
||||
export interface MtlsVerifier {
|
||||
// P5 impl — verifies the peer cert against the host registry pubkey (INV14).
|
||||
verifyPeer(peerCert: Uint8Array): { hostId: string; accountId: string } | null
|
||||
}
|
||||
|
||||
export interface RouteRegistrar {
|
||||
// P3 impl — Redis route:{host_id} with heartbeat TTL (INV7).
|
||||
register(hostId: string, relayNodeId: string, ttlMs: number): Promise<void>
|
||||
heartbeat(hostId: string): Promise<void>
|
||||
deregister(hostId: string): Promise<void>
|
||||
}
|
||||
|
||||
export interface AgentTunnel {
|
||||
readonly hostId: string
|
||||
readonly accountId: string // carried for account-scope revocation fan-out (T14)
|
||||
readonly session: MuxSession
|
||||
closeTunnel(): void
|
||||
}
|
||||
|
||||
/** Frozen mTLS server options — the ONLY shape the agent-facing TLS server is ever built with. */
|
||||
export interface TlsServerOptions {
|
||||
readonly requestCert: true
|
||||
readonly rejectUnauthorized: true
|
||||
readonly ca: readonly Uint8Array[]
|
||||
}
|
||||
|
||||
export interface TlsServerLike {
|
||||
close(): void
|
||||
}
|
||||
|
||||
export type TlsServerFactory = (
|
||||
opts: TlsServerOptions,
|
||||
onPeer: (ws: WebSocketLike, peerCert: Uint8Array) => void,
|
||||
) => TlsServerLike
|
||||
|
||||
export interface AgentListenerDeps {
|
||||
config: DataPlaneConfig
|
||||
mtls: MtlsVerifier
|
||||
registrar: RouteRegistrar
|
||||
onTunnel(t: AgentTunnel): void
|
||||
// CA bundle bytes (from config.agentCaCertPath/agentCaChainPath); injected for testability.
|
||||
caBundle: readonly Uint8Array[]
|
||||
// Optional seams (default: real setInterval; no TLS server started in unit tests).
|
||||
tlsServerFactory?: TlsServerFactory
|
||||
schedule?: (fn: () => void, ms: number) => ScheduleHandle
|
||||
onError?: (e: unknown) => void
|
||||
}
|
||||
|
||||
export interface AgentListener {
|
||||
attach(ws: WebSocketLike, peerCert: Uint8Array): void
|
||||
tunnels(): ReadonlyMap<string, AgentTunnel>
|
||||
close(): void
|
||||
}
|
||||
|
||||
function defaultSchedule(fn: () => void, ms: number): ScheduleHandle {
|
||||
const id = setInterval(fn, ms)
|
||||
id.unref?.()
|
||||
return { cancel: () => clearInterval(id) }
|
||||
}
|
||||
|
||||
export function createAgentListener(deps: AgentListenerDeps): AgentListener {
|
||||
const schedule = deps.schedule ?? defaultSchedule
|
||||
const onError = deps.onError ?? (() => {})
|
||||
const tunnels = new Map<string, AgentTunnel>()
|
||||
|
||||
function runAsync(p: Promise<void>): void {
|
||||
p.catch((e: unknown) => onError(e)) // boundary: route errors to onError, never swallow silently
|
||||
}
|
||||
|
||||
// TLS layer: presentation enforced HERE (requestCert + rejectUnauthorized). attach() is only ever
|
||||
// reached for a peer whose cert already chained to our CA.
|
||||
const server = deps.tlsServerFactory?.(
|
||||
{ requestCert: true, rejectUnauthorized: true, ca: deps.caBundle },
|
||||
(ws, peerCert) => attach(ws, peerCert),
|
||||
)
|
||||
|
||||
function attach(ws: WebSocketLike, peerCert: Uint8Array): void {
|
||||
const verified = deps.mtls.verifyPeer(peerCert) // app-layer bind (INV14) — after CA validation
|
||||
if (verified === null) {
|
||||
ws.close(4401) // cert chains to our CA but pubkey not in the host registry → no route
|
||||
return
|
||||
}
|
||||
const { hostId, accountId } = verified
|
||||
|
||||
// Agent reconnected on this node → replace the prior tunnel (latest wins).
|
||||
tunnels.get(hostId)?.closeTunnel()
|
||||
|
||||
const session: MuxSession = createMuxSession({
|
||||
role: 'relay',
|
||||
sendWire: (frame) => ws.send(frame),
|
||||
onOpen: () => {}, // relay never receives OPEN
|
||||
onData: () => {}, // inbound routed per-stream via the handle (relay-node subscribes)
|
||||
onClose: () => {},
|
||||
onDead: () => teardown(),
|
||||
maxFrameBytes: deps.config.maxFrameBytes,
|
||||
initialWindowBytes: deps.config.initialWindowBytes,
|
||||
})
|
||||
|
||||
let hbHandle: ScheduleHandle | null = schedule(() => {
|
||||
runAsync(deps.registrar.heartbeat(hostId))
|
||||
}, deps.config.heartbeatIntervalMs)
|
||||
|
||||
let torn = false
|
||||
function teardown(): void {
|
||||
if (torn) return
|
||||
torn = true
|
||||
hbHandle?.cancel()
|
||||
hbHandle = null
|
||||
session.close()
|
||||
ws.close()
|
||||
tunnels.delete(hostId)
|
||||
runAsync(deps.registrar.deregister(hostId)) // stateless: ingress stops routing here (INV7)
|
||||
}
|
||||
|
||||
ws.onMessage((bytes) => session.onWire(bytes))
|
||||
ws.onClose(() => teardown())
|
||||
|
||||
const tunnel: AgentTunnel = { hostId, accountId, session, closeTunnel: teardown }
|
||||
tunnels.set(hostId, tunnel)
|
||||
runAsync(deps.registrar.register(hostId, deps.config.relayNodeId, deps.config.routeTtlMs))
|
||||
deps.onTunnel(tunnel)
|
||||
}
|
||||
|
||||
return {
|
||||
attach,
|
||||
tunnels: () => tunnels,
|
||||
close: () => {
|
||||
for (const t of [...tunnels.values()]) t.closeTunnel()
|
||||
server?.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
41
term-relay/data-plane/authz-port.ts
Normal file
41
term-relay/data-plane/authz-port.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* P5 authorization SEAM (FIX 6a). These interfaces MIRROR relay-auth's frozen §6a shapes
|
||||
* (`UpgradeContext` / `AuthzOutcome` / `DpopContext` / `Authorizer`). P5 (`relay-auth/`) is not
|
||||
* built in this package's build graph, so — per the "program against interfaces / DI seams,
|
||||
* don't hard-import an unbuilt sibling" rule — P1 defines the port here and injects an impl.
|
||||
*
|
||||
* INTEGRATION POINT: when relay-auth lands, replace these local declarations with
|
||||
* `import type { UpgradeContext, AuthzOutcome, DpopContext, Authorizer } from 'relay-auth'`
|
||||
* (structurally identical). P1 holds NO authz logic — the decision is P5's alone.
|
||||
*/
|
||||
import type { CapabilityRight } from 'relay-contracts'
|
||||
|
||||
/** Proof-of-possession material P5 needs; P1 passes it through, never inspects it. */
|
||||
export interface DpopContext {
|
||||
readonly proof: string | null
|
||||
readonly publicKeyThumbprint: string | null
|
||||
}
|
||||
|
||||
/** Everything P5's `onUpgrade`/`onReattach` needs to make the deny-by-default decision. */
|
||||
export interface UpgradeContext {
|
||||
readonly capabilityRaw: string // opaque token string; P5 verifies the signature
|
||||
readonly originHeader: string // retained for Origin/CSWSH check (P5)
|
||||
readonly expectedAud: string // = the resolved subdomain (Host-confusion guard, INV1)
|
||||
readonly requestedHostId: string // P1 NAMES the resolved host; P5 gates token.host against it
|
||||
readonly requiredRight: CapabilityRight
|
||||
readonly remoteAddrHash: string // salted hash (audit only, INV10)
|
||||
readonly activeSessionCount: number
|
||||
readonly dpop: DpopContext
|
||||
readonly principal: string | null // P5 resolves from the signed token (INV3)
|
||||
}
|
||||
|
||||
/** P5's verdict. The `ok` branch carries the VERIFIED token's `jti` (OQ5 resolved). */
|
||||
export type AuthzOutcome =
|
||||
| { readonly ok: true; readonly hostId: string; readonly principal: string; readonly jti: string }
|
||||
| { readonly ok: false; readonly status: 401 | 403 }
|
||||
|
||||
/** The single authorizer (P5). P1 injects it and supplies the ctx; P5 makes every decision. */
|
||||
export interface Authorizer {
|
||||
onUpgrade(ctx: UpgradeContext, now: number): Promise<AuthzOutcome>
|
||||
onReattach(ctx: UpgradeContext & { readonly sessionId: string }, now: number): Promise<AuthzOutcome>
|
||||
}
|
||||
81
term-relay/data-plane/config.ts
Normal file
81
term-relay/data-plane/config.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* T1 · Data-plane config — env → frozen DataPlaneConfig (INV9 fail-fast, no secret logged).
|
||||
*
|
||||
* Startup MUST fail if TLS server material, the AGENT_CA_CERT_PATH mTLS trust-anchor, or
|
||||
* BASE_DOMAIN is absent (a missing CA would silently degrade "mTLS" to accept-any-cert,
|
||||
* Finding-4). Zod validates at the boundary; the returned object is frozen (immutable).
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
|
||||
const MAX_FRAME_BYTES_DEFAULT = 1024 * 1024 // 1 MiB (§4.1 payloadLen ceiling)
|
||||
const INITIAL_WINDOW_BYTES_DEFAULT = 256 * 1024 // 256 KiB (§4.1 credit window)
|
||||
const HEARTBEAT_INTERVAL_MS_DEFAULT = 15_000 // §4.1: PING every 15s
|
||||
const ROUTE_TTL_MS_DEFAULT = 45_000 // Redis route:{host_id} TTL
|
||||
const BIND_HOST_DEFAULT = '0.0.0.0'
|
||||
const BIND_PORT_DEFAULT = 8443
|
||||
const AGENT_BIND_PORT_DEFAULT = 8444
|
||||
|
||||
export interface DataPlaneConfig {
|
||||
readonly baseDomain: string
|
||||
readonly bindHost: string
|
||||
readonly bindPort: number
|
||||
readonly agentBindPort: number
|
||||
readonly tlsCertPath: string
|
||||
readonly tlsKeyPath: string
|
||||
readonly agentCaCertPath: string
|
||||
readonly agentCaChainPath: string
|
||||
readonly relayNodeId: string
|
||||
readonly maxFrameBytes: number
|
||||
readonly initialWindowBytes: number
|
||||
readonly heartbeatIntervalMs: number
|
||||
readonly routeTtlMs: number
|
||||
}
|
||||
|
||||
const requiredPath = z.string().min(1)
|
||||
const port = z.coerce.number().int().gt(0).max(65535)
|
||||
const positiveInt = z.coerce.number().int().positive()
|
||||
|
||||
const ConfigSchema = z.object({
|
||||
baseDomain: z.string().min(1),
|
||||
bindHost: z.string().min(1).default(BIND_HOST_DEFAULT),
|
||||
bindPort: port.default(BIND_PORT_DEFAULT),
|
||||
agentBindPort: port.default(AGENT_BIND_PORT_DEFAULT),
|
||||
tlsCertPath: requiredPath,
|
||||
tlsKeyPath: requiredPath,
|
||||
agentCaCertPath: requiredPath,
|
||||
agentCaChainPath: requiredPath,
|
||||
relayNodeId: z.string().min(1),
|
||||
maxFrameBytes: positiveInt.default(MAX_FRAME_BYTES_DEFAULT),
|
||||
initialWindowBytes: positiveInt.default(INITIAL_WINDOW_BYTES_DEFAULT),
|
||||
heartbeatIntervalMs: positiveInt.default(HEARTBEAT_INTERVAL_MS_DEFAULT),
|
||||
routeTtlMs: positiveInt.default(ROUTE_TTL_MS_DEFAULT),
|
||||
})
|
||||
|
||||
/**
|
||||
* Load + validate the data-plane config from `env`. Throws a clear, secret-free error
|
||||
* (fail-fast, INV9) when a required key is missing or a numeric key is non-numeric.
|
||||
* `agentCaChainPath` falls back to `agentCaCertPath` when unset (single-tier CA).
|
||||
*/
|
||||
export function loadDataPlaneConfig(env: NodeJS.ProcessEnv): DataPlaneConfig {
|
||||
const parsed = ConfigSchema.safeParse({
|
||||
baseDomain: env.BASE_DOMAIN,
|
||||
bindHost: env.BIND_HOST,
|
||||
bindPort: env.BIND_PORT,
|
||||
agentBindPort: env.AGENT_BIND_PORT,
|
||||
tlsCertPath: env.TLS_CERT_PATH,
|
||||
tlsKeyPath: env.TLS_KEY_PATH,
|
||||
agentCaCertPath: env.AGENT_CA_CERT_PATH,
|
||||
agentCaChainPath: env.AGENT_CA_CHAIN_PATH ?? env.AGENT_CA_CERT_PATH,
|
||||
relayNodeId: env.RELAY_NODE_ID,
|
||||
maxFrameBytes: env.MAX_FRAME_BYTES,
|
||||
initialWindowBytes: env.INITIAL_WINDOW_BYTES,
|
||||
heartbeatIntervalMs: env.HEARTBEAT_INTERVAL_MS,
|
||||
routeTtlMs: env.ROUTE_TTL_MS,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
// Report only WHICH keys are wrong — never echo values (INV9: no secret in logs).
|
||||
const fields = parsed.error.issues.map((i) => i.path.join('.')).join(', ')
|
||||
throw new Error(`invalid data-plane config: ${fields}`)
|
||||
}
|
||||
return Object.freeze(parsed.data)
|
||||
}
|
||||
73
term-relay/data-plane/drain.ts
Normal file
73
term-relay/data-plane/drain.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* T11 · Graceful drain + GOAWAY (INV7/INV12). GOAWAY on streamId 0 to every (or one) tunnel,
|
||||
* then teardown. The grace window is REASON-DIFFERENTIATED (Finding-2):
|
||||
* effectiveGraceMs = (reason === revoked) ? 0 : inFlightGraceMs
|
||||
* `revoked` is a SECURITY action against a compromised host/device — it forces immediate teardown
|
||||
* (RST/close now, deregister now) regardless of any caller-supplied grace, so INV12's "its live
|
||||
* tunnel drops within seconds" holds even when operator-drain grace is tuned to tens of seconds.
|
||||
* `operatorDrain`/`shutdown` keep a grace window purely for clean migration.
|
||||
*/
|
||||
import type { AgentTunnel, RouteRegistrar } from './agent-listener.js'
|
||||
|
||||
/** Drain reason ⇄ §4.1 GOAWAY wire code (frozen in relay-contracts as GOAWAY_REASON_TO_CODE). */
|
||||
export const DRAIN_REASON = { operatorDrain: 1, revoked: 2, shutdown: 3 } as const
|
||||
export type DrainReason = (typeof DRAIN_REASON)[keyof typeof DRAIN_REASON]
|
||||
|
||||
export interface DrainDeps {
|
||||
tunnels(): ReadonlyMap<string, AgentTunnel>
|
||||
registrar: RouteRegistrar
|
||||
reason: DrainReason
|
||||
inFlightGraceMs: number
|
||||
// Injected timer for the grace window (default global setTimeout); testability seam.
|
||||
setTimeoutFn?: (fn: () => void, ms: number) => unknown
|
||||
}
|
||||
|
||||
export interface DrainHostDeps extends DrainDeps {
|
||||
// single-host target resolved by drainHost's first arg
|
||||
}
|
||||
|
||||
function effectiveGrace(reason: DrainReason, inFlightGraceMs: number): number {
|
||||
return reason === DRAIN_REASON.revoked ? 0 : Math.max(0, inFlightGraceMs)
|
||||
}
|
||||
|
||||
async function tearDownTunnel(t: AgentTunnel, registrar: RouteRegistrar): Promise<void> {
|
||||
t.closeTunnel() // socket close (agent side sees the streams die)
|
||||
await registrar.deregister(t.hostId) // ingress stops routing to this host on this node
|
||||
}
|
||||
|
||||
/** GOAWAY then teardown one tunnel, honoring the reason-differentiated grace. */
|
||||
function drainOne(
|
||||
t: AgentTunnel,
|
||||
reason: DrainReason,
|
||||
inFlightGraceMs: number,
|
||||
registrar: RouteRegistrar,
|
||||
setTimeoutFn: (fn: () => void, ms: number) => unknown,
|
||||
): Promise<void> {
|
||||
t.session.drain(0, reason) // §4.1 GOAWAY on streamId 0; refuses new streams thereafter
|
||||
const grace = effectiveGrace(reason, inFlightGraceMs)
|
||||
if (grace === 0) {
|
||||
return tearDownTunnel(t, registrar) // immediate (revoked, or zero-grace operator drain)
|
||||
}
|
||||
setTimeoutFn(() => {
|
||||
void tearDownTunnel(t, registrar)
|
||||
}, grace)
|
||||
return Promise.resolve() // grace scheduled; drain initiated
|
||||
}
|
||||
|
||||
/** Drain EVERY tunnel on the node (operator drain / shutdown / node-wide revocation). */
|
||||
export async function drainNode(deps: DrainDeps): Promise<void> {
|
||||
const setTimeoutFn = deps.setTimeoutFn ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
await Promise.all(
|
||||
[...deps.tunnels().values()].map((t) =>
|
||||
drainOne(t, deps.reason, deps.inFlightGraceMs, deps.registrar, setTimeoutFn),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Drain exactly ONE host, leaving siblings running (whole-host revocation / operator drain, INV12). */
|
||||
export async function drainHost(hostId: string, deps: DrainHostDeps): Promise<void> {
|
||||
const setTimeoutFn = deps.setTimeoutFn ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const t = deps.tunnels().get(hostId)
|
||||
if (t === undefined) return // no-op: already gone (deny-by-default, never a broader kill)
|
||||
await drainOne(t, deps.reason, deps.inFlightGraceMs, deps.registrar, setTimeoutFn)
|
||||
}
|
||||
130
term-relay/data-plane/relay-node.ts
Normal file
130
term-relay/data-plane/relay-node.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* T10 · Stateless relay node + opaque splice (INV2/INV5/INV7/INV11 core). Browser upgrade →
|
||||
* authorize (P5 via T8) → openStream on the host's agent tunnel → OPAQUE byte splice. The node
|
||||
* NEVER decodes DATA (INV2/INV11): a byte is a byte. Buffers are per-connection (each ws↔stream
|
||||
* pair) — NO global mutable buffer pool (INV5, kills cross-tenant buffer bleed). All maps are
|
||||
* in-RAM only (INV7): a crash loses nothing; agents reconnect + re-register.
|
||||
*
|
||||
* closeStream (Finding-3/INV12): a per-`jti` splice index lets P5 RST exactly the stream(s) under
|
||||
* ONE revoked capability token, leaving the owner's other devices (different `jti`s) attached —
|
||||
* removing the "nuke everyone or nothing" false choice.
|
||||
*/
|
||||
import type { WebSocketLike } from './ws-like.js'
|
||||
import type { AgentListener } from './agent-listener.js'
|
||||
import type { DataPlaneConfig } from './config.js'
|
||||
import type { UpgradeRequest, UpgradeDecision } from './upgrade.js'
|
||||
import type { MuxStreamHandle } from '../mux/mux-session.js'
|
||||
|
||||
const WS_TRY_LATER = 1013 // agent offline for this host
|
||||
const WS_REVOKED = 4403 // single-device revocation close code
|
||||
|
||||
interface Splice {
|
||||
readonly hostId: string
|
||||
readonly streamId: number
|
||||
readonly jti: string
|
||||
readonly ws: WebSocketLike
|
||||
readonly stream: MuxStreamHandle
|
||||
}
|
||||
|
||||
export type StreamSelector = { readonly jti: string } | { readonly streamId: number }
|
||||
|
||||
export interface RelayNode {
|
||||
handleAgentTunnel(ws: WebSocketLike, peerCert: Uint8Array): void
|
||||
handleBrowserUpgrade(req: UpgradeRequest, ws: WebSocketLike): Promise<void>
|
||||
drain(reason: number): Promise<void>
|
||||
closeTunnel(hostId: string): void
|
||||
closeStream(hostId: string, selector: StreamSelector): number
|
||||
}
|
||||
|
||||
export interface RelayNodeDeps {
|
||||
config: DataPlaneConfig
|
||||
listener: AgentListener
|
||||
authorize(req: UpgradeRequest): Promise<UpgradeDecision>
|
||||
}
|
||||
|
||||
function selectorMatches(splice: Splice, selector: StreamSelector): boolean {
|
||||
return 'jti' in selector ? splice.jti === selector.jti : splice.streamId === selector.streamId
|
||||
}
|
||||
|
||||
export function createRelayNode(deps: RelayNodeDeps): RelayNode {
|
||||
// In-RAM splice index (INV7). No durable buffers, no global pool (INV5).
|
||||
const splices = new Set<Splice>()
|
||||
|
||||
function removeSplice(splice: Splice): void {
|
||||
splices.delete(splice)
|
||||
}
|
||||
|
||||
return {
|
||||
handleAgentTunnel(ws, peerCert) {
|
||||
deps.listener.attach(ws, peerCert) // TLS+mTLS already enforced upstream (T9)
|
||||
},
|
||||
|
||||
async handleBrowserUpgrade(req, ws) {
|
||||
const decision = await deps.authorize(req)
|
||||
if (!decision.ok) {
|
||||
ws.close(decision.status) // INV6/INV15: only P5's {ok:true} opens a stream
|
||||
return
|
||||
}
|
||||
const tunnel = deps.listener.tunnels().get(decision.hostId) // strictly by token-derived hostId (INV1/INV3)
|
||||
if (tunnel === undefined) {
|
||||
ws.close(WS_TRY_LATER) // agent offline; no crash
|
||||
return
|
||||
}
|
||||
const stream = tunnel.session.openStream(decision.open) // relay allocates streamId
|
||||
const splice: Splice = {
|
||||
hostId: decision.hostId,
|
||||
streamId: stream.streamId,
|
||||
jti: decision.open.capabilityTokenRef,
|
||||
ws,
|
||||
stream,
|
||||
}
|
||||
splices.add(splice)
|
||||
|
||||
// OPAQUE splice — verbatim Uint8Array both ways (INV2), per-connection only (INV5).
|
||||
stream.onData((bytes) => ws.send(bytes)) // agent → browser
|
||||
stream.onClose(() => {
|
||||
ws.close()
|
||||
removeSplice(splice)
|
||||
})
|
||||
ws.onMessage((bytes) => stream.writeData(bytes)) // browser → agent
|
||||
ws.onClose(() => {
|
||||
stream.close()
|
||||
removeSplice(splice)
|
||||
})
|
||||
},
|
||||
|
||||
async drain(reason) {
|
||||
// Whole-node drain: GOAWAY every tunnel, then close (deregisters via the listener's teardown).
|
||||
for (const t of [...deps.listener.tunnels().values()]) {
|
||||
t.session.drain(0, reason)
|
||||
t.closeTunnel()
|
||||
}
|
||||
await Promise.resolve()
|
||||
},
|
||||
|
||||
closeTunnel(hostId) {
|
||||
// Whole-host revocation lever — kicks every device on the host.
|
||||
for (const splice of [...splices]) {
|
||||
if (splice.hostId === hostId) {
|
||||
splice.ws.close(WS_REVOKED)
|
||||
removeSplice(splice)
|
||||
}
|
||||
}
|
||||
deps.listener.tunnels().get(hostId)?.closeTunnel()
|
||||
},
|
||||
|
||||
closeStream(hostId, selector) {
|
||||
// Single-device revocation (Finding-3): RST exactly the matching stream(s); unknown → 0 (no-op).
|
||||
let count = 0
|
||||
for (const splice of [...splices]) {
|
||||
if (splice.hostId !== hostId) continue
|
||||
if (!selectorMatches(splice, selector)) continue
|
||||
splice.stream.close(true) // RST just this stream via the mux (tunnel stays up)
|
||||
splice.ws.close(WS_REVOKED)
|
||||
removeSplice(splice)
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
},
|
||||
}
|
||||
}
|
||||
83
term-relay/data-plane/revocation-subscriber.ts
Normal file
83
term-relay/data-plane/revocation-subscriber.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* T14 · Revocation subscriber (INV12 data-plane teardown owner, FIX 4). Subscribes to the frozen
|
||||
* §4.2 `relay:revocations` bus and maps a `KillSignal` onto the EXISTING §4.1 wire (no new frame
|
||||
* type) via T11's `drainHost(…, revoked)` — immediate teardown (grace forced to 0) within
|
||||
* `REVOCATION_PUSH_BUDGET_MS`. P3/P5 PUBLISH; every P1 node SUBSCRIBES (P1 never imports P5 —
|
||||
* the KillSignal shape lives in relay-contracts, keeping the DAG acyclic).
|
||||
*
|
||||
* Security: scope selection is blast-radius-bounded (host ⊂ account ⊂ global); a host this node
|
||||
* doesn't serve is a no-op (no cross-tenant reach, INV1). `signal.reason` is metadata only, never
|
||||
* written as terminal payload/log content (INV10). A malformed signal is dropped + counted — a
|
||||
* poisoned message can neither kill the bus nor trigger a spurious teardown.
|
||||
*/
|
||||
import {
|
||||
KillSignalSchema,
|
||||
RELAY_REVOCATIONS_CHANNEL,
|
||||
type KillSignal,
|
||||
type RevocationScope,
|
||||
} from 'relay-contracts'
|
||||
import type { AgentTunnel } from './agent-listener.js'
|
||||
import { DRAIN_REASON } from './drain.js'
|
||||
|
||||
export interface Subscription {
|
||||
close(): void
|
||||
}
|
||||
|
||||
export interface RevocationSubscriberDeps {
|
||||
subscribe(channel: string, onMessage: (raw: string) => void): Subscription
|
||||
drainHost(hostId: string, reason: typeof DRAIN_REASON.revoked): Promise<void>
|
||||
tunnels(): ReadonlyMap<string, AgentTunnel>
|
||||
now: () => number
|
||||
onApplied?(signal: KillSignal, hostsAffected: number, elapsedMs: number): void
|
||||
onDropped?(): void // malformed-signal counter (metadata only, INV10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure scope→hosts selector: which of THIS node's live hosts a signal hits. `accountOf` maps a
|
||||
* live hostId to its accountId (carried on the tunnel). Testable in isolation.
|
||||
*/
|
||||
export function hostsForScope(
|
||||
scope: RevocationScope,
|
||||
liveHosts: ReadonlyMap<string, AgentTunnel>,
|
||||
accountOf: (hostId: string) => string | undefined,
|
||||
): readonly string[] {
|
||||
switch (scope.kind) {
|
||||
case 'host':
|
||||
return liveHosts.has(scope.hostId) ? [scope.hostId] : []
|
||||
case 'account':
|
||||
return [...liveHosts.keys()].filter((h) => accountOf(h) === scope.accountId)
|
||||
case 'global':
|
||||
return [...liveHosts.keys()]
|
||||
}
|
||||
}
|
||||
|
||||
function parseKillSignal(raw: string): KillSignal | null {
|
||||
let json: unknown
|
||||
try {
|
||||
json = JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const result = KillSignalSchema.safeParse(json)
|
||||
return result.success ? result.data : null
|
||||
}
|
||||
|
||||
export function startRevocationSubscriber(deps: RevocationSubscriberDeps): Subscription {
|
||||
const accountOf = (hostId: string): string | undefined => deps.tunnels().get(hostId)?.accountId
|
||||
|
||||
const sub = deps.subscribe(RELAY_REVOCATIONS_CHANNEL, (raw) => {
|
||||
const startedAt = deps.now()
|
||||
const signal = parseKillSignal(raw)
|
||||
if (signal === null) {
|
||||
deps.onDropped?.() // dropped + counted; bus stays alive, no teardown fired
|
||||
return
|
||||
}
|
||||
const hosts = hostsForScope(signal.scope, deps.tunnels(), accountOf)
|
||||
// Fire immediate teardown (reason=revoked ⇒ grace forced to 0 in T11) for each in-scope host.
|
||||
void Promise.all(hosts.map((hostId) => deps.drainHost(hostId, DRAIN_REASON.revoked))).then(() => {
|
||||
deps.onApplied?.(signal, hosts.length, deps.now() - startedAt)
|
||||
})
|
||||
})
|
||||
|
||||
return { close: () => sub.close() }
|
||||
}
|
||||
43
term-relay/data-plane/subdomain-router.ts
Normal file
43
term-relay/data-plane/subdomain-router.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* T7 · Subdomain router (§4.2 routing). `extractSubdomain` returns the SINGLE leftmost label
|
||||
* of `host` under `baseDomain`, or null for a bare/nested/confusing Host (INV1 Host-confusion
|
||||
* guard). It is a HINT for candidate lookup only — NEVER the authority: T8 requires the
|
||||
* capability token's `aud` to equal this subdomain and P5 gates `token.host` against the
|
||||
* resolver's `hostId`. `RouteResolver` is IMPLEMENTED BY P3; P1 owns only the interface.
|
||||
*/
|
||||
export interface ResolvedHost {
|
||||
readonly hostId: string
|
||||
readonly accountId: string
|
||||
readonly subdomain: string
|
||||
}
|
||||
|
||||
export interface RouteResolver {
|
||||
resolveSubdomain(subdomain: string): Promise<ResolvedHost | null>
|
||||
}
|
||||
|
||||
const LABEL_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/
|
||||
|
||||
/** Strip a single trailing dot and lowercase; strip a `:port` suffix if present. */
|
||||
function normalizeHost(host: string): string {
|
||||
const noPort = host.split(':', 1)[0] ?? host
|
||||
const trimmed = noPort.endsWith('.') ? noPort.slice(0, -1) : noPort
|
||||
return trimmed.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the single tenant label: `'alice.term.example.com'` under `'term.example.com'` → `'alice'`.
|
||||
* Returns null when: host equals the base domain (bare), the suffix does not match, or the prefix
|
||||
* is multi-label (`'a.b.term.example.com'` → null — nested subdomains are rejected, INV1).
|
||||
*/
|
||||
export function extractSubdomain(hostHeader: string, baseDomain: string): string | null {
|
||||
const host = normalizeHost(hostHeader)
|
||||
const base = normalizeHost(baseDomain)
|
||||
if (host === base) return null // bare base domain — no tenant
|
||||
const suffix = '.' + base
|
||||
if (!host.endsWith(suffix)) return null // confusion attempt (e.g. '...example.com.evil.com')
|
||||
const label = host.slice(0, host.length - suffix.length)
|
||||
if (label.length === 0) return null
|
||||
if (label.includes('.')) return null // multi-label prefix rejected (single-label tenant only)
|
||||
if (!LABEL_RE.test(label)) return null // invalid DNS label
|
||||
return label
|
||||
}
|
||||
124
term-relay/data-plane/upgrade.ts
Normal file
124
term-relay/data-plane/upgrade.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
11
term-relay/data-plane/ws-like.ts
Normal file
11
term-relay/data-plane/ws-like.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Minimal WebSocket abstraction the data plane splices over. Kept as an injected interface so
|
||||
* the mux/data-plane code has NO hard dependency on the `ws` package (pure + unit-testable);
|
||||
* the composition root adapts a real `ws` socket to this shape.
|
||||
*/
|
||||
export interface WebSocketLike {
|
||||
send(data: Uint8Array): void
|
||||
close(code?: number): void
|
||||
onMessage(handler: (data: Uint8Array) => void): void
|
||||
onClose(handler: () => void): void
|
||||
}
|
||||
53
term-relay/frp-scaffold/README.md
Normal file
53
term-relay/frp-scaffold/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# frp scaffold — v0.8 MVP transport (café-demo runbook)
|
||||
|
||||
The **v0.8 stepping-stone** transport for the rendezvous-relay: a single-node `frps` on a VPS with
|
||||
wildcard-subdomain vhost routing, so a phone browser can open `alice.term.<domain>` and land in the
|
||||
laptop's shell with **no router/VPN/static-IP** configured. This is deliberately temporary — v0.9
|
||||
replaces it with the native §4.1 WS mux (see **Retirement** below).
|
||||
|
||||
## Pieces
|
||||
|
||||
| Piece | File | Role |
|
||||
|---|---|---|
|
||||
| `frps` config | `frps.toml` | wildcard `*.term.<domain>` vhost + edge auth plugin |
|
||||
| server-plugin shim | `plugin-hook.ts` | delegates `Login`/`NewProxy`/`NewUserConn` to P3 (no tenancy logic) |
|
||||
| agent (`frpc`) wrap | *P2 `agent/`* | dials out, wraps `ws://127.0.0.1:3000` |
|
||||
| control-plane authz | *P3 `control-plane/`* | owns the deny-by-default decision |
|
||||
|
||||
## Runbook (one VPS)
|
||||
|
||||
1. **Wildcard DNS** — `*.term.<domain>` → the VPS public IP (an `A`/`AAAA` record on the wildcard).
|
||||
2. **Wildcard TLS** — LetsEncrypt **DNS-01** for `*.term.<domain>` (or put Cloudflare in front and
|
||||
terminate TLS there — M6 scheme-following is preserved end-to-end either way).
|
||||
3. **`frps`** — run with `frps.toml`; set `subdomainHost = <BASE_DOMAIN>`, `vhostHTTPSPort = 443`.
|
||||
4. **Edge auth** — start the `plugin-hook.ts` HTTP shim on `127.0.0.1:9001`; point it at P3's authz
|
||||
endpoint. Every `Login`/`NewProxy`/`NewUserConn` is **deny-by-default** (INDEX v0.8 shared-token
|
||||
gate — a KNOWN temporary shortcut).
|
||||
5. **Agent** — on the laptop, run the P2 `frpc`-wrapped agent; it registers subdomain `alice` and
|
||||
proxies to the local web-terminal `ws://127.0.0.1:3000`.
|
||||
6. **Demo** — from a phone browser open `alice.term.<domain>`, pass the `clientToken` gate, and you
|
||||
are in the laptop shell.
|
||||
|
||||
### Confirm (acceptance)
|
||||
|
||||
- A **relay-node restart does NOT kill the running shell** — the PTY survives on the laptop
|
||||
(base-app PTY≠WS decoupling, INV7).
|
||||
- A **foreign-Origin page cannot open the WS** (CSWSH retained at the edge).
|
||||
- **All 212 base-app tests still pass** (`npm test` in the repo root) — the relay is a layer
|
||||
*underneath* the existing WebSocket; it does not modify `src/`.
|
||||
|
||||
## Retirement → native §4.1 mux (v0.9)
|
||||
|
||||
This scaffold is a **swap, not a rewrite**. Each frp piece maps 1:1 onto a native-mux task:
|
||||
|
||||
| v0.8 frp piece | v0.9 native replacement |
|
||||
|---|---|
|
||||
| frp **yamux** stream multiplexing | §4.1 **mux frame codec + `MuxSession`** (T2/T6) |
|
||||
| frp **subdomain vhost** routing | **`extractSubdomain` + `RouteResolver`** (T7) |
|
||||
| frp **login/NewUserConn** plugin | **`authorizeUpgrade`** thin adapter → P5 `onUpgrade` (T8) |
|
||||
| frp shared `clientToken`/`agentToken` | **capability tokens** (P5) + **mTLS** agent listener (T9) |
|
||||
| frp opaque TCP/WS forwarding | **opaque byte splice** in `relay-node` (T10, INV2/INV11) |
|
||||
|
||||
Because both speak subdomain routing + opaque byte forwarding, ciphertext (P4's `E2EEnvelope`) drops
|
||||
into DATA frames without reshaping anything (INV2). Even in v0.8, **no terminal bytes are parsed**
|
||||
— frp forwards opaque streams (INV11).
|
||||
26
term-relay/frp-scaffold/frps.toml
Normal file
26
term-relay/frp-scaffold/frps.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
# T12 · frp server (frps) — v0.8 MVP transport stepping-stone.
|
||||
#
|
||||
# Single-node frps on the VPS with WILDCARD vhost so `*.term.<domain>` routes by subdomain to
|
||||
# each agent's proxy (EXPLORE §5.4). Contract-compatible with the native §4.1 mux so the v0.9 swap
|
||||
# reshapes nothing (frp subdomain vhost → T7 router; frp login plugin → T8 gate; yamux → §4.1 mux).
|
||||
#
|
||||
# TLS: terminate here OR at Cloudflare in front (M6 scheme-following preserved end-to-end). Values
|
||||
# below reference environment via your process manager; DO NOT commit real secrets (INV5/INV9).
|
||||
|
||||
bindPort = 7000 # agent (frpc) control connection
|
||||
|
||||
# Wildcard subdomain vhost — the tenant routing substrate.
|
||||
vhostHTTPSPort = 443 # browser-facing wss:// (or set vhostHTTPPort behind Cloudflare TLS)
|
||||
subdomainHost = "term.example.com" # = BASE_DOMAIN; *.term.example.com routes by leftmost label
|
||||
|
||||
# Deny-by-default auth at the edge (the auth the base app never had, EXPLORE §5.5).
|
||||
# The server-plugin delegates EVERY Login/NewProxy/NewUserConn decision to P3's control plane
|
||||
# via plugin-hook.ts — no tenancy logic lives in frps (v0.8 shared-token gate is a KNOWN,
|
||||
# temporary shortcut, replaced by capability tokens + mTLS in v0.9).
|
||||
[[httpPlugins]]
|
||||
name = "control-plane-authz"
|
||||
addr = "127.0.0.1:9001" # plugin-hook.ts HTTP shim → P3 authz endpoint
|
||||
path = "/handler"
|
||||
ops = ["Login", "NewProxy", "NewUserConn"]
|
||||
|
||||
# Opaque forwarding only — frps forwards TCP/WS streams and parses NO terminal bytes (INV11).
|
||||
49
term-relay/frp-scaffold/plugin-hook.ts
Normal file
49
term-relay/frp-scaffold/plugin-hook.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* T12 · frp server-plugin HTTP shim (v0.8 MVP stepping-stone). A THIN adapter implementing frp's
|
||||
* `Login` / `NewProxy` / `NewUserConn` server-plugin hooks by DELEGATING the decision to P3's
|
||||
* control-plane authz endpoint. NO tenancy logic lives here — it forwards and enforces the
|
||||
* deny-by-default answer. Malformed hook bodies are rejected at the Zod boundary.
|
||||
*
|
||||
* Retirement (v0.9): frp login plugin → T8 upgrade gate; frp subdomain vhost → T7 router;
|
||||
* frp yamux → the native §4.1 mux. This shim is deleted when the native mux lands.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
|
||||
export type FrpOp = 'Login' | 'NewProxy' | 'NewUserConn'
|
||||
|
||||
/** frp server-plugin request envelope: `{ version, op, content }` (content shape varies by op). */
|
||||
const FrpHookRequestSchema = z
|
||||
.object({
|
||||
version: z.string().min(1),
|
||||
op: z.enum(['Login', 'NewProxy', 'NewUserConn']),
|
||||
content: z.record(z.unknown()),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type FrpHookRequest = z.infer<typeof FrpHookRequestSchema>
|
||||
|
||||
/** frp plugin response: reject (deny) OR pass-through unchanged. */
|
||||
export type FrpHookResponse =
|
||||
| { readonly reject: true; readonly reject_reason: string }
|
||||
| { readonly reject: false; readonly unchange: true }
|
||||
|
||||
/** The control-plane (P3) decision the shim forwards to — it owns ALL tenancy logic. */
|
||||
export interface ControlPlaneAuthz {
|
||||
authorize(op: FrpOp, content: Readonly<Record<string, unknown>>): Promise<{ allow: boolean }>
|
||||
}
|
||||
|
||||
const REJECT_MALFORMED: FrpHookResponse = { reject: true, reject_reason: 'malformed hook request' }
|
||||
const REJECT_DENIED: FrpHookResponse = { reject: true, reject_reason: 'denied by control plane' }
|
||||
const ALLOW: FrpHookResponse = { reject: false, unchange: true }
|
||||
|
||||
/**
|
||||
* Handle one frp server-plugin hook call. Validates the body (deny-by-default on malformed),
|
||||
* forwards the decision to P3, and maps allow→pass-through / deny→reject. Never decides tenancy.
|
||||
*/
|
||||
export async function handleFrpHook(raw: unknown, authz: ControlPlaneAuthz): Promise<FrpHookResponse> {
|
||||
const parsed = FrpHookRequestSchema.safeParse(raw)
|
||||
if (!parsed.success) return REJECT_MALFORMED
|
||||
const { op, content } = parsed.data
|
||||
const decision = await authz.authorize(op, content)
|
||||
return decision.allow ? ALLOW : REJECT_DENIED
|
||||
}
|
||||
76
term-relay/mux/flow-control.ts
Normal file
76
term-relay/mux/flow-control.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* T4 · Credit-based per-stream flow control (§4.1) — pure, in-memory, deny-by-default.
|
||||
*
|
||||
* Each stream has an independent send window: exhausting stream A's credit MUST NOT affect
|
||||
* stream B (heavy `vim`/`top` redraw can't starve another stream). Unknown/released streams
|
||||
* carry no credit (deny-by-default: prevents send-after-close write amplification).
|
||||
* Immutable-style updates (replace the record, never mutate in place).
|
||||
*/
|
||||
export const DEFAULT_INITIAL_WINDOW = 256 * 1024
|
||||
export const WINDOW_REPLENISH_THRESHOLD = 0.5 // emit WINDOW_UPDATE when half consumed
|
||||
|
||||
interface StreamCredit {
|
||||
readonly window: number // remaining SEND credit
|
||||
readonly initial: number // initial window (for replenish threshold)
|
||||
readonly delivered: number // RECEIVED bytes since last replenish
|
||||
}
|
||||
|
||||
export interface FlowController {
|
||||
registerStream(streamId: number, initialWindow: number): void
|
||||
releaseStream(streamId: number): void
|
||||
canSend(streamId: number, bytes: number): boolean
|
||||
consumeSendCredit(streamId: number, bytes: number): void
|
||||
grantCredit(streamId: number, credit: number): void
|
||||
creditFor(streamId: number): number
|
||||
onDelivered(streamId: number, bytes: number): number
|
||||
}
|
||||
|
||||
export function createFlowController(): FlowController {
|
||||
const streams = new Map<number, StreamCredit>()
|
||||
|
||||
return {
|
||||
registerStream(streamId, initialWindow) {
|
||||
streams.set(streamId, { window: initialWindow, initial: initialWindow, delivered: 0 })
|
||||
},
|
||||
|
||||
releaseStream(streamId) {
|
||||
streams.delete(streamId)
|
||||
},
|
||||
|
||||
canSend(streamId, bytes) {
|
||||
const c = streams.get(streamId)
|
||||
if (c === undefined) return false // deny-by-default (unknown/released stream)
|
||||
return bytes <= c.window
|
||||
},
|
||||
|
||||
consumeSendCredit(streamId, bytes) {
|
||||
const c = streams.get(streamId)
|
||||
if (c === undefined) return
|
||||
const nextWindow = c.window - bytes
|
||||
// Never go negative (canSend gates callers; clamp defensively).
|
||||
streams.set(streamId, { ...c, window: Math.max(0, nextWindow) })
|
||||
},
|
||||
|
||||
grantCredit(streamId, credit) {
|
||||
const c = streams.get(streamId)
|
||||
if (c === undefined) return
|
||||
streams.set(streamId, { ...c, window: c.window + credit })
|
||||
},
|
||||
|
||||
creditFor(streamId) {
|
||||
return streams.get(streamId)?.window ?? 0
|
||||
},
|
||||
|
||||
onDelivered(streamId, bytes) {
|
||||
const c = streams.get(streamId)
|
||||
if (c === undefined) return 0
|
||||
const delivered = c.delivered + bytes
|
||||
if (delivered >= c.initial * WINDOW_REPLENISH_THRESHOLD) {
|
||||
streams.set(streamId, { ...c, delivered: 0 })
|
||||
return delivered // replenish this many bytes of credit back to the peer
|
||||
}
|
||||
streams.set(streamId, { ...c, delivered })
|
||||
return 0
|
||||
},
|
||||
}
|
||||
}
|
||||
28
term-relay/mux/frame-codec.ts
Normal file
28
term-relay/mux/frame-codec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* T2 · Mux frame codec (§4.1) — the 15-byte header + opaque payload, and the CBOR/int
|
||||
* control-frame payload codecs.
|
||||
*
|
||||
* The binary codec is FROZEN in `relay-contracts` (INDEX §4.1, OQ2 resolved: the codec ships
|
||||
* in contracts so P1 relay and P2 agent share ONE encoder). P1 re-exports it as the stable
|
||||
* `mux/` surface that T6 (mux-session) and the data plane consume, adding NO wire behavior.
|
||||
*
|
||||
* Security (INV2/INV11): this module imports NO terminal/ANSI parser. DATA payloads are opaque
|
||||
* `Uint8Array` and are never inspected here; only OPEN/WINDOW_UPDATE/GOAWAY control frames are
|
||||
* shape-validated (via the contracts' Zod guards).
|
||||
*/
|
||||
export {
|
||||
MUX_HEADER_BYTES,
|
||||
MUX_VERSION,
|
||||
encodeMuxFrame,
|
||||
decodeHeader,
|
||||
decodeMuxFrame,
|
||||
encodeOpen,
|
||||
decodeOpen,
|
||||
encodeWindowUpdate,
|
||||
decodeWindowUpdate,
|
||||
encodeGoaway,
|
||||
decodeGoaway,
|
||||
} from 'relay-contracts'
|
||||
|
||||
export { TYPE_TO_BYTE, BYTE_TO_TYPE } from './type-bytes.js'
|
||||
export type { MuxFrameType, MuxFrameHeader, MuxOpen, GoAwayReason } from 'relay-contracts'
|
||||
22
term-relay/mux/frame-guards.ts
Normal file
22
term-relay/mux/frame-guards.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* T2 · Boundary Zod validation of control-frame payloads (OPEN / WINDOW_UPDATE / GOAWAY).
|
||||
*
|
||||
* The decode+validate functions live in `relay-contracts` (they CBOR/int-decode then Zod-guard,
|
||||
* throwing `ContractDecodeError` on a bad shape — never a partial value, INV boundary validation).
|
||||
* P1 re-exports them plus a small `assertWithinFrameCeiling` helper the mux session uses to reject
|
||||
* an oversized `payloadLen` before allocating (kills the OOM footgun, T6 frame-ceiling case).
|
||||
*
|
||||
* Security: NEVER validates DATA content (INV2) — DATA is opaque bytes.
|
||||
*/
|
||||
import { ContractDecodeError } from 'relay-contracts'
|
||||
|
||||
export { decodeOpen, decodeWindowUpdate, decodeGoaway } from 'relay-contracts'
|
||||
|
||||
/** Throw if a decoded `payloadLen` exceeds the configured ceiling (caller RSTs the stream). */
|
||||
export function assertWithinFrameCeiling(payloadLen: number, maxFrameBytes: number): void {
|
||||
if (payloadLen > maxFrameBytes) {
|
||||
throw new ContractDecodeError(
|
||||
`frame payloadLen ${payloadLen} exceeds ceiling ${maxFrameBytes}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
96
term-relay/mux/heartbeat.ts
Normal file
96
term-relay/mux/heartbeat.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* T5 · Heartbeat / liveness (§4.1) — 15s PING/PONG with an injectable timer.
|
||||
*
|
||||
* PING carries a fresh 8-byte crypto-random token every interval; the matching PONG must echo
|
||||
* the EXACT outstanding token to reset the miss counter. An unmatched/stale/replayed PONG is
|
||||
* ignored (INV13-adjacent: a replayed PONG can't keep a dead/hijacked tunnel "alive").
|
||||
* `HEARTBEAT_MISS_LIMIT` consecutive missed PONGs (~45s) ⇒ `onDead` fires exactly once.
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
export const HEARTBEAT_INTERVAL_MS = 15_000
|
||||
export const HEARTBEAT_MISS_LIMIT = 3
|
||||
const TOKEN_BYTES = 8
|
||||
|
||||
export interface ScheduleHandle {
|
||||
cancel(): void
|
||||
}
|
||||
|
||||
export interface HeartbeatDeps {
|
||||
sendPing(token: Uint8Array): void
|
||||
onDead(): void
|
||||
now?: () => number
|
||||
schedule?: (fn: () => void, ms: number) => ScheduleHandle
|
||||
intervalMs?: number
|
||||
}
|
||||
|
||||
export interface Heartbeat {
|
||||
start(): void
|
||||
stop(): void
|
||||
onPong(token: Uint8Array): void
|
||||
}
|
||||
|
||||
function defaultSchedule(fn: () => void, ms: number): ScheduleHandle {
|
||||
const id = setInterval(fn, ms)
|
||||
return { cancel: () => clearInterval(id) }
|
||||
}
|
||||
|
||||
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function createHeartbeat(deps: HeartbeatDeps): Heartbeat {
|
||||
const schedule = deps.schedule ?? defaultSchedule
|
||||
const intervalMs = deps.intervalMs ?? HEARTBEAT_INTERVAL_MS
|
||||
|
||||
let handle: ScheduleHandle | null = null
|
||||
let outstanding: Uint8Array | null = null // token awaiting a PONG, or null once matched
|
||||
let misses = 0
|
||||
let dead = false
|
||||
|
||||
function tick(): void {
|
||||
if (dead) return
|
||||
// If the previous PING was never answered, count a miss.
|
||||
if (outstanding !== null) {
|
||||
misses += 1
|
||||
if (misses >= HEARTBEAT_MISS_LIMIT) {
|
||||
dead = true
|
||||
stop()
|
||||
deps.onDead()
|
||||
return
|
||||
}
|
||||
}
|
||||
const token = new Uint8Array(randomBytes(TOKEN_BYTES))
|
||||
outstanding = token
|
||||
deps.sendPing(token)
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (handle !== null) {
|
||||
handle.cancel()
|
||||
handle = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (handle !== null || dead) return
|
||||
// Send the first PING immediately, then on every interval.
|
||||
tick()
|
||||
handle = schedule(tick, intervalMs)
|
||||
},
|
||||
stop,
|
||||
onPong(token) {
|
||||
if (dead || outstanding === null) return
|
||||
if (bytesEqual(token, outstanding)) {
|
||||
outstanding = null
|
||||
misses = 0
|
||||
}
|
||||
// A stale/unknown token is ignored — it does NOT reset the miss counter.
|
||||
},
|
||||
}
|
||||
}
|
||||
296
term-relay/mux/mux-session.ts
Normal file
296
term-relay/mux/mux-session.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* T6 · Mux session — the multiplexer over ONE WebSocket tunnel. Assembles the T2 codec,
|
||||
* T3 stream state machine, T4 credit flow-control, and T5 heartbeat into a demux/dispatch loop.
|
||||
*
|
||||
* Security (INV2/INV5/INV11): DATA payloads are copied OPAQUE and never inspected; buffers are
|
||||
* per-stream, per-session (no global pool → no cross-tenant buffer bleed) and freed on CLOSE;
|
||||
* this module imports NO terminal/ANSI parser. An inbound DATA for an unknown/closed stream, an
|
||||
* illegal transition, or a `payloadLen > maxFrameBytes` frame RSTs that ONE stream — the tunnel
|
||||
* stays up (INV13). Ordering is preserved and never reordered/deduped (so P4's `seq` holds).
|
||||
*/
|
||||
import { concatBytes, GOAWAY_CODE_TO_REASON, type MuxOpen } from 'relay-contracts'
|
||||
import {
|
||||
MUX_HEADER_BYTES,
|
||||
encodeMuxFrame,
|
||||
decodeHeader,
|
||||
encodeOpen,
|
||||
decodeOpen,
|
||||
encodeWindowUpdate,
|
||||
decodeWindowUpdate,
|
||||
encodeGoaway,
|
||||
type MuxFrameHeader,
|
||||
type MuxFrameType,
|
||||
} from './frame-codec.js'
|
||||
import { assertWithinFrameCeiling } from './frame-guards.js'
|
||||
import {
|
||||
initialStreamState,
|
||||
nextStreamState,
|
||||
isTerminal,
|
||||
type StreamState,
|
||||
} from './stream.js'
|
||||
import { createFlowController, type FlowController } from './flow-control.js'
|
||||
import {
|
||||
createHeartbeat,
|
||||
type Heartbeat,
|
||||
type ScheduleHandle,
|
||||
} from './heartbeat.js'
|
||||
|
||||
export interface MuxStreamHandle {
|
||||
readonly streamId: number
|
||||
writeData(payload: Uint8Array): boolean // false ⇒ backpressured (buffered until WINDOW_UPDATE)
|
||||
close(rst?: boolean): void
|
||||
onData(cb: (payload: Uint8Array) => void): void // inbound opaque bytes for THIS stream (INV2)
|
||||
onClose(cb: (rst: boolean) => void): void // remote/RST close of THIS stream
|
||||
}
|
||||
|
||||
export type MuxRole = 'relay' | 'agent'
|
||||
|
||||
export interface MuxSessionDeps {
|
||||
role: MuxRole
|
||||
sendWire(frame: Uint8Array): void
|
||||
onOpen(open: MuxOpen, stream: MuxStreamHandle): void
|
||||
onData(streamId: number, payload: Uint8Array): void // fallback if no per-stream subscriber
|
||||
onClose(streamId: number, rst: boolean): void
|
||||
onDead(): void
|
||||
maxFrameBytes: number
|
||||
initialWindowBytes: number
|
||||
// P1-owned testability seam (NOT a frozen contract): inject the heartbeat timer.
|
||||
schedule?: (fn: () => void, ms: number) => ScheduleHandle
|
||||
heartbeatIntervalMs?: number
|
||||
}
|
||||
|
||||
export interface MuxSession {
|
||||
openStream(open: MuxOpen): MuxStreamHandle
|
||||
onWire(buf: Uint8Array): void
|
||||
drain(lastStreamId: number, reason: number): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
interface StreamCtx {
|
||||
state: StreamState
|
||||
readonly handle: MuxStreamHandle
|
||||
sendBuffer: Uint8Array[]
|
||||
dataSub: ((payload: Uint8Array) => void) | null
|
||||
closeSub: ((rst: boolean) => void) | null
|
||||
}
|
||||
|
||||
const EMPTY = new Uint8Array(0)
|
||||
|
||||
export function createMuxSession(deps: MuxSessionDeps): MuxSession {
|
||||
const flow: FlowController = createFlowController()
|
||||
const streams = new Map<number, StreamCtx>()
|
||||
let pending: Uint8Array = EMPTY
|
||||
let nextStreamId = 1 // relay allocates monotonic, never reused
|
||||
let draining = false
|
||||
let closed = false
|
||||
|
||||
const heartbeat: Heartbeat = createHeartbeat({
|
||||
sendPing: (token) => sendFrame('ping', 0, token, false, false),
|
||||
onDead: () => deps.onDead(),
|
||||
...(deps.schedule ? { schedule: deps.schedule } : {}),
|
||||
...(deps.heartbeatIntervalMs !== undefined ? { intervalMs: deps.heartbeatIntervalMs } : {}),
|
||||
})
|
||||
|
||||
function sendFrame(
|
||||
type: MuxFrameType,
|
||||
streamId: number,
|
||||
payload: Uint8Array,
|
||||
fin: boolean,
|
||||
rst: boolean,
|
||||
): void {
|
||||
if (closed) return
|
||||
const header: MuxFrameHeader = {
|
||||
version: 1,
|
||||
type,
|
||||
fin,
|
||||
rst,
|
||||
streamId,
|
||||
payloadLen: payload.length,
|
||||
}
|
||||
deps.sendWire(encodeMuxFrame(header, payload))
|
||||
}
|
||||
|
||||
function makeHandle(streamId: number): MuxStreamHandle {
|
||||
return {
|
||||
streamId,
|
||||
writeData: (payload) => writeData(streamId, payload),
|
||||
close: (rst = false) => localClose(streamId, rst),
|
||||
onData: (cb) => {
|
||||
const ctx = streams.get(streamId)
|
||||
if (ctx) ctx.dataSub = cb
|
||||
},
|
||||
onClose: (cb) => {
|
||||
const ctx = streams.get(streamId)
|
||||
if (ctx) ctx.closeSub = cb
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function registerStream(streamId: number, state: StreamState): StreamCtx {
|
||||
const handle = makeHandle(streamId)
|
||||
const ctx: StreamCtx = { state, handle, sendBuffer: [], dataSub: null, closeSub: null }
|
||||
streams.set(streamId, ctx)
|
||||
flow.registerStream(streamId, deps.initialWindowBytes)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function writeData(streamId: number, payload: Uint8Array): boolean {
|
||||
const ctx = streams.get(streamId)
|
||||
if (ctx === undefined || isTerminal(ctx.state)) return false
|
||||
if (payload.length > deps.maxFrameBytes) return false // never emit an over-ceiling frame
|
||||
if (!flow.canSend(streamId, payload.length)) {
|
||||
ctx.sendBuffer.push(payload) // backpressure: buffer until WINDOW_UPDATE
|
||||
return false
|
||||
}
|
||||
flow.consumeSendCredit(streamId, payload.length)
|
||||
sendFrame('data', streamId, payload, false, false)
|
||||
return true
|
||||
}
|
||||
|
||||
function flushBuffer(streamId: number): void {
|
||||
const ctx = streams.get(streamId)
|
||||
if (ctx === undefined) return
|
||||
while (ctx.sendBuffer.length > 0) {
|
||||
const next = ctx.sendBuffer[0]!
|
||||
if (!flow.canSend(streamId, next.length)) break
|
||||
ctx.sendBuffer.shift()
|
||||
flow.consumeSendCredit(streamId, next.length)
|
||||
sendFrame('data', streamId, next, false, false)
|
||||
}
|
||||
}
|
||||
|
||||
function localClose(streamId: number, rst: boolean): void {
|
||||
const ctx = streams.get(streamId)
|
||||
if (ctx === undefined) return
|
||||
sendFrame('close', streamId, EMPTY, !rst, rst)
|
||||
cleanupStream(streamId)
|
||||
}
|
||||
|
||||
function cleanupStream(streamId: number): void {
|
||||
flow.releaseStream(streamId)
|
||||
streams.delete(streamId)
|
||||
}
|
||||
|
||||
/** RST exactly ONE stream (illegal transition / unknown stream / ceiling) — tunnel stays up. */
|
||||
function rstStream(streamId: number): void {
|
||||
const ctx = streams.get(streamId)
|
||||
sendFrame('close', streamId, EMPTY, false, true)
|
||||
if (ctx) {
|
||||
if (ctx.closeSub) ctx.closeSub(true)
|
||||
else deps.onClose(streamId, true)
|
||||
}
|
||||
cleanupStream(streamId)
|
||||
}
|
||||
|
||||
function deliverData(ctx: StreamCtx, streamId: number, payload: Uint8Array): void {
|
||||
if (ctx.dataSub) ctx.dataSub(payload)
|
||||
else deps.onData(streamId, payload)
|
||||
// Receiver-side replenishment: grant credit back once the threshold is crossed.
|
||||
const replenish = flow.onDelivered(streamId, payload.length)
|
||||
if (replenish > 0) sendFrame('windowUpdate', streamId, encodeWindowUpdate(replenish), false, false)
|
||||
}
|
||||
|
||||
function dispatch(header: MuxFrameHeader, payload: Uint8Array): void {
|
||||
const { type, streamId, fin, rst } = header
|
||||
|
||||
// Connection-level control (streamId 0).
|
||||
if (streamId === 0) {
|
||||
if (type === 'ping') sendFrame('pong', 0, payload, false, false)
|
||||
else if (type === 'pong') heartbeat.onPong(payload)
|
||||
else if (type === 'goaway') draining = true
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'open') {
|
||||
if (deps.role !== 'agent') return // relay never receives OPEN
|
||||
if (streams.get(streamId) !== undefined) {
|
||||
rstStream(streamId) // re-OPEN of a live stream is illegal
|
||||
return
|
||||
}
|
||||
const open = decodeOpen(payload)
|
||||
const ctx = registerStream(streamId, 'open')
|
||||
deps.onOpen(open, ctx.handle)
|
||||
return
|
||||
}
|
||||
|
||||
const ctx = streams.get(streamId)
|
||||
if (ctx === undefined) {
|
||||
// DATA/CLOSE/WU for an unknown/closed stream → RST that stream only.
|
||||
sendFrame('close', streamId, EMPTY, false, true)
|
||||
return
|
||||
}
|
||||
|
||||
const transition = nextStreamState(ctx.state, type, fin, 'inbound')
|
||||
if ('illegal' in transition) {
|
||||
rstStream(streamId)
|
||||
return
|
||||
}
|
||||
ctx.state = transition.next
|
||||
|
||||
if (type === 'data') {
|
||||
deliverData(ctx, streamId, payload)
|
||||
} else if (type === 'windowUpdate') {
|
||||
flow.grantCredit(streamId, decodeWindowUpdate(payload))
|
||||
flushBuffer(streamId)
|
||||
} else if (type === 'close') {
|
||||
if (ctx.closeSub) ctx.closeSub(rst)
|
||||
else deps.onClose(streamId, rst)
|
||||
cleanupStream(streamId)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
openStream(open) {
|
||||
if (draining || closed) throw new Error('session draining/closed: cannot open new stream')
|
||||
const streamId = nextStreamId++
|
||||
const ctx = registerStream(streamId, 'open')
|
||||
const full: MuxOpen = { ...open, streamId }
|
||||
sendFrame('open', streamId, encodeOpen(full), false, false)
|
||||
heartbeat.start()
|
||||
return ctx.handle
|
||||
},
|
||||
|
||||
onWire(buf) {
|
||||
if (closed) return
|
||||
heartbeat.start()
|
||||
pending = pending.length === 0 ? buf : concatBytes([pending, buf])
|
||||
for (;;) {
|
||||
if (pending.length < MUX_HEADER_BYTES) break
|
||||
let header: MuxFrameHeader
|
||||
try {
|
||||
header = decodeHeader(pending)
|
||||
} catch {
|
||||
// Unrecoverable framing error — drop the tunnel's buffer (do NOT parse further).
|
||||
pending = EMPTY
|
||||
break
|
||||
}
|
||||
try {
|
||||
assertWithinFrameCeiling(header.payloadLen, deps.maxFrameBytes)
|
||||
} catch {
|
||||
if (header.streamId > 0) rstStream(header.streamId)
|
||||
else deps.onDead()
|
||||
pending = EMPTY
|
||||
break
|
||||
}
|
||||
const total = MUX_HEADER_BYTES + header.payloadLen
|
||||
if (pending.length < total) break // wait for the rest of the payload
|
||||
const payload = pending.slice(MUX_HEADER_BYTES, total)
|
||||
pending = pending.slice(total)
|
||||
dispatch(header, payload)
|
||||
}
|
||||
},
|
||||
|
||||
drain(lastStreamId, reason) {
|
||||
draining = true
|
||||
const label = GOAWAY_CODE_TO_REASON[reason]
|
||||
if (label !== undefined) sendFrame('goaway', 0, encodeGoaway(lastStreamId, label), false, false)
|
||||
},
|
||||
|
||||
close() {
|
||||
closed = true
|
||||
heartbeat.stop()
|
||||
streams.clear()
|
||||
pending = EMPTY
|
||||
},
|
||||
}
|
||||
}
|
||||
88
term-relay/mux/stream.ts
Normal file
88
term-relay/mux/stream.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* T3 · Stream state machine (§4.1) — a PURE reducer over the per-stream lifecycle.
|
||||
*
|
||||
* Lifecycle: `OPEN → (DATA | WINDOW_UPDATE)* → CLOSE`. An illegal transition returns
|
||||
* `{ illegal: true }` so the caller RSTs that ONE stream (never the tunnel) — this is what
|
||||
* lets P4's end-to-end `seq` monotonicity mean something (INV13). No I/O, no input mutation.
|
||||
*/
|
||||
import type { MuxFrameType } from 'relay-contracts'
|
||||
|
||||
export type StreamState =
|
||||
| 'idle'
|
||||
| 'open'
|
||||
| 'halfClosedLocal'
|
||||
| 'halfClosedRemote'
|
||||
| 'closed'
|
||||
|
||||
export type StreamDirection = 'inbound' | 'outbound'
|
||||
|
||||
export type StreamTransition = { readonly next: StreamState } | { readonly illegal: true }
|
||||
|
||||
const ILLEGAL: StreamTransition = { illegal: true }
|
||||
|
||||
export function initialStreamState(): StreamState {
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
export function isTerminal(s: StreamState): boolean {
|
||||
return s === 'closed'
|
||||
}
|
||||
|
||||
/** True when the state still accepts DATA/WINDOW_UPDATE flow in at least one direction. */
|
||||
function isFlowing(s: StreamState): boolean {
|
||||
return s === 'open' || s === 'halfClosedLocal' || s === 'halfClosedRemote'
|
||||
}
|
||||
|
||||
/** The half-closed state produced when `dir` sends FIN while fully open. */
|
||||
function halfCloseFor(dir: StreamDirection): StreamState {
|
||||
return dir === 'outbound' ? 'halfClosedLocal' : 'halfClosedRemote'
|
||||
}
|
||||
|
||||
/**
|
||||
* Total transition function. Returns a NEW state (never mutates). `fin` marks a DATA/CLOSE
|
||||
* frame that half/fully closes the sending direction; `dir` is which side sent the frame.
|
||||
*/
|
||||
export function nextStreamState(
|
||||
current: StreamState,
|
||||
type: MuxFrameType,
|
||||
fin: boolean,
|
||||
dir: StreamDirection,
|
||||
): StreamTransition {
|
||||
if (current === 'closed') return ILLEGAL
|
||||
|
||||
switch (type) {
|
||||
case 'open':
|
||||
// OPEN is only legal from idle; re-OPEN of a live stream is illegal.
|
||||
return current === 'idle' ? { next: 'open' } : ILLEGAL
|
||||
|
||||
case 'data':
|
||||
case 'windowUpdate': {
|
||||
if (current === 'idle') return ILLEGAL // DATA/WU before OPEN
|
||||
if (!isFlowing(current)) return ILLEGAL
|
||||
// WINDOW_UPDATE never carries FIN; only DATA may half-close its direction.
|
||||
if (type === 'windowUpdate') return { next: current }
|
||||
if (!fin) return { next: current }
|
||||
return finTransition(current, dir)
|
||||
}
|
||||
|
||||
case 'close': {
|
||||
if (current === 'idle') return ILLEGAL // CLOSE before OPEN
|
||||
return { next: 'closed' }
|
||||
}
|
||||
|
||||
// ping/pong/goaway are connection-level (streamId 0), never per-stream.
|
||||
default:
|
||||
return ILLEGAL
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a FIN from `dir` to a flowing state; both-side FIN ⇒ closed. */
|
||||
function finTransition(current: StreamState, dir: StreamDirection): StreamTransition {
|
||||
const half = halfCloseFor(dir)
|
||||
if (current === 'open') return { next: half }
|
||||
// Already half-closed on the OTHER side, and now this side FINs ⇒ fully closed.
|
||||
if (current === 'halfClosedLocal' && dir === 'inbound') return { next: 'closed' }
|
||||
if (current === 'halfClosedRemote' && dir === 'outbound') return { next: 'closed' }
|
||||
// Re-FIN on the already-closed direction is illegal.
|
||||
return ILLEGAL
|
||||
}
|
||||
21
term-relay/mux/type-bytes.ts
Normal file
21
term-relay/mux/type-bytes.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* T2 · MuxFrameType ⇄ byte + flag bit maps (§4.1).
|
||||
*
|
||||
* The frozen wire codes live in `relay-contracts` (§4.1). P1 imports them read-only and
|
||||
* re-exports the local stable surface the mux layer consumes — it NEVER redefines a code.
|
||||
*/
|
||||
export {
|
||||
MUX_HEADER_BYTES,
|
||||
MUX_VERSION,
|
||||
MUX_TYPE_TO_CODE as TYPE_TO_BYTE,
|
||||
MUX_CODE_TO_TYPE as BYTE_TO_TYPE,
|
||||
FLAG_FIN,
|
||||
FLAG_RST,
|
||||
FLAG_RESERVED_MASK,
|
||||
CONNECTION_STREAM_ID,
|
||||
UINT32_MAX,
|
||||
GOAWAY_REASON_TO_CODE,
|
||||
GOAWAY_CODE_TO_REASON,
|
||||
} from 'relay-contracts'
|
||||
|
||||
export type { MuxFrameType, MuxFrameHeader, MuxOpen, GoAwayReason } from 'relay-contracts'
|
||||
1331
term-relay/package-lock.json
generated
Normal file
1331
term-relay/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
term-relay/package.json
Normal file
25
term-relay/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "term-relay",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "P1 — Tunnel & Mux Protocol + stateless relay data plane for the rendezvous-relay service. Native WS mux (§4.1 frame codec via relay-contracts), per-stream credit flow-control, 15s heartbeat, GOAWAY drain, subdomain routing, opaque ciphertext splice, and the relay:revocations subscriber. See docs/PLAN_RELAY_TRANSPORT.md.",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"relay-contracts": "file:../relay-contracts",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
125
term-relay/test/agent-listener.test.ts
Normal file
125
term-relay/test/agent-listener.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import { createAgentListener, type MtlsVerifier, type RouteRegistrar, type TlsServerOptions } from '../data-plane/agent-listener.js'
|
||||
import { loadDataPlaneConfig, type DataPlaneConfig } from '../data-plane/config.js'
|
||||
import type { WebSocketLike } from '../data-plane/ws-like.js'
|
||||
import type { ScheduleHandle } from '../mux/heartbeat.js'
|
||||
|
||||
function cfg(): DataPlaneConfig {
|
||||
return loadDataPlaneConfig({
|
||||
BASE_DOMAIN: 'term.example.com',
|
||||
TLS_CERT_PATH: '/c',
|
||||
TLS_KEY_PATH: '/k',
|
||||
AGENT_CA_CERT_PATH: '/ca',
|
||||
RELAY_NODE_ID: 'node-1',
|
||||
HEARTBEAT_INTERVAL_MS: '1000',
|
||||
ROUTE_TTL_MS: '5000',
|
||||
})
|
||||
}
|
||||
|
||||
function fakeWs() {
|
||||
const closeCbs: (() => void)[] = []
|
||||
return {
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
onMessage: vi.fn(),
|
||||
onClose: (h: () => void) => closeCbs.push(h),
|
||||
emitClose: () => closeCbs.forEach((f) => f()),
|
||||
} satisfies WebSocketLike & { emitClose: () => void }
|
||||
}
|
||||
|
||||
function goodMtls(): MtlsVerifier {
|
||||
return { verifyPeer: (cert) => (cert[0] === 1 ? { hostId: 'host-1', accountId: 'acct-1' } : null) }
|
||||
}
|
||||
|
||||
function registrar(): RouteRegistrar {
|
||||
return { register: vi.fn(async () => {}), heartbeat: vi.fn(async () => {}), deregister: vi.fn(async () => {}) }
|
||||
}
|
||||
|
||||
function fakeScheduler() {
|
||||
const fns: (() => void)[] = []
|
||||
const schedule = (fn: () => void): ScheduleHandle => {
|
||||
fns.push(fn)
|
||||
return { cancel: () => {} }
|
||||
}
|
||||
return { schedule, tick: () => fns.forEach((f) => f()) }
|
||||
}
|
||||
|
||||
const flush = () => new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
describe('agent tunnel listener (T9, INV4/INV7/INV14)', () => {
|
||||
test('valid peer cert → MuxSession created, route registered, tunnel indexed by hostId', async () => {
|
||||
const reg = registrar()
|
||||
const l = createAgentListener({ config: cfg(), mtls: goodMtls(), registrar: reg, caBundle: [], onTunnel: () => {}, schedule: fakeScheduler().schedule })
|
||||
l.attach(fakeWs(), new Uint8Array([1]))
|
||||
await flush()
|
||||
expect(reg.register).toHaveBeenCalledWith('host-1', 'node-1', 5000)
|
||||
expect(l.tunnels().get('host-1')?.hostId).toBe('host-1')
|
||||
})
|
||||
|
||||
test('TLS-layer enforcement (Finding-4): server built with requestCert+rejectUnauthorized+ca', () => {
|
||||
let opts: TlsServerOptions | undefined
|
||||
const ca = [new Uint8Array([9, 9])]
|
||||
createAgentListener({
|
||||
config: cfg(),
|
||||
mtls: goodMtls(),
|
||||
registrar: registrar(),
|
||||
caBundle: ca,
|
||||
onTunnel: () => {},
|
||||
tlsServerFactory: (o) => {
|
||||
opts = o
|
||||
return { close: () => {} }
|
||||
},
|
||||
})
|
||||
expect(opts).toEqual({ requestCert: true, rejectUnauthorized: true, ca })
|
||||
})
|
||||
|
||||
test('invalid cert (verifyPeer null) → ws closed, NO route registered (INV14)', async () => {
|
||||
const reg = registrar()
|
||||
const ws = fakeWs()
|
||||
const l = createAgentListener({ config: cfg(), mtls: goodMtls(), registrar: reg, caBundle: [], onTunnel: () => {}, schedule: fakeScheduler().schedule })
|
||||
l.attach(ws, new Uint8Array([0])) // cert[0] !== 1 → verifyPeer null
|
||||
await flush()
|
||||
expect(ws.close).toHaveBeenCalled()
|
||||
expect(reg.register).not.toHaveBeenCalled()
|
||||
expect(l.tunnels().size).toBe(0)
|
||||
})
|
||||
|
||||
test('heartbeat: registrar.heartbeat runs on the interval; deregister on ws close', async () => {
|
||||
const reg = registrar()
|
||||
const sched = fakeScheduler()
|
||||
const ws = fakeWs()
|
||||
const l = createAgentListener({ config: cfg(), mtls: goodMtls(), registrar: reg, caBundle: [], onTunnel: () => {}, schedule: sched.schedule })
|
||||
l.attach(ws, new Uint8Array([1]))
|
||||
sched.tick()
|
||||
await flush()
|
||||
expect(reg.heartbeat).toHaveBeenCalledWith('host-1')
|
||||
ws.emitClose()
|
||||
await flush()
|
||||
expect(reg.deregister).toHaveBeenCalledWith('host-1')
|
||||
expect(l.tunnels().size).toBe(0)
|
||||
})
|
||||
|
||||
test('closeTunnel tears down + deregisters (revocation lever, INV12)', async () => {
|
||||
const reg = registrar()
|
||||
const l = createAgentListener({ config: cfg(), mtls: goodMtls(), registrar: reg, caBundle: [], onTunnel: () => {}, schedule: fakeScheduler().schedule })
|
||||
l.attach(fakeWs(), new Uint8Array([1]))
|
||||
l.tunnels().get('host-1')!.closeTunnel()
|
||||
await flush()
|
||||
expect(reg.deregister).toHaveBeenCalledWith('host-1')
|
||||
expect(l.tunnels().size).toBe(0)
|
||||
})
|
||||
|
||||
test('reconnection: a second attach for the same hostId replaces the prior tunnel', async () => {
|
||||
const reg = registrar()
|
||||
const l = createAgentListener({ config: cfg(), mtls: goodMtls(), registrar: reg, caBundle: [], onTunnel: () => {}, schedule: fakeScheduler().schedule })
|
||||
l.attach(fakeWs(), new Uint8Array([1]))
|
||||
const first = l.tunnels().get('host-1')
|
||||
l.attach(fakeWs(), new Uint8Array([1]))
|
||||
const second = l.tunnels().get('host-1')
|
||||
await flush()
|
||||
expect(second).not.toBe(first)
|
||||
expect(reg.deregister).toHaveBeenCalledWith('host-1') // old one deregistered
|
||||
expect(reg.register).toHaveBeenCalledTimes(2)
|
||||
expect(l.tunnels().size).toBe(1)
|
||||
})
|
||||
})
|
||||
57
term-relay/test/config.test.ts
Normal file
57
term-relay/test/config.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import { loadDataPlaneConfig } from '../data-plane/config.js'
|
||||
|
||||
const base = {
|
||||
BASE_DOMAIN: 'term.example.com',
|
||||
TLS_CERT_PATH: '/etc/relay/cert.pem',
|
||||
TLS_KEY_PATH: '/etc/relay/key.pem',
|
||||
AGENT_CA_CERT_PATH: '/etc/relay/ca.pem',
|
||||
RELAY_NODE_ID: 'node-1',
|
||||
} satisfies NodeJS.ProcessEnv
|
||||
|
||||
describe('loadDataPlaneConfig (T1, INV9 fail-fast)', () => {
|
||||
test('happy path returns a frozen typed config with defaults applied', () => {
|
||||
const cfg = loadDataPlaneConfig(base)
|
||||
expect(cfg.baseDomain).toBe('term.example.com')
|
||||
expect(cfg.bindHost).toBe('0.0.0.0')
|
||||
expect(cfg.maxFrameBytes).toBe(1024 * 1024)
|
||||
expect(cfg.initialWindowBytes).toBe(256 * 1024)
|
||||
expect(cfg.heartbeatIntervalMs).toBe(15_000)
|
||||
expect(cfg.routeTtlMs).toBe(45_000)
|
||||
// agentCaChainPath falls back to agentCaCertPath when unset.
|
||||
expect(cfg.agentCaChainPath).toBe('/etc/relay/ca.pem')
|
||||
expect(Object.isFrozen(cfg)).toBe(true)
|
||||
})
|
||||
|
||||
test('throws when BASE_DOMAIN is missing', () => {
|
||||
expect(() => loadDataPlaneConfig({ ...base, BASE_DOMAIN: undefined })).toThrow(/baseDomain/)
|
||||
})
|
||||
|
||||
test('throws when TLS_CERT_PATH / TLS_KEY_PATH missing (fail-fast)', () => {
|
||||
expect(() => loadDataPlaneConfig({ ...base, TLS_CERT_PATH: undefined })).toThrow(/tlsCertPath/)
|
||||
expect(() => loadDataPlaneConfig({ ...base, TLS_KEY_PATH: undefined })).toThrow(/tlsKeyPath/)
|
||||
})
|
||||
|
||||
test('throws when AGENT_CA_CERT_PATH (mTLS trust-anchor) missing — Finding-4 footgun', () => {
|
||||
expect(() => loadDataPlaneConfig({ ...base, AGENT_CA_CERT_PATH: undefined })).toThrow(
|
||||
/agentCaCertPath/,
|
||||
)
|
||||
})
|
||||
|
||||
test('throws on non-numeric MAX_FRAME_BYTES', () => {
|
||||
expect(() => loadDataPlaneConfig({ ...base, MAX_FRAME_BYTES: 'not-a-number' })).toThrow(
|
||||
/maxFrameBytes/,
|
||||
)
|
||||
})
|
||||
|
||||
test('error message never echoes secret values (INV9)', () => {
|
||||
try {
|
||||
loadDataPlaneConfig({ ...base, TLS_KEY_PATH: undefined, MAX_FRAME_BYTES: 'xyz' })
|
||||
throw new Error('should have thrown')
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message
|
||||
expect(msg).not.toContain('/etc/relay/key.pem')
|
||||
expect(msg).not.toContain('xyz')
|
||||
}
|
||||
})
|
||||
})
|
||||
74
term-relay/test/drain.test.ts
Normal file
74
term-relay/test/drain.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import { drainNode, drainHost, DRAIN_REASON } from '../data-plane/drain.js'
|
||||
import type { AgentTunnel, RouteRegistrar } from '../data-plane/agent-listener.js'
|
||||
import type { MuxSession } from '../mux/mux-session.js'
|
||||
|
||||
function fakeTunnel(hostId: string): AgentTunnel & { drainSpy: ReturnType<typeof vi.fn> } {
|
||||
const drainSpy = vi.fn()
|
||||
const session = { openStream: vi.fn(), onWire: vi.fn(), drain: drainSpy, close: vi.fn() } satisfies MuxSession
|
||||
return { hostId, accountId: 'a', session, closeTunnel: vi.fn(), drainSpy }
|
||||
}
|
||||
|
||||
function registrar(): RouteRegistrar {
|
||||
return { register: vi.fn(async () => {}), heartbeat: vi.fn(async () => {}), deregister: vi.fn(async () => {}) }
|
||||
}
|
||||
|
||||
describe('graceful drain + GOAWAY (T11, INV7/INV12)', () => {
|
||||
test('operatorDrain: GOAWAY sent, teardown deferred to the grace window', async () => {
|
||||
const t = fakeTunnel('h1')
|
||||
const reg = registrar()
|
||||
const scheduled: (() => void)[] = []
|
||||
await drainNode({
|
||||
tunnels: () => new Map([['h1', t]]),
|
||||
registrar: reg,
|
||||
reason: DRAIN_REASON.operatorDrain,
|
||||
inFlightGraceMs: 5000,
|
||||
setTimeoutFn: (fn) => scheduled.push(fn),
|
||||
})
|
||||
expect(t.drainSpy).toHaveBeenCalledWith(0, DRAIN_REASON.operatorDrain) // GOAWAY
|
||||
expect(t.closeTunnel).not.toHaveBeenCalled() // still within grace
|
||||
scheduled.forEach((f) => f()) // grace elapses
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(t.closeTunnel).toHaveBeenCalled()
|
||||
expect(reg.deregister).toHaveBeenCalledWith('h1')
|
||||
})
|
||||
|
||||
test('revoked: IMMEDIATE teardown regardless of a large grace (Finding-2/INV12)', async () => {
|
||||
const t = fakeTunnel('h1')
|
||||
const reg = registrar()
|
||||
const setTimeoutFn = vi.fn()
|
||||
await drainHost('h1', {
|
||||
tunnels: () => new Map([['h1', t]]),
|
||||
registrar: reg,
|
||||
reason: DRAIN_REASON.revoked,
|
||||
inFlightGraceMs: 30_000, // must be ignored
|
||||
setTimeoutFn,
|
||||
})
|
||||
expect(t.drainSpy).toHaveBeenCalledWith(0, DRAIN_REASON.revoked)
|
||||
expect(t.closeTunnel).toHaveBeenCalled() // immediate
|
||||
expect(reg.deregister).toHaveBeenCalledWith('h1')
|
||||
expect(setTimeoutFn).not.toHaveBeenCalled() // grace never scheduled
|
||||
})
|
||||
|
||||
test('drainHost targets exactly one host; siblings keep running', async () => {
|
||||
const h1 = fakeTunnel('h1')
|
||||
const h2 = fakeTunnel('h2')
|
||||
const reg = registrar()
|
||||
await drainHost('h1', {
|
||||
tunnels: () => new Map([['h1', h1], ['h2', h2]]),
|
||||
registrar: reg,
|
||||
reason: DRAIN_REASON.revoked,
|
||||
inFlightGraceMs: 0,
|
||||
})
|
||||
expect(h1.closeTunnel).toHaveBeenCalled()
|
||||
expect(h2.closeTunnel).not.toHaveBeenCalled()
|
||||
expect(reg.deregister).toHaveBeenCalledWith('h1')
|
||||
expect(reg.deregister).not.toHaveBeenCalledWith('h2')
|
||||
})
|
||||
|
||||
test('drainHost for an absent host is a no-op (deny-by-default, never a broader kill)', async () => {
|
||||
const reg = registrar()
|
||||
await drainHost('ghost', { tunnels: () => new Map(), registrar: reg, reason: DRAIN_REASON.revoked, inFlightGraceMs: 0 })
|
||||
expect(reg.deregister).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
68
term-relay/test/flow-control.test.ts
Normal file
68
term-relay/test/flow-control.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import {
|
||||
createFlowController,
|
||||
DEFAULT_INITIAL_WINDOW,
|
||||
WINDOW_REPLENISH_THRESHOLD,
|
||||
} from '../mux/flow-control.js'
|
||||
|
||||
describe('credit flow control (T4, §4.1)', () => {
|
||||
test('canSend true until credit exhausted, then false; never negative', () => {
|
||||
const fc = createFlowController()
|
||||
fc.registerStream(1, 100)
|
||||
expect(fc.canSend(1, 100)).toBe(true)
|
||||
fc.consumeSendCredit(1, 100)
|
||||
expect(fc.creditFor(1)).toBe(0)
|
||||
expect(fc.canSend(1, 1)).toBe(false)
|
||||
// Over-consume never drives credit negative.
|
||||
fc.consumeSendCredit(1, 50)
|
||||
expect(fc.creditFor(1)).toBe(0)
|
||||
})
|
||||
|
||||
test('grantCredit unblocks a backpressured stream', () => {
|
||||
const fc = createFlowController()
|
||||
fc.registerStream(1, 10)
|
||||
fc.consumeSendCredit(1, 10)
|
||||
expect(fc.canSend(1, 5)).toBe(false)
|
||||
fc.grantCredit(1, 20)
|
||||
expect(fc.canSend(1, 5)).toBe(true)
|
||||
expect(fc.creditFor(1)).toBe(20)
|
||||
})
|
||||
|
||||
test('onDelivered returns a replenish amount only once the threshold is crossed', () => {
|
||||
const fc = createFlowController()
|
||||
fc.registerStream(1, 100) // threshold = 50 bytes
|
||||
expect(fc.onDelivered(1, 40)).toBe(0)
|
||||
const replenish = fc.onDelivered(1, 20) // cumulative 60 ≥ 50
|
||||
expect(replenish).toBe(60)
|
||||
// Counter reset after replenish.
|
||||
expect(fc.onDelivered(1, 10)).toBe(0)
|
||||
})
|
||||
|
||||
test('unknown streamId is deny-by-default: canSend false, creditFor 0', () => {
|
||||
const fc = createFlowController()
|
||||
expect(fc.canSend(999, 1)).toBe(false)
|
||||
expect(fc.creditFor(999)).toBe(0)
|
||||
expect(fc.onDelivered(999, 100)).toBe(0)
|
||||
})
|
||||
|
||||
test('released streams carry no credit (send-after-close is denied)', () => {
|
||||
const fc = createFlowController()
|
||||
fc.registerStream(1, 100)
|
||||
fc.releaseStream(1)
|
||||
expect(fc.canSend(1, 1)).toBe(false)
|
||||
})
|
||||
|
||||
test('per-stream isolation: exhausting A does not affect B (no cross-stream starvation)', () => {
|
||||
const fc = createFlowController()
|
||||
fc.registerStream(1, 50)
|
||||
fc.registerStream(2, 50)
|
||||
fc.consumeSendCredit(1, 50)
|
||||
expect(fc.canSend(1, 1)).toBe(false)
|
||||
expect(fc.canSend(2, 50)).toBe(true) // B untouched
|
||||
})
|
||||
|
||||
test('exposes the documented constants', () => {
|
||||
expect(DEFAULT_INITIAL_WINDOW).toBe(256 * 1024)
|
||||
expect(WINDOW_REPLENISH_THRESHOLD).toBe(0.5)
|
||||
})
|
||||
})
|
||||
112
term-relay/test/frame-codec.test.ts
Normal file
112
term-relay/test/frame-codec.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import {
|
||||
MUX_HEADER_BYTES,
|
||||
encodeMuxFrame,
|
||||
decodeHeader,
|
||||
decodeMuxFrame,
|
||||
encodeOpen,
|
||||
decodeOpen,
|
||||
encodeWindowUpdate,
|
||||
decodeWindowUpdate,
|
||||
encodeGoaway,
|
||||
decodeGoaway,
|
||||
TYPE_TO_BYTE,
|
||||
BYTE_TO_TYPE,
|
||||
type MuxFrameHeader,
|
||||
type MuxFrameType,
|
||||
type MuxOpen,
|
||||
} from '../mux/frame-codec.js'
|
||||
|
||||
const ALL_TYPES: readonly MuxFrameType[] = [
|
||||
'open',
|
||||
'data',
|
||||
'close',
|
||||
'ping',
|
||||
'pong',
|
||||
'windowUpdate',
|
||||
'goaway',
|
||||
]
|
||||
|
||||
function header(over: Partial<MuxFrameHeader>): MuxFrameHeader {
|
||||
return { version: 1, type: 'data', fin: false, rst: false, streamId: 7, payloadLen: 0, ...over }
|
||||
}
|
||||
|
||||
describe('frame codec (T2, §4.1) — re-exported from the frozen relay-contracts codec', () => {
|
||||
test('round-trips every frame type incl. fin/rst bit packing', () => {
|
||||
for (const type of ALL_TYPES) {
|
||||
const payload = new Uint8Array([1, 2, 3])
|
||||
const h = header({ type, fin: true, rst: false, streamId: 42, payloadLen: payload.length })
|
||||
const decoded = decodeMuxFrame(encodeMuxFrame(h, payload))
|
||||
expect(decoded.header.type).toBe(type)
|
||||
expect(decoded.header.fin).toBe(true)
|
||||
expect(decoded.header.rst).toBe(false)
|
||||
expect(decoded.header.streamId).toBe(42)
|
||||
expect([...decoded.payload]).toEqual([1, 2, 3])
|
||||
}
|
||||
})
|
||||
|
||||
test('streamId=0 link-level frames decode with streamId===0', () => {
|
||||
const h = header({ type: 'ping', streamId: 0, payloadLen: 0 })
|
||||
expect(decodeHeader(encodeMuxFrame(h, new Uint8Array(0))).streamId).toBe(0)
|
||||
})
|
||||
|
||||
test('rst bit round-trips independently of fin', () => {
|
||||
const h = header({ type: 'close', fin: false, rst: true, payloadLen: 0 })
|
||||
const d = decodeHeader(encodeMuxFrame(h, new Uint8Array(0)))
|
||||
expect(d.rst).toBe(true)
|
||||
expect(d.fin).toBe(false)
|
||||
})
|
||||
|
||||
test('type ⇄ byte maps agree with §4.1 (0x01 OPEN … 0x07 GOAWAY)', () => {
|
||||
expect(TYPE_TO_BYTE.open).toBe(0x01)
|
||||
expect(TYPE_TO_BYTE.goaway).toBe(0x07)
|
||||
expect(BYTE_TO_TYPE[0x02]).toBe('data')
|
||||
})
|
||||
|
||||
test('negative: truncated buffer (< 15B) throws', () => {
|
||||
expect(() => decodeHeader(new Uint8Array(MUX_HEADER_BYTES - 1))).toThrow()
|
||||
})
|
||||
|
||||
test('negative: payloadLen larger than actual payload throws', () => {
|
||||
const buf = encodeMuxFrame(header({ payloadLen: 2 }), new Uint8Array([9, 9]))
|
||||
// Claim a longer payload than present.
|
||||
buf[14] = 5
|
||||
expect(() => decodeMuxFrame(buf)).toThrow()
|
||||
})
|
||||
|
||||
test('negative: unknown type byte throws', () => {
|
||||
const buf = encodeMuxFrame(header({ type: 'data', payloadLen: 0 }), new Uint8Array(0))
|
||||
buf[1] = 0x08 // unknown type code
|
||||
expect(() => decodeHeader(buf)).toThrow()
|
||||
buf[1] = 0x00
|
||||
expect(() => decodeHeader(buf)).toThrow()
|
||||
})
|
||||
|
||||
test('negative: version ≠ 0x01 throws', () => {
|
||||
const buf = encodeMuxFrame(header({ payloadLen: 0 }), new Uint8Array(0))
|
||||
buf[0] = 0x02
|
||||
expect(() => decodeHeader(buf)).toThrow()
|
||||
})
|
||||
|
||||
test('OPEN payload round-trips via CBOR + Zod guard', () => {
|
||||
const open: MuxOpen = {
|
||||
streamId: 3,
|
||||
subdomain: 'alice',
|
||||
requestPath: '/term?join=x',
|
||||
originHeader: 'https://alice.term.example.com',
|
||||
remoteAddrHash: 'deadbeef',
|
||||
capabilityTokenRef: 'jti-1',
|
||||
}
|
||||
expect(decodeOpen(encodeOpen(open))).toEqual(open)
|
||||
})
|
||||
|
||||
test('decodeOpen rejects a malformed/partial CBOR payload (never returns partial)', () => {
|
||||
expect(() => decodeOpen(new Uint8Array([0x00, 0x01, 0x02]))).toThrow()
|
||||
})
|
||||
|
||||
test('WINDOW_UPDATE + GOAWAY int codecs round-trip', () => {
|
||||
expect(decodeWindowUpdate(encodeWindowUpdate(65_536))).toBe(65_536)
|
||||
const g = decodeGoaway(encodeGoaway(9, 'revoked'))
|
||||
expect(g).toEqual({ lastStreamId: 9, reason: 'revoked' })
|
||||
})
|
||||
})
|
||||
74
term-relay/test/heartbeat.test.ts
Normal file
74
term-relay/test/heartbeat.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import {
|
||||
createHeartbeat,
|
||||
HEARTBEAT_INTERVAL_MS,
|
||||
HEARTBEAT_MISS_LIMIT,
|
||||
type ScheduleHandle,
|
||||
} from '../mux/heartbeat.js'
|
||||
|
||||
/** A manually-driven fake scheduler: `tick()` fires the scheduled callback; cancel stops it. */
|
||||
function fakeScheduler() {
|
||||
let scheduled: (() => void) | null = null
|
||||
const schedule = (fn: () => void): ScheduleHandle => {
|
||||
scheduled = fn
|
||||
return { cancel: () => (scheduled = null) }
|
||||
}
|
||||
return { schedule, tick: () => scheduled?.() }
|
||||
}
|
||||
|
||||
describe('heartbeat (T5, §4.1 15s PING/PONG)', () => {
|
||||
test('start sends a PING immediately with a fresh 8-byte token', () => {
|
||||
const { schedule } = fakeScheduler()
|
||||
const pings: Uint8Array[] = []
|
||||
const hb = createHeartbeat({ schedule, sendPing: (t) => pings.push(t), onDead: () => {} })
|
||||
hb.start()
|
||||
expect(pings).toHaveLength(1)
|
||||
expect(pings[0]!.length).toBe(8)
|
||||
})
|
||||
|
||||
test('a matching onPong resets the miss counter; 3 misses ⇒ onDead exactly once', () => {
|
||||
const sched = fakeScheduler()
|
||||
const pings: Uint8Array[] = []
|
||||
const onDead = vi.fn()
|
||||
const hb = createHeartbeat({ schedule: sched.schedule, sendPing: (t) => pings.push(t), onDead })
|
||||
hb.start() // ping #1, outstanding set
|
||||
hb.onPong(pings[0]!) // matched → miss counter reset
|
||||
sched.tick() // outstanding was cleared → no miss, ping #2
|
||||
sched.tick() // miss 1, ping #3
|
||||
sched.tick() // miss 2, ping #4
|
||||
sched.tick() // miss 3 → dead
|
||||
expect(onDead).toHaveBeenCalledTimes(1)
|
||||
const pingsAfterDead = pings.length
|
||||
sched.tick() // no further pings after dead
|
||||
expect(pings.length).toBe(pingsAfterDead)
|
||||
})
|
||||
|
||||
test('a stale/unknown PONG token does NOT reset the miss counter (anti-replay)', () => {
|
||||
const sched = fakeScheduler()
|
||||
const onDead = vi.fn()
|
||||
const hb = createHeartbeat({ schedule: sched.schedule, sendPing: () => {}, onDead })
|
||||
hb.start()
|
||||
hb.onPong(new Uint8Array([9, 9, 9, 9, 9, 9, 9, 9])) // wrong token — ignored
|
||||
sched.tick() // miss 1
|
||||
sched.tick() // miss 2
|
||||
sched.tick() // miss 3 → dead despite the spoofed PONG
|
||||
expect(onDead).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('stop cancels cleanly (no onDead after stop)', () => {
|
||||
const sched = fakeScheduler()
|
||||
const onDead = vi.fn()
|
||||
const hb = createHeartbeat({ schedule: sched.schedule, sendPing: () => {}, onDead })
|
||||
hb.start()
|
||||
hb.stop()
|
||||
sched.tick() // scheduler cancelled → no-op
|
||||
sched.tick()
|
||||
sched.tick()
|
||||
expect(onDead).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('exposes the §4.1 constants', () => {
|
||||
expect(HEARTBEAT_INTERVAL_MS).toBe(15_000)
|
||||
expect(HEARTBEAT_MISS_LIMIT).toBe(3)
|
||||
})
|
||||
})
|
||||
101
term-relay/test/integration/data-plane.test.ts
Normal file
101
term-relay/test/integration/data-plane.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import { createMuxSession, type MuxSession } from '../../mux/mux-session.js'
|
||||
import { createRelayNode } from '../../data-plane/relay-node.js'
|
||||
import { loadDataPlaneConfig } from '../../data-plane/config.js'
|
||||
import type { AgentListener, AgentTunnel } from '../../data-plane/agent-listener.js'
|
||||
import type { UpgradeDecision, UpgradeRequest } from '../../data-plane/upgrade.js'
|
||||
|
||||
const config = loadDataPlaneConfig({
|
||||
BASE_DOMAIN: 'term.example.com',
|
||||
TLS_CERT_PATH: '/c',
|
||||
TLS_KEY_PATH: '/k',
|
||||
AGENT_CA_CERT_PATH: '/ca',
|
||||
RELAY_NODE_ID: 'node-1',
|
||||
})
|
||||
|
||||
const noSchedule = () => ({ cancel: () => {} })
|
||||
|
||||
/** Build a real relay↔agent MuxSession pair; the agent echoes each DATA payload back verbatim. */
|
||||
function wiredTunnel(hostId: string): AgentTunnel {
|
||||
let agent!: MuxSession
|
||||
const relay = createMuxSession({
|
||||
role: 'relay',
|
||||
sendWire: (f) => agent.onWire(f),
|
||||
onOpen: () => {},
|
||||
onData: () => {},
|
||||
onClose: () => {},
|
||||
onDead: () => {},
|
||||
maxFrameBytes: 1024,
|
||||
initialWindowBytes: 1_000_000,
|
||||
schedule: noSchedule,
|
||||
})
|
||||
agent = createMuxSession({
|
||||
role: 'agent',
|
||||
sendWire: (f) => relay.onWire(f),
|
||||
onOpen: (_open, stream) => {
|
||||
// The agent dials its local ws://127.0.0.1:3000 (here: an echo) — OPAQUE bytes only.
|
||||
stream.onData((bytes) => stream.writeData(bytes))
|
||||
},
|
||||
onData: () => {},
|
||||
onClose: () => {},
|
||||
onDead: () => {},
|
||||
maxFrameBytes: 1024,
|
||||
initialWindowBytes: 1_000_000,
|
||||
schedule: noSchedule,
|
||||
})
|
||||
return { hostId, accountId: 'acct-1', session: relay, closeTunnel: () => relay.close() }
|
||||
}
|
||||
|
||||
function listenerWith(tunnels: Map<string, AgentTunnel>): AgentListener {
|
||||
return { attach: () => {}, tunnels: () => tunnels, close: () => {} }
|
||||
}
|
||||
|
||||
function browserWs() {
|
||||
let onMsg: ((b: Uint8Array) => void) | null = null
|
||||
return {
|
||||
sent: [] as Uint8Array[],
|
||||
closedWith: undefined as number | undefined,
|
||||
send(b: Uint8Array) {
|
||||
this.sent.push(b)
|
||||
},
|
||||
close(code?: number) {
|
||||
this.closedWith = code
|
||||
},
|
||||
onMessage(h: (b: Uint8Array) => void) {
|
||||
onMsg = h
|
||||
},
|
||||
onClose() {},
|
||||
type: (b: Uint8Array) => onMsg?.(b),
|
||||
}
|
||||
}
|
||||
|
||||
function okDecision(hostId: string): UpgradeDecision {
|
||||
return {
|
||||
ok: true,
|
||||
hostId,
|
||||
acceptedSubprotocol: 'term.relay.v1',
|
||||
open: { streamId: 0, subdomain: 'alice', requestPath: '/', originHeader: 'o', remoteAddrHash: 'h', capabilityTokenRef: 'jti' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('data-plane integration (T13) — end-to-end opaque splice through a real MuxSession pair', () => {
|
||||
test('INV2: bytes round-trip browser → agent → browser byte-identical, never parsed', async () => {
|
||||
const tunnels = new Map([['host-A', wiredTunnel('host-A')]])
|
||||
const node = createRelayNode({ config, listener: listenerWith(tunnels), authorize: async () => okDecision('host-A') })
|
||||
const ws = browserWs()
|
||||
await node.handleBrowserUpgrade({} as UpgradeRequest, ws)
|
||||
const payload = new TextEncoder().encode('echo-me-🔒-ciphertext')
|
||||
ws.type(payload)
|
||||
expect(ws.sent).toHaveLength(1)
|
||||
expect([...ws.sent[0]!]).toEqual([...payload]) // opaque round-trip
|
||||
})
|
||||
|
||||
test('INV1: an upgrade authorized for a host not on this node cannot reach any tunnel', async () => {
|
||||
const tunnels = new Map([['host-A', wiredTunnel('host-A')]])
|
||||
const node = createRelayNode({ config, listener: listenerWith(tunnels), authorize: async () => okDecision('host-B') })
|
||||
const ws = browserWs()
|
||||
await node.handleBrowserUpgrade({} as UpgradeRequest, ws)
|
||||
expect(ws.closedWith).toBe(1013) // no tunnel for host-B; A is never reachable by another host's token
|
||||
expect(ws.sent).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
65
term-relay/test/inv-tripwires.test.ts
Normal file
65
term-relay/test/inv-tripwires.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createMuxSession, type MuxSession } from '../mux/mux-session.js'
|
||||
import type { MuxOpen } from '../mux/frame-codec.js'
|
||||
|
||||
/** Recursively collect .ts source files under a directory. */
|
||||
function tsFiles(dir: string): string[] {
|
||||
const out: string[] = []
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = `${dir}/${entry}`
|
||||
if (statSync(full).isDirectory()) out.push(...tsFiles(full))
|
||||
else if (entry.endsWith('.ts')) out.push(full)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const MUX_DIR = fileURLToPath(new URL('../mux', import.meta.url))
|
||||
const DP_DIR = fileURLToPath(new URL('../data-plane', import.meta.url))
|
||||
const TERMINAL_PARSER_RE = /xterm|ansi|vt100|terminal-parser/i
|
||||
|
||||
describe('P1 invariant tripwires (T13)', () => {
|
||||
test('INV11: no mux/ or data-plane/ source imports a terminal/ANSI parser', () => {
|
||||
const files = [...tsFiles(MUX_DIR), ...tsFiles(DP_DIR)]
|
||||
expect(files.length).toBeGreaterThan(0)
|
||||
const offenders = files.filter((f) => {
|
||||
const src = readFileSync(f, 'utf8')
|
||||
const importLines = src.split('\n').filter((l) => /\bimport\b/.test(l) || /\bfrom\b/.test(l))
|
||||
return importLines.some((l) => TERMINAL_PARSER_RE.test(l))
|
||||
})
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
test('INV2: a plaintext marker rides DATA as OPAQUE bytes; never inspected or retained', () => {
|
||||
const marker = new TextEncoder().encode('SECRET-PLAINTEXT-MARKER')
|
||||
let delivered: Uint8Array | null = null
|
||||
let agent!: MuxSession
|
||||
const relay = createMuxSession({
|
||||
role: 'relay',
|
||||
sendWire: (f) => agent.onWire(f),
|
||||
onOpen: () => {},
|
||||
onData: () => {},
|
||||
onClose: () => {},
|
||||
onDead: () => {},
|
||||
maxFrameBytes: 1024,
|
||||
initialWindowBytes: 1_000_000,
|
||||
schedule: () => ({ cancel: () => {} }),
|
||||
})
|
||||
agent = createMuxSession({
|
||||
role: 'agent',
|
||||
sendWire: (f) => relay.onWire(f),
|
||||
onOpen: () => {},
|
||||
onData: (_id, payload) => (delivered = payload),
|
||||
onClose: () => {},
|
||||
onDead: () => {},
|
||||
maxFrameBytes: 1024,
|
||||
initialWindowBytes: 1_000_000,
|
||||
schedule: () => ({ cancel: () => {} }),
|
||||
})
|
||||
const open: MuxOpen = { streamId: 0, subdomain: 'alice', requestPath: '/', originHeader: 'o', remoteAddrHash: 'h', capabilityTokenRef: 'j' }
|
||||
relay.openStream(open).writeData(marker)
|
||||
expect(delivered).not.toBeNull()
|
||||
expect([...delivered!]).toEqual([...marker]) // byte-identical, opaque
|
||||
})
|
||||
})
|
||||
172
term-relay/test/mux-session.test.ts
Normal file
172
term-relay/test/mux-session.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import { createMuxSession, type MuxSession, type MuxStreamHandle } from '../mux/mux-session.js'
|
||||
import { encodeMuxFrame, decodeHeader, type MuxOpen } from '../mux/frame-codec.js'
|
||||
import type { ScheduleHandle } from '../mux/heartbeat.js'
|
||||
|
||||
const noAutoSchedule = (): { schedule: (fn: () => void) => ScheduleHandle; tick: () => void } => {
|
||||
let scheduled: (() => void) | null = null
|
||||
return {
|
||||
schedule: (fn) => {
|
||||
scheduled = fn
|
||||
return { cancel: () => (scheduled = null) }
|
||||
},
|
||||
tick: () => scheduled?.(),
|
||||
}
|
||||
}
|
||||
|
||||
function openFor(streamId: number): MuxOpen {
|
||||
return {
|
||||
streamId,
|
||||
subdomain: 'alice',
|
||||
requestPath: '/term',
|
||||
originHeader: 'https://alice.term.example.com',
|
||||
remoteAddrHash: 'hash',
|
||||
capabilityTokenRef: 'jti-1',
|
||||
}
|
||||
}
|
||||
|
||||
interface Pair {
|
||||
relay: MuxSession
|
||||
agent: MuxSession
|
||||
opened: { open: MuxOpen; stream: MuxStreamHandle }[]
|
||||
agentData: { streamId: number; payload: Uint8Array }[]
|
||||
}
|
||||
|
||||
function wirePair(relayWindow = 1_000_000, agentWindow = 1_000_000): Pair {
|
||||
const opened: Pair['opened'] = []
|
||||
const agentData: Pair['agentData'] = []
|
||||
let agent!: MuxSession
|
||||
const relay = createMuxSession({
|
||||
role: 'relay',
|
||||
sendWire: (f) => agent.onWire(f),
|
||||
onOpen: () => {},
|
||||
onData: () => {},
|
||||
onClose: () => {},
|
||||
onDead: () => {},
|
||||
maxFrameBytes: 1024,
|
||||
initialWindowBytes: relayWindow,
|
||||
schedule: noAutoSchedule().schedule,
|
||||
})
|
||||
agent = createMuxSession({
|
||||
role: 'agent',
|
||||
sendWire: (f) => relay.onWire(f),
|
||||
onOpen: (open, stream) => opened.push({ open, stream }),
|
||||
onData: (streamId, payload) => agentData.push({ streamId, payload }),
|
||||
onClose: () => {},
|
||||
onDead: () => {},
|
||||
maxFrameBytes: 1024,
|
||||
initialWindowBytes: agentWindow,
|
||||
schedule: noAutoSchedule().schedule,
|
||||
})
|
||||
return { relay, agent, opened, agentData }
|
||||
}
|
||||
|
||||
describe('mux session (T6)', () => {
|
||||
test('openStream → agent onOpen with a fresh monotonic streamId; never reused', () => {
|
||||
const p = wirePair()
|
||||
p.relay.openStream(openFor(0))
|
||||
p.relay.openStream(openFor(0))
|
||||
expect(p.opened.map((o) => o.open.streamId)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
test('writeData delivers a byte-identical OPAQUE payload (INV2), never inspected', () => {
|
||||
const p = wirePair()
|
||||
const h = p.relay.openStream(openFor(0))
|
||||
const marker = new TextEncoder().encode('PLAINTEXT-MARKER-🔒')
|
||||
expect(h.writeData(marker)).toBe(true)
|
||||
expect(p.agentData).toHaveLength(1)
|
||||
expect([...p.agentData[0]!.payload]).toEqual([...marker])
|
||||
})
|
||||
|
||||
test('backpressure: writeData returns false when the window is full; WINDOW_UPDATE resumes', () => {
|
||||
// relay send window = 10, agent receive window huge (no auto-replenish crossing threshold).
|
||||
const p = wirePair(10, 1_000_000)
|
||||
const h = p.relay.openStream(openFor(0))
|
||||
const streamId = h.streamId
|
||||
const a = new Uint8Array(8).fill(1)
|
||||
const b = new Uint8Array(8).fill(2)
|
||||
expect(h.writeData(a)).toBe(true) // window 10 → 2
|
||||
expect(h.writeData(b)).toBe(false) // 8 > 2 → buffered
|
||||
expect(p.agentData).toHaveLength(1)
|
||||
// Simulate a peer WINDOW_UPDATE granting credit → buffered `b` flushes.
|
||||
p.relay.onWire(encodeWU(streamId, 50))
|
||||
expect(p.agentData).toHaveLength(2)
|
||||
expect([...p.agentData[1]!.payload]).toEqual([...b])
|
||||
})
|
||||
|
||||
test('per-stream isolation: exhausting stream A does not block stream B', () => {
|
||||
const p = wirePair(10, 1_000_000)
|
||||
const a = p.relay.openStream(openFor(0))
|
||||
const b = p.relay.openStream(openFor(0))
|
||||
expect(a.writeData(new Uint8Array(10).fill(1))).toBe(true) // A window exhausted
|
||||
expect(a.writeData(new Uint8Array(1))).toBe(false)
|
||||
expect(b.writeData(new Uint8Array(10).fill(2))).toBe(true) // B unaffected
|
||||
})
|
||||
|
||||
test('inbound DATA for an unknown stream → CLOSE+RST for that stream only (tunnel stays up)', () => {
|
||||
const wire: Uint8Array[] = []
|
||||
const s = createMuxSession({
|
||||
role: 'agent',
|
||||
sendWire: (f) => wire.push(f),
|
||||
onOpen: () => {},
|
||||
onData: () => {},
|
||||
onClose: () => {},
|
||||
onDead: () => {},
|
||||
maxFrameBytes: 1024,
|
||||
initialWindowBytes: 1000,
|
||||
schedule: noAutoSchedule().schedule,
|
||||
})
|
||||
s.onWire(encodeMuxFrame({ version: 1, type: 'data', fin: false, rst: false, streamId: 99, payloadLen: 1 }, new Uint8Array([7])))
|
||||
const rst = wire.map(decodeHeader).find((x) => x.type === 'close' && x.rst)
|
||||
expect(rst?.streamId).toBe(99)
|
||||
})
|
||||
|
||||
test('frame ceiling: inbound payloadLen > maxFrameBytes → RST that stream, no OOM', () => {
|
||||
const wire: Uint8Array[] = []
|
||||
const s = createMuxSession({
|
||||
role: 'agent',
|
||||
sendWire: (f) => wire.push(f),
|
||||
onOpen: () => {},
|
||||
onData: () => {},
|
||||
onClose: () => {},
|
||||
onDead: () => {},
|
||||
maxFrameBytes: 4,
|
||||
initialWindowBytes: 1000,
|
||||
schedule: noAutoSchedule().schedule,
|
||||
})
|
||||
const oversized = encodeMuxFrame({ version: 1, type: 'data', fin: false, rst: false, streamId: 5, payloadLen: 5 }, new Uint8Array(5))
|
||||
s.onWire(oversized)
|
||||
const rst = wire.map(decodeHeader).find((x) => x.rst)
|
||||
expect(rst?.streamId).toBe(5)
|
||||
})
|
||||
|
||||
test('heartbeat: no PONG for the miss limit ⇒ onDead', () => {
|
||||
const sched = noAutoSchedule()
|
||||
const onDead = vi.fn()
|
||||
const s = createMuxSession({
|
||||
role: 'relay',
|
||||
sendWire: () => {}, // peer never responds
|
||||
onOpen: () => {},
|
||||
onData: () => {},
|
||||
onClose: () => {},
|
||||
onDead,
|
||||
maxFrameBytes: 1024,
|
||||
initialWindowBytes: 1000,
|
||||
schedule: sched.schedule,
|
||||
})
|
||||
s.openStream(openFor(0)) // starts heartbeat (ping #1)
|
||||
sched.tick() // miss 1
|
||||
sched.tick() // miss 2
|
||||
sched.tick() // miss 3 → dead
|
||||
expect(onDead).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
function encodeWU(streamId: number, credit: number): Uint8Array {
|
||||
const payload = new Uint8Array(4)
|
||||
new DataView(payload.buffer).setUint32(0, credit, false)
|
||||
return encodeMuxFrame(
|
||||
{ version: 1, type: 'windowUpdate', fin: false, rst: false, streamId, payloadLen: 4 },
|
||||
payload,
|
||||
)
|
||||
}
|
||||
33
term-relay/test/plugin-hook.test.ts
Normal file
33
term-relay/test/plugin-hook.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import { handleFrpHook, type ControlPlaneAuthz } from '../frp-scaffold/plugin-hook.js'
|
||||
|
||||
function authz(allow: boolean): ControlPlaneAuthz {
|
||||
return { authorize: vi.fn(async () => ({ allow })) }
|
||||
}
|
||||
|
||||
const loginReq = { version: '0.1.0', op: 'Login', content: { user: 'alice', token: 't' } }
|
||||
|
||||
describe('frp plugin-hook shim (T12, deny-by-default, delegates to P3)', () => {
|
||||
test('unknown token → reject (deny-by-default)', async () => {
|
||||
const res = await handleFrpHook({ ...loginReq, op: 'NewUserConn' }, authz(false))
|
||||
expect(res).toEqual({ reject: true, reject_reason: 'denied by control plane' })
|
||||
})
|
||||
|
||||
test('valid + control-plane-owned subdomain → allow (pass-through unchanged)', async () => {
|
||||
const res = await handleFrpHook(loginReq, authz(true))
|
||||
expect(res).toEqual({ reject: false, unchange: true })
|
||||
})
|
||||
|
||||
test('forwards the decision to P3, never decides tenancy itself', async () => {
|
||||
const a = authz(true)
|
||||
await handleFrpHook({ ...loginReq, op: 'NewProxy' }, a)
|
||||
expect(a.authorize).toHaveBeenCalledWith('NewProxy', loginReq.content)
|
||||
})
|
||||
|
||||
test('malformed payload → reject at the Zod boundary, authorizer NOT called', async () => {
|
||||
const a = authz(true)
|
||||
const res = await handleFrpHook({ op: 'Bogus' }, a)
|
||||
expect(res).toEqual({ reject: true, reject_reason: 'malformed hook request' })
|
||||
expect(a.authorize).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
167
term-relay/test/relay-node.test.ts
Normal file
167
term-relay/test/relay-node.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import { createRelayNode } from '../data-plane/relay-node.js'
|
||||
import type { AgentListener, AgentTunnel } from '../data-plane/agent-listener.js'
|
||||
import type { MuxSession, MuxStreamHandle } from '../mux/mux-session.js'
|
||||
import type { WebSocketLike } from '../data-plane/ws-like.js'
|
||||
import type { UpgradeDecision, UpgradeRequest } from '../data-plane/upgrade.js'
|
||||
import { loadDataPlaneConfig } from '../data-plane/config.js'
|
||||
|
||||
const config = loadDataPlaneConfig({
|
||||
BASE_DOMAIN: 'term.example.com',
|
||||
TLS_CERT_PATH: '/c',
|
||||
TLS_KEY_PATH: '/k',
|
||||
AGENT_CA_CERT_PATH: '/ca',
|
||||
RELAY_NODE_ID: 'node-1',
|
||||
})
|
||||
|
||||
interface FakeStream {
|
||||
handle: MuxStreamHandle
|
||||
emitData(b: Uint8Array): void
|
||||
emitClose(): void
|
||||
writes: Uint8Array[]
|
||||
rstClosed: boolean
|
||||
}
|
||||
|
||||
function fakeTunnel(hostId: string) {
|
||||
let nextId = 0
|
||||
const fakeStreams: FakeStream[] = []
|
||||
const session = {
|
||||
openStream: () => {
|
||||
let dataCb: ((b: Uint8Array) => void) | null = null
|
||||
let closeCb: ((rst: boolean) => void) | null = null
|
||||
const fs: FakeStream = { writes: [], rstClosed: false, emitData: () => {}, emitClose: () => {}, handle: undefined as unknown as MuxStreamHandle }
|
||||
const handle: MuxStreamHandle = {
|
||||
streamId: ++nextId,
|
||||
writeData: (b) => {
|
||||
fs.writes.push(b)
|
||||
return true
|
||||
},
|
||||
close: (rst = false) => {
|
||||
fs.rstClosed = rst
|
||||
},
|
||||
onData: (cb) => (dataCb = cb),
|
||||
onClose: (cb) => (closeCb = cb),
|
||||
}
|
||||
fs.handle = handle
|
||||
fs.emitData = (b) => dataCb?.(b)
|
||||
fs.emitClose = () => closeCb?.(false)
|
||||
fakeStreams.push(fs)
|
||||
return handle
|
||||
},
|
||||
onWire: () => {},
|
||||
drain: vi.fn(),
|
||||
close: vi.fn(),
|
||||
} satisfies MuxSession
|
||||
const tunnel: AgentTunnel = { hostId, accountId: 'acct-1', session, closeTunnel: vi.fn() }
|
||||
return { tunnel, fakeStreams }
|
||||
}
|
||||
|
||||
function fakeWs() {
|
||||
let onMsg: ((b: Uint8Array) => void) | null = null
|
||||
let onClose: (() => void) | null = null
|
||||
return {
|
||||
sent: [] as Uint8Array[],
|
||||
closedWith: undefined as number | undefined,
|
||||
send(b: Uint8Array) {
|
||||
this.sent.push(b)
|
||||
},
|
||||
close(code?: number) {
|
||||
this.closedWith = code
|
||||
},
|
||||
onMessage(h: (b: Uint8Array) => void) {
|
||||
onMsg = h
|
||||
},
|
||||
onClose(h: () => void) {
|
||||
onClose = h
|
||||
},
|
||||
emitMessage: (b: Uint8Array) => onMsg?.(b),
|
||||
emitClose: () => onClose?.(),
|
||||
}
|
||||
}
|
||||
|
||||
function listenerWith(tunnels: Map<string, AgentTunnel>): AgentListener {
|
||||
return { attach: vi.fn(), tunnels: () => tunnels, close: vi.fn() }
|
||||
}
|
||||
|
||||
function decisionFor(hostId: string, jti: string): UpgradeDecision {
|
||||
return {
|
||||
ok: true,
|
||||
hostId,
|
||||
acceptedSubprotocol: 'term.relay.v1',
|
||||
open: { streamId: 0, subdomain: 'alice', requestPath: '/term', originHeader: 'o', remoteAddrHash: 'h', capabilityTokenRef: jti },
|
||||
}
|
||||
}
|
||||
|
||||
const req = {} as UpgradeRequest
|
||||
|
||||
describe('relay node opaque splice (T10, INV2/INV5/INV7/INV11/INV12)', () => {
|
||||
test('authorized upgrade → bytes splice byte-identical in both directions', async () => {
|
||||
const { tunnel, fakeStreams } = fakeTunnel('host-1')
|
||||
const node = createRelayNode({ config, listener: listenerWith(new Map([['host-1', tunnel]])), authorize: async () => decisionFor('host-1', 'jti-a') })
|
||||
const ws = fakeWs()
|
||||
await node.handleBrowserUpgrade(req, ws)
|
||||
const fs = fakeStreams[0]!
|
||||
const up = new Uint8Array([1, 2, 3])
|
||||
ws.emitMessage(up) // browser → agent
|
||||
expect([...fs.writes[0]!]).toEqual([1, 2, 3])
|
||||
const down = new Uint8Array([9, 8, 7])
|
||||
fs.emitData(down) // agent → browser
|
||||
expect([...ws.sent[0]!]).toEqual([9, 8, 7])
|
||||
})
|
||||
|
||||
test('unauthorized upgrade → ws closed with the status; no stream, no tunnel touched', async () => {
|
||||
const { tunnel } = fakeTunnel('host-1')
|
||||
const node = createRelayNode({ config, listener: listenerWith(new Map([['host-1', tunnel]])), authorize: async () => ({ ok: false, status: 403 }) })
|
||||
const ws = fakeWs()
|
||||
await node.handleBrowserUpgrade(req, ws)
|
||||
expect(ws.closedWith).toBe(403)
|
||||
})
|
||||
|
||||
test('agent offline (no tunnel) → ws closed 1013, no crash', async () => {
|
||||
const node = createRelayNode({ config, listener: listenerWith(new Map()), authorize: async () => decisionFor('host-x', 'jti') })
|
||||
const ws = fakeWs()
|
||||
await node.handleBrowserUpgrade(req, ws)
|
||||
expect(ws.closedWith).toBe(1013)
|
||||
})
|
||||
|
||||
test('single-device revocation (Finding-3): closeStream by jti RSTs only that jti', async () => {
|
||||
const { tunnel, fakeStreams } = fakeTunnel('host-1')
|
||||
const node = createRelayNode({ config, listener: listenerWith(new Map([['host-1', tunnel]])), authorize: async () => decisionFor('host-1', jtiRef()) })
|
||||
let currentJti = 'jtiA'
|
||||
function jtiRef() {
|
||||
return currentJti
|
||||
}
|
||||
const wsA = fakeWs()
|
||||
await node.handleBrowserUpgrade(req, wsA)
|
||||
currentJti = 'jtiB'
|
||||
const wsB = fakeWs()
|
||||
await node.handleBrowserUpgrade(req, wsB)
|
||||
|
||||
const count = node.closeStream('host-1', { jti: 'jtiA' })
|
||||
expect(count).toBe(1)
|
||||
expect(fakeStreams[0]!.rstClosed).toBe(true) // A RST
|
||||
expect(wsA.closedWith).toBe(4403)
|
||||
expect(fakeStreams[1]!.rstClosed).toBe(false) // B untouched
|
||||
// B still flows
|
||||
fakeStreams[1]!.emitData(new Uint8Array([5]))
|
||||
expect([...wsB.sent[0]!]).toEqual([5])
|
||||
})
|
||||
|
||||
test('closeStream with two splices under the SAME jti returns 2; unknown jti returns 0', async () => {
|
||||
const { tunnel } = fakeTunnel('host-1')
|
||||
const node = createRelayNode({ config, listener: listenerWith(new Map([['host-1', tunnel]])), authorize: async () => decisionFor('host-1', 'jtiSame') })
|
||||
await node.handleBrowserUpgrade(req, fakeWs())
|
||||
await node.handleBrowserUpgrade(req, fakeWs())
|
||||
expect(node.closeStream('host-1', { jti: 'jtiSame' })).toBe(2)
|
||||
expect(node.closeStream('host-1', { jti: 'unknown' })).toBe(0)
|
||||
})
|
||||
|
||||
test('after a browser ws closes normally, its splice entry is gone (no stale handle)', async () => {
|
||||
const { tunnel } = fakeTunnel('host-1')
|
||||
const node = createRelayNode({ config, listener: listenerWith(new Map([['host-1', tunnel]])), authorize: async () => decisionFor('host-1', 'jtiZ') })
|
||||
const ws = fakeWs()
|
||||
await node.handleBrowserUpgrade(req, ws)
|
||||
ws.emitClose()
|
||||
expect(node.closeStream('host-1', { jti: 'jtiZ' })).toBe(0)
|
||||
})
|
||||
})
|
||||
141
term-relay/test/revocation-subscriber.test.ts
Normal file
141
term-relay/test/revocation-subscriber.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import { RELAY_REVOCATIONS_CHANNEL, REVOCATION_PUSH_BUDGET_MS, type KillSignal } from 'relay-contracts'
|
||||
import { startRevocationSubscriber, hostsForScope, type Subscription } from '../data-plane/revocation-subscriber.js'
|
||||
import { DRAIN_REASON } from '../data-plane/drain.js'
|
||||
import type { AgentTunnel } from '../data-plane/agent-listener.js'
|
||||
|
||||
const UUID_H = '11111111-1111-4111-8111-111111111111'
|
||||
const UUID_H2 = '22222222-2222-4222-8222-222222222222'
|
||||
const UUID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
|
||||
function tunnel(hostId: string, accountId: string): AgentTunnel {
|
||||
return { hostId, accountId, session: {} as AgentTunnel['session'], closeTunnel: () => {} }
|
||||
}
|
||||
|
||||
function fakeBus() {
|
||||
let handler: ((raw: string) => void) | null = null
|
||||
const sub: Subscription = { close: vi.fn() }
|
||||
return {
|
||||
subscribe: (channel: string, onMessage: (raw: string) => void) => {
|
||||
expect(channel).toBe(RELAY_REVOCATIONS_CHANNEL)
|
||||
handler = onMessage
|
||||
return sub
|
||||
},
|
||||
publish: (signal: KillSignal | string) => handler?.(typeof signal === 'string' ? signal : JSON.stringify(signal)),
|
||||
sub,
|
||||
}
|
||||
}
|
||||
|
||||
describe('hostsForScope (T14, pure blast-radius selector)', () => {
|
||||
const live = new Map([
|
||||
[UUID_H, tunnel(UUID_H, UUID_A)],
|
||||
[UUID_H2, tunnel(UUID_H2, 'other-acct')],
|
||||
])
|
||||
const accountOf = (h: string) => live.get(h)?.accountId
|
||||
|
||||
test('host scope: only if this node serves it', () => {
|
||||
expect(hostsForScope({ kind: 'host', hostId: UUID_H }, live, accountOf)).toEqual([UUID_H])
|
||||
expect(hostsForScope({ kind: 'host', hostId: 'nope' }, live, accountOf)).toEqual([])
|
||||
})
|
||||
test('account scope: only the account’s hosts on this node', () => {
|
||||
expect(hostsForScope({ kind: 'account', accountId: UUID_A }, live, accountOf)).toEqual([UUID_H])
|
||||
})
|
||||
test('global scope: every live host', () => {
|
||||
expect(hostsForScope({ kind: 'global' }, live, accountOf)).toEqual([UUID_H, UUID_H2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('revocation subscriber (T14, INV12/FIX 4)', () => {
|
||||
function setup(tunnels: Map<string, AgentTunnel>) {
|
||||
const bus = fakeBus()
|
||||
const drainHost = vi.fn(async () => {})
|
||||
const onApplied = vi.fn()
|
||||
const onDropped = vi.fn()
|
||||
const clock = { t: 0 }
|
||||
startRevocationSubscriber({
|
||||
subscribe: bus.subscribe,
|
||||
drainHost,
|
||||
tunnels: () => tunnels,
|
||||
now: () => clock.t,
|
||||
onApplied,
|
||||
onDropped,
|
||||
})
|
||||
return { bus, drainHost, onApplied, onDropped, clock }
|
||||
}
|
||||
|
||||
test('host signal → drainHost(H, revoked) once, within the push budget', async () => {
|
||||
const { bus, drainHost } = setup(new Map([[UUID_H, tunnel(UUID_H, UUID_A)]]))
|
||||
bus.publish({ scope: { kind: 'host', hostId: UUID_H }, at: 1, reason: 'compromised' })
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(drainHost).toHaveBeenCalledTimes(1)
|
||||
expect(drainHost).toHaveBeenCalledWith(UUID_H, DRAIN_REASON.revoked)
|
||||
expect(REVOCATION_PUSH_BUDGET_MS).toBe(2000)
|
||||
})
|
||||
|
||||
test('a host NOT on this node → no-op (0 calls, no cross-tenant reach)', async () => {
|
||||
const { bus, drainHost } = setup(new Map([[UUID_H, tunnel(UUID_H, UUID_A)]]))
|
||||
bus.publish({ scope: { kind: 'host', hostId: UUID_H2 }, at: 1, reason: 'x' })
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(drainHost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('account scope tears down only that account’s hosts (sibling account keeps flowing, INV1)', async () => {
|
||||
const { bus, drainHost } = setup(
|
||||
new Map([
|
||||
[UUID_H, tunnel(UUID_H, UUID_A)],
|
||||
[UUID_H2, tunnel(UUID_H2, 'other-acct')],
|
||||
]),
|
||||
)
|
||||
bus.publish({ scope: { kind: 'account', accountId: UUID_A }, at: 1, reason: 'x' })
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(drainHost).toHaveBeenCalledTimes(1)
|
||||
expect(drainHost).toHaveBeenCalledWith(UUID_H, DRAIN_REASON.revoked)
|
||||
})
|
||||
|
||||
test('global scope tears down every live tunnel', async () => {
|
||||
const { bus, drainHost } = setup(
|
||||
new Map([
|
||||
[UUID_H, tunnel(UUID_H, UUID_A)],
|
||||
[UUID_H2, tunnel(UUID_H2, 'other-acct')],
|
||||
]),
|
||||
)
|
||||
bus.publish({ scope: { kind: 'global' }, at: 1, reason: 'x' })
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(drainHost).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test('malformed signal → dropped + counted, no teardown, subscriber stays alive', async () => {
|
||||
const { bus, drainHost, onDropped } = setup(new Map([[UUID_H, tunnel(UUID_H, UUID_A)]]))
|
||||
bus.publish('{ not valid json')
|
||||
bus.publish(JSON.stringify({ scope: { kind: 'nonsense' }, at: 1, reason: 'x' }))
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(onDropped).toHaveBeenCalledTimes(2)
|
||||
expect(drainHost).not.toHaveBeenCalled()
|
||||
// Still alive: a good signal after the poison ones is applied.
|
||||
bus.publish({ scope: { kind: 'host', hostId: UUID_H }, at: 2, reason: 'x' })
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(drainHost).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('onApplied reports metadata only (count + elapsed), never the reason as payload (INV10)', async () => {
|
||||
const { bus, onApplied } = setup(new Map([[UUID_H, tunnel(UUID_H, UUID_A)]]))
|
||||
bus.publish({ scope: { kind: 'host', hostId: UUID_H }, at: 1, reason: 'super-secret-reason' })
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(onApplied).toHaveBeenCalledTimes(1)
|
||||
const [, hostsAffected, elapsed] = onApplied.mock.calls[0]!
|
||||
expect(hostsAffected).toBe(1)
|
||||
expect(typeof elapsed).toBe('number')
|
||||
})
|
||||
|
||||
test('close() unsubscribes cleanly', () => {
|
||||
const bus = fakeBus()
|
||||
const handle = startRevocationSubscriber({
|
||||
subscribe: bus.subscribe,
|
||||
drainHost: vi.fn(async () => {}),
|
||||
tunnels: () => new Map(),
|
||||
now: () => 0,
|
||||
})
|
||||
handle.close()
|
||||
expect(bus.sub.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
67
term-relay/test/stream.test.ts
Normal file
67
term-relay/test/stream.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import {
|
||||
initialStreamState,
|
||||
nextStreamState,
|
||||
isTerminal,
|
||||
type StreamState,
|
||||
type StreamTransition,
|
||||
} from '../mux/stream.js'
|
||||
|
||||
function legal(t: StreamTransition): StreamState {
|
||||
if ('illegal' in t) throw new Error('expected legal transition')
|
||||
return t.next
|
||||
}
|
||||
|
||||
describe('stream state machine (T3, §4.1 lifecycle)', () => {
|
||||
test('legal path: idle --OPEN--> open --DATA*--> open --CLOSE--> closed', () => {
|
||||
let s = initialStreamState()
|
||||
expect(s).toBe('idle')
|
||||
s = legal(nextStreamState(s, 'open', false, 'outbound'))
|
||||
expect(s).toBe('open')
|
||||
s = legal(nextStreamState(s, 'data', false, 'outbound'))
|
||||
s = legal(nextStreamState(s, 'data', false, 'inbound'))
|
||||
expect(s).toBe('open')
|
||||
s = legal(nextStreamState(s, 'close', true, 'outbound'))
|
||||
expect(s).toBe('closed')
|
||||
expect(isTerminal(s)).toBe(true)
|
||||
})
|
||||
|
||||
test('DATA with fin half-closes the SENDING direction; both-side FIN ⇒ closed', () => {
|
||||
const outFin = legal(nextStreamState('open', 'data', true, 'outbound'))
|
||||
expect(outFin).toBe('halfClosedLocal')
|
||||
const inFin = legal(nextStreamState('open', 'data', true, 'inbound'))
|
||||
expect(inFin).toBe('halfClosedRemote')
|
||||
// The other side FINs ⇒ fully closed.
|
||||
expect(legal(nextStreamState('halfClosedLocal', 'data', true, 'inbound'))).toBe('closed')
|
||||
expect(legal(nextStreamState('halfClosedRemote', 'data', true, 'outbound'))).toBe('closed')
|
||||
})
|
||||
|
||||
test('illegal: DATA before OPEN', () => {
|
||||
expect(nextStreamState('idle', 'data', false, 'inbound')).toEqual({ illegal: true })
|
||||
})
|
||||
|
||||
test('illegal: any frame after closed', () => {
|
||||
for (const t of ['data', 'close', 'windowUpdate', 'open'] as const) {
|
||||
expect(nextStreamState('closed', t, false, 'inbound')).toEqual({ illegal: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('illegal: OPEN on an already-open stream', () => {
|
||||
expect(nextStreamState('open', 'open', false, 'inbound')).toEqual({ illegal: true })
|
||||
})
|
||||
|
||||
test('illegal: CLOSE before OPEN', () => {
|
||||
expect(nextStreamState('idle', 'close', false, 'inbound')).toEqual({ illegal: true })
|
||||
})
|
||||
|
||||
test('WINDOW_UPDATE keeps state and is legal while flowing', () => {
|
||||
expect(legal(nextStreamState('open', 'windowUpdate', false, 'inbound'))).toBe('open')
|
||||
expect(legal(nextStreamState('halfClosedLocal', 'windowUpdate', false, 'inbound'))).toBe(
|
||||
'halfClosedLocal',
|
||||
)
|
||||
})
|
||||
|
||||
test('illegal: re-FIN on the already-closed direction', () => {
|
||||
expect(nextStreamState('halfClosedLocal', 'data', true, 'outbound')).toEqual({ illegal: true })
|
||||
})
|
||||
})
|
||||
38
term-relay/test/subdomain-router.test.ts
Normal file
38
term-relay/test/subdomain-router.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import { extractSubdomain } from '../data-plane/subdomain-router.js'
|
||||
|
||||
const BASE = 'term.example.com'
|
||||
|
||||
describe('extractSubdomain (T7, INV1 Host-confusion guard)', () => {
|
||||
test('single-label tenant resolves to the leftmost label', () => {
|
||||
expect(extractSubdomain('alice.term.example.com', BASE)).toBe('alice')
|
||||
})
|
||||
|
||||
test('bare base domain → null (no tenant)', () => {
|
||||
expect(extractSubdomain('term.example.com', BASE)).toBeNull()
|
||||
})
|
||||
|
||||
test('suffix-confusion attempt → null', () => {
|
||||
expect(extractSubdomain('alice.term.example.com.evil.com', BASE)).toBeNull()
|
||||
})
|
||||
|
||||
test('multi-label (nested) subdomain → null (single-label tenant only)', () => {
|
||||
expect(extractSubdomain('a.b.term.example.com', BASE)).toBeNull()
|
||||
})
|
||||
|
||||
test('case-insensitive host', () => {
|
||||
expect(extractSubdomain('ALICE.Term.Example.COM', BASE)).toBe('alice')
|
||||
})
|
||||
|
||||
test('trailing dot tolerated', () => {
|
||||
expect(extractSubdomain('alice.term.example.com.', BASE)).toBe('alice')
|
||||
})
|
||||
|
||||
test('host:port suffix is stripped before matching', () => {
|
||||
expect(extractSubdomain('alice.term.example.com:8443', BASE)).toBe('alice')
|
||||
})
|
||||
|
||||
test('invalid DNS label → null', () => {
|
||||
expect(extractSubdomain('_bad.term.example.com', BASE)).toBeNull()
|
||||
})
|
||||
})
|
||||
124
term-relay/test/upgrade.test.ts
Normal file
124
term-relay/test/upgrade.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts'
|
||||
import {
|
||||
authorizeUpgrade,
|
||||
extractCapabilityToken,
|
||||
TOKEN_COOKIE_NAME,
|
||||
type UpgradeRequest,
|
||||
type AuthorizeDeps,
|
||||
} from '../data-plane/upgrade.js'
|
||||
import type { Authorizer, AuthzOutcome } from '../data-plane/authz-port.js'
|
||||
import type { RouteResolver, ResolvedHost } from '../data-plane/subdomain-router.js'
|
||||
|
||||
const RAW_TOKEN = 'cap-token-abc123'
|
||||
const RESOLVED: ResolvedHost = { hostId: 'host-uuid-1', accountId: 'acct-1', subdomain: 'alice' }
|
||||
|
||||
function resolver(overrides?: Partial<Record<string, ResolvedHost | null>>): RouteResolver {
|
||||
return {
|
||||
resolveSubdomain: async (sub) => {
|
||||
if (overrides && sub in overrides) return overrides[sub] ?? null
|
||||
return sub === 'alice' ? RESOLVED : null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function okAuthorizer(): Authorizer {
|
||||
const ok: AuthzOutcome = { ok: true, hostId: RESOLVED.hostId, principal: 'p-1', jti: 'jti-9' }
|
||||
return { onUpgrade: vi.fn(async () => ok), onReattach: vi.fn(async () => ok) }
|
||||
}
|
||||
|
||||
function baseReq(over?: Partial<UpgradeRequest>): UpgradeRequest {
|
||||
return {
|
||||
host: 'alice.term.example.com',
|
||||
origin: 'https://alice.term.example.com',
|
||||
url: '/term?join=x',
|
||||
subprotocols: [APP_SUBPROTOCOL, encodeTokenSubprotocol(RAW_TOKEN)],
|
||||
cookies: {},
|
||||
remoteAddr: '203.0.113.7',
|
||||
dpop: { proof: 'proof', publicKeyThumbprint: 'jkt' },
|
||||
activeSessionCount: 2,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function deps(authorizer: Authorizer, r: RouteResolver = resolver()): AuthorizeDeps {
|
||||
return {
|
||||
authorizer,
|
||||
resolver: r,
|
||||
baseDomain: 'term.example.com',
|
||||
now: () => 1000,
|
||||
remoteAddrSalt: 'salt',
|
||||
requiredRight: 'attach',
|
||||
}
|
||||
}
|
||||
|
||||
describe('authorizeUpgrade — thin adapter over P5 (T8, FIX 6a)', () => {
|
||||
test('happy: delegates to onUpgrade with a fully-populated ctx; maps to {ok:true}', async () => {
|
||||
const auth = okAuthorizer()
|
||||
const d = deps(auth)
|
||||
const res = await authorizeUpgrade(baseReq(), d)
|
||||
expect(res.ok).toBe(true)
|
||||
if (!res.ok) return
|
||||
expect(res.hostId).toBe(RESOLVED.hostId)
|
||||
expect(res.open.subdomain).toBe('alice')
|
||||
expect(res.open.capabilityTokenRef).toBe('jti-9')
|
||||
expect(res.acceptedSubprotocol).toBe(APP_SUBPROTOCOL)
|
||||
expect(auth.onUpgrade).toHaveBeenCalledTimes(1)
|
||||
const ctx = (auth.onUpgrade as ReturnType<typeof vi.fn>).mock.calls[0]![0]
|
||||
expect(ctx.expectedAud).toBe('alice')
|
||||
expect(ctx.requestedHostId).toBe(RESOLVED.hostId)
|
||||
expect(ctx.requiredRight).toBe('attach')
|
||||
expect(ctx.capabilityRaw).toBe(RAW_TOKEN) // decoded from the subprotocol carrier
|
||||
expect(ctx.dpop).toEqual({ proof: 'proof', publicKeyThumbprint: 'jkt' })
|
||||
expect(ctx.activeSessionCount).toBe(2)
|
||||
expect(ctx.principal).toBeNull() // P5 resolves identity from the signed token (INV3)
|
||||
})
|
||||
|
||||
test('P5 deny is passed through verbatim; no independent allow branch', async () => {
|
||||
const deny: AuthzOutcome = { ok: false, status: 401 }
|
||||
const auth: Authorizer = { onUpgrade: vi.fn(async () => deny), onReattach: vi.fn(async () => deny) }
|
||||
const res = await authorizeUpgrade(baseReq(), deps(auth))
|
||||
expect(res).toEqual({ ok: false, status: 401 })
|
||||
})
|
||||
|
||||
test('reattach: sessionId present → onReattach is called (not onUpgrade)', async () => {
|
||||
const auth = okAuthorizer()
|
||||
await authorizeUpgrade(baseReq({ sessionId: 'sess-1' }), deps(auth))
|
||||
expect(auth.onReattach).toHaveBeenCalledTimes(1)
|
||||
expect(auth.onUpgrade).not.toHaveBeenCalled()
|
||||
const ctx = (auth.onReattach as ReturnType<typeof vi.fn>).mock.calls[0]![0]
|
||||
expect(ctx.sessionId).toBe('sess-1')
|
||||
})
|
||||
|
||||
test('token via cookie fallback is accepted', async () => {
|
||||
const auth = okAuthorizer()
|
||||
const req = baseReq({ subprotocols: [APP_SUBPROTOCOL], cookies: { [TOKEN_COOKIE_NAME]: RAW_TOKEN } })
|
||||
const res = await authorizeUpgrade(req, deps(auth))
|
||||
expect(res.ok).toBe(true)
|
||||
const via = extractCapabilityToken(req)
|
||||
expect(via).toEqual({ token: RAW_TOKEN, via: 'cookie' })
|
||||
})
|
||||
|
||||
test('INV15: a token ONLY in the query string → 401 and the authorizer is NEVER called', async () => {
|
||||
const auth = okAuthorizer()
|
||||
const req = baseReq({ url: `/term?cap=${RAW_TOKEN}`, subprotocols: [APP_SUBPROTOCOL], cookies: {} })
|
||||
const res = await authorizeUpgrade(req, deps(auth))
|
||||
expect(res).toEqual({ ok: false, status: 401 })
|
||||
expect(auth.onUpgrade).not.toHaveBeenCalled()
|
||||
expect(extractCapabilityToken(req)).toBeNull() // never reads req.url
|
||||
})
|
||||
|
||||
test('local parse: unparseable Host → 401 without calling the authorizer', async () => {
|
||||
const auth = okAuthorizer()
|
||||
const res = await authorizeUpgrade(baseReq({ host: 'term.example.com' }), deps(auth))
|
||||
expect(res).toEqual({ ok: false, status: 401 })
|
||||
expect(auth.onUpgrade).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('local parse: unknown subdomain (resolver null) → 403 without calling the authorizer', async () => {
|
||||
const auth = okAuthorizer()
|
||||
const res = await authorizeUpgrade(baseReq({ host: 'bob.term.example.com' }), deps(auth))
|
||||
expect(res).toEqual({ ok: false, status: 403 })
|
||||
expect(auth.onUpgrade).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
22
term-relay/tsconfig.json
Normal file
22
term-relay/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["mux/**/*.ts", "data-plane/**/*.ts", "frp-scaffold/**/*.ts", "test/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
13
term-relay/vitest.config.ts
Normal file
13
term-relay/vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['mux/**/*.ts', 'data-plane/**/*.ts', 'frp-scaffold/**/*.ts'],
|
||||
exclude: ['**/*.d.ts'],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user