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