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.
75 lines
3.0 KiB
TypeScript
75 lines
3.0 KiB
TypeScript
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()
|
|
})
|
|
})
|