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:
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user