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