feat(relay): Phase1 waves A2-E — server entry, shared-store data plane, agent runtime, deploy artifacts
RELAY-PHASE1 Wave A2/B/C/D/E (12-agent workflow, all tsc-clean, 314/314 tests pass): - A2: control-plane server.ts entry + boot/redis.ts revocation-bus wiring + start script. - B1: relay-run shared-store EnforceDeps (relay-auth ports over the SAME Postgres+Redis as P3). - B2: registry-backed MtlsVerifier (verifyAgentCert, fail-closed, INV14). - B3: store-backed RouteResolver (subdomain->hostId). - B4: Redis relay:revocations subscriber -> tunnel teardown (INV12). - B5: main-phase1.ts production entry (public bind, real TLS, async-mTLS prefetch bridge) + staging /auth/mint. - B6 (PARTIAL): relay-web operator login + browser DPoP; proof offered via term.dpop.<b64u> subprotocol. - C: agent dist/cli.js build (esbuild) + runTunnel run-loop + CliDeps. - D1: same-origin static serve of relay-web/public from the browser WSS. - E: systemd units + gen-ca/gen-capability-key/issue-tls-cert scripts + deploy/RUNBOOK.md. Adversarial review: all hard invariants PASS. Follow-ups (B7): close DPoP-subprotocol read on browser-server (blocks browser connect); rate-limit /auth/mint (F1); wire activeSessionCount (F2); scrub error logs (F5). Excludes unrelated public/style.css (concurrent iOS job).
This commit is contained in:
128
agent/test/runTunnel.test.ts
Normal file
128
agent/test/runTunnel.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { decodeMuxFrame, encodeGoaway, encodeMuxFrame, encodeOpen, type MuxOpen } from 'relay-contracts'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import type { Keystore } from '../src/keys/keystore.js'
|
||||
import { createBackoff } from '../src/transport/backoff.js'
|
||||
import { runTunnel, type RunTunnelDeps } from '../src/transport/runTunnel.js'
|
||||
import { FakeTimer, FakeWs } from './fixtures/fakes.js'
|
||||
|
||||
/**
|
||||
* C2 supervisor: proves runTunnel ports the cafeDemo assembly into a supervised loop —
|
||||
* bytes splice both ways, a dead session reconnects, and a `revoked` GOAWAY stops for good (INV12).
|
||||
*/
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
const OPEN: MuxOpen = {
|
||||
streamId: 5,
|
||||
subdomain: 'host-42',
|
||||
requestPath: '/term?join=abc',
|
||||
originHeader: 'https://host-42.term.example.com',
|
||||
remoteAddrHash: 'x',
|
||||
capabilityTokenRef: 'jti',
|
||||
}
|
||||
const KS = {} as unknown as Keystore // unused when connectRelay/dialLoopback are injected
|
||||
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
|
||||
|
||||
function emitOpen(upstream: FakeWs, open: MuxOpen): void {
|
||||
const payload = encodeOpen(open)
|
||||
upstream.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'open', fin: false, rst: false, streamId: open.streamId, payloadLen: payload.length },
|
||||
payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
function emitGoAwayRevoked(upstream: FakeWs): void {
|
||||
const payload = encodeGoaway(0, 'revoked')
|
||||
upstream.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: payload.length },
|
||||
payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function baseDeps(over: Partial<RunTunnelDeps>): Partial<RunTunnelDeps> {
|
||||
return {
|
||||
timer: new FakeTimer(), // never auto-fires ⇒ heartbeat is inert during the test
|
||||
sleep: async () => {},
|
||||
backoff: createBackoff(),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('runTunnel supervisor (C2)', () => {
|
||||
it('splices bytes both ways through the loopback', async () => {
|
||||
const upstream = new FakeWs()
|
||||
const loopback = new FakeWs()
|
||||
const connectRelay = vi.fn(async () => upstream)
|
||||
const dialLoopback = vi.fn(async () => loopback)
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: dialLoopback as never }))
|
||||
await flush()
|
||||
|
||||
emitOpen(upstream, OPEN)
|
||||
await flush()
|
||||
expect(dialLoopback).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
|
||||
|
||||
upstream.emitMessage(encodeMuxFrame({ version: 1, type: 'data', fin: false, rst: false, streamId: 5, payloadLen: 3 }, new Uint8Array([104, 105, 10])))
|
||||
expect(loopback.sent.at(-1)).toEqual(new Uint8Array([104, 105, 10]))
|
||||
|
||||
loopback.emit('message', new Uint8Array([79, 75])) // "OK" echoes back upstream as DATA
|
||||
const last = decodeMuxFrame(upstream.sent.at(-1)!)
|
||||
expect(last.header.type).toBe('data')
|
||||
expect([...last.payload]).toEqual([79, 75])
|
||||
|
||||
await handle.stop()
|
||||
expect(await handle.done).toBe(0)
|
||||
})
|
||||
|
||||
it('reconnects after the tunnel dies', async () => {
|
||||
const sockets = [new FakeWs(), new FakeWs()]
|
||||
let i = 0
|
||||
const connectRelay = vi.fn(async () => sockets[i++]!)
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
|
||||
await flush()
|
||||
expect(connectRelay).toHaveBeenCalledTimes(1)
|
||||
|
||||
sockets[0]!.emit('close') // first session dies ⇒ supervisor redials
|
||||
await flush()
|
||||
expect(connectRelay).toHaveBeenCalledTimes(2)
|
||||
|
||||
await handle.stop()
|
||||
})
|
||||
|
||||
it('a revoked GOAWAY tears down and NEVER reconnects (INV12)', async () => {
|
||||
const connectRelay = vi.fn(async () => new FakeWs())
|
||||
let socket: FakeWs | undefined
|
||||
const wrapped = vi.fn(async () => {
|
||||
socket = new FakeWs()
|
||||
return socket
|
||||
})
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay: wrapped, dialLoopback: async () => new FakeWs() }))
|
||||
await flush()
|
||||
expect(wrapped).toHaveBeenCalledTimes(1)
|
||||
|
||||
emitGoAwayRevoked(socket!)
|
||||
await flush()
|
||||
|
||||
expect(await handle.done).toBe(0)
|
||||
expect(wrapped).toHaveBeenCalledTimes(1) // no reconnect after revocation
|
||||
expect(connectRelay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stop() ends the loop with exit code 0 and does not reconnect', async () => {
|
||||
const connectRelay = vi.fn(async () => new FakeWs())
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
|
||||
await flush()
|
||||
|
||||
await handle.stop()
|
||||
expect(await handle.done).toBe(0)
|
||||
expect(connectRelay).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user