/** * Phase B: PilotTunnelBridge handles `tcp_open_reverse` from the agent. * Two paths: * - target is the central's local node: dial via Dockerode container IP * and splice bytes between the resulting `net.Socket` and the tunnel. * - target is another pilot: open a forward `TcpStream` on the target * pilot's bridge and relay. * * The local-dial path is the common case for small fleets. This file * exercises the validation gate (agent-id-range, stream cap) and the * local-dial outcome by intercepting `MeshService.resolveContainerIp`. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import net from 'net'; import { EventEmitter } from 'events'; import { WebSocket } from 'ws'; import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; import { AGENT_REVERSE_ID_BASE, BinaryFrameType, decodeJsonFrame, encodeBinaryFrame, encodeJsonFrame, } from '../pilot/protocol'; let tmpDir: string; let PilotTunnelBridge: typeof import('../services/PilotTunnelBridge').PilotTunnelBridge; let MeshService: typeof import('../services/MeshService').MeshService; beforeAll(async () => { tmpDir = await setupTestDb(); ({ PilotTunnelBridge } = await import('../services/PilotTunnelBridge')); ({ MeshService } = await import('../services/MeshService')); }); afterAll(() => { vi.restoreAllMocks(); cleanupTestDb(tmpDir); }); function makeMockTunnelWs(): EventEmitter & { sent: unknown[]; readyState: number; bufferedAmount: number; send: (data: unknown) => void; ping: () => void; close: () => void; } { const ws = new EventEmitter() as EventEmitter & { sent: unknown[]; readyState: number; bufferedAmount: number; send: (data: unknown) => void; ping: () => void; close: () => void; }; ws.sent = []; ws.readyState = WebSocket.OPEN; ws.bufferedAmount = 0; ws.send = (data: unknown) => { ws.sent.push(data); }; ws.ping = () => { /* no-op */ }; ws.close = () => { ws.readyState = WebSocket.CLOSED; ws.emit('close'); }; return ws; } function findAck(ws: { sent: unknown[] }, s: number): { ok: boolean; err?: string } | undefined { for (const item of ws.sent) { if (typeof item !== 'string') continue; try { const f = decodeJsonFrame(item); if (f.t === 'tcp_open_ack' && f.s === s) return { ok: f.ok, err: f.err }; } catch { /* ignore */ } } return undefined; } async function waitFor(check: () => T | undefined): Promise { const deadline = Date.now() + 1500; while (Date.now() < deadline) { const v = check(); if (v !== undefined) return v; await new Promise((r) => setTimeout(r, 10)); } throw new Error('timeout'); } describe('PilotTunnelBridge handles tcp_open_reverse (Phase B)', () => { it('rejects frames whose s is below the agent-reverse range', async () => { const mockWs = makeMockTunnelWs(); const bridge = new PilotTunnelBridge(1, mockWs as unknown as WebSocket); await bridge.start(); // Drive the tunnel-message handler with a low-range id; the // validation gate must reject and emit a tcp_open_ack {ok: false}. mockWs.emit('message', encodeJsonFrame({ t: 'tcp_open_reverse', s: 5, targetNodeId: 1, stack: 's', service: 'svc', port: 80, }), false); const ack = await waitFor(() => findAck(mockWs, 5)); expect(ack.ok).toBe(false); expect(ack.err).toBe('agent_error'); bridge.close(); }); it('local target with no Dockerode resolution returns ok:false err:no_target', async () => { const mockWs = makeMockTunnelWs(); const bridge = new PilotTunnelBridge(1, mockWs as unknown as WebSocket); await bridge.start(); // The default node id from the test DB. Force resolveContainerIp to // return null to simulate a stack that does not exist locally. const localNodeId = (await import('../services/NodeRegistry')).NodeRegistry.getInstance().getDefaultNodeId(); const spy = vi.spyOn(MeshService.getInstance(), 'resolveContainerIp').mockResolvedValue(null); const s = AGENT_REVERSE_ID_BASE + 1; mockWs.emit('message', encodeJsonFrame({ t: 'tcp_open_reverse', s, targetNodeId: localNodeId, stack: 'missing', service: 'svc', port: 80, }), false); const ack = await waitFor(() => findAck(mockWs, s)); expect(ack.ok).toBe(false); expect(ack.err).toBe('no_target'); expect(spy).toHaveBeenCalledTimes(1); bridge.close(); vi.restoreAllMocks(); }); it('buffers TcpData arriving during the resolveContainerIp await and flushes it on connect (F-9 reverse race fix)', async () => { const mockWs = makeMockTunnelWs(); const bridge = new PilotTunnelBridge(1, mockWs as unknown as WebSocket); await bridge.start(); // Upstream that captures received bytes so we can assert the peer's // request body was delivered (not silently dropped during the // resolveContainerIp window). let resolveReceived!: (buf: Buffer) => void; const received = new Promise((r) => { resolveReceived = r; }); const upstream = net.createServer((socket) => { let acc = Buffer.alloc(0); socket.on('data', (chunk: Buffer) => { acc = Buffer.concat([acc, chunk]); if (acc.length >= 'POST /hook HTTP/1.1\r\n\r\n'.length) resolveReceived(acc); }); }); await new Promise((resolve) => upstream.listen(0, '127.0.0.1', () => resolve())); const addr = upstream.address(); if (!addr || typeof addr === 'string') throw new Error('no address'); const upstreamPort = addr.port; const localNodeId = (await import('../services/NodeRegistry')).NodeRegistry.getInstance().getDefaultNodeId(); // Delay the resolve to widen the race window; without the fix the // TcpData frame below lands while this.streams has no entry for s // and the bytes are dropped at the lookup-miss path in // handleBinaryFrame, so the upstream never sees the request. vi.spyOn(MeshService.getInstance(), 'resolveContainerIp') .mockImplementation(() => new Promise((r) => setTimeout(() => r('127.0.0.1'), 50))); const s = AGENT_REVERSE_ID_BASE + 3; mockWs.emit('message', encodeJsonFrame({ t: 'tcp_open_reverse', s, targetNodeId: localNodeId, stack: 'real', service: 'svc', port: upstreamPort, }), false); // Immediately push the "HTTP request" bytes while resolve is in flight. mockWs.emit('message', encodeBinaryFrame(BinaryFrameType.TcpData, s, Buffer.from('POST /hook HTTP/1.1\r\n\r\n')), true); const body = await received; expect(body.toString()).toBe('POST /hook HTTP/1.1\r\n\r\n'); // ack:true should also have landed (after the flush). const ack = await waitFor(() => findAck(mockWs, s)); expect(ack.ok).toBe(true); upstream.close(); bridge.close(); vi.restoreAllMocks(); }); it('local target with a working dial sends ok:true and registers a reverse stream', async () => { const mockWs = makeMockTunnelWs(); const bridge = new PilotTunnelBridge(1, mockWs as unknown as WebSocket); await bridge.start(); // Spin up a real local server the bridge will dial as the target. const upstream = net.createServer((socket) => { socket.write('hello-upstream'); }); await new Promise((resolve) => upstream.listen(0, '127.0.0.1', () => resolve())); const addr = upstream.address(); if (!addr || typeof addr === 'string') throw new Error('no address'); const upstreamPort = addr.port; const localNodeId = (await import('../services/NodeRegistry')).NodeRegistry.getInstance().getDefaultNodeId(); vi.spyOn(MeshService.getInstance(), 'resolveContainerIp').mockResolvedValue('127.0.0.1'); const s = AGENT_REVERSE_ID_BASE + 2; mockWs.emit('message', encodeJsonFrame({ t: 'tcp_open_reverse', s, targetNodeId: localNodeId, stack: 'real', service: 'svc', port: upstreamPort, }), false); const ack = await waitFor(() => findAck(mockWs, s)); expect(ack.ok).toBe(true); // Stream is registered on the bridge. const streams = (bridge as unknown as { streams: Map }).streams; const state = streams.get(s); expect(state?.kind).toBe('reverse_local'); upstream.close(); bridge.close(); vi.restoreAllMocks(); }); it('buffers TcpData arriving during ensureBridge and flushes it on target open (reverse-relay early-data fix)', async () => { const mockWs = makeMockTunnelWs(); const bridge = new PilotTunnelBridge(1, mockWs as unknown as WebSocket); await bridge.start(); // Fake target stream: an EventEmitter with a write() method that // captures bytes. We control when 'open' fires to widen the // race window the fix guards. const writes: Buffer[] = []; const fakeTargetStream = new EventEmitter() as EventEmitter & { write: (b: Buffer) => void; }; fakeTargetStream.write = (b: Buffer) => { writes.push(b); }; const fakeTargetBridge = { openTcpStream: vi.fn(() => fakeTargetStream), getBufferedAmount: () => 0, }; const { PilotTunnelManager } = await import('../services/PilotTunnelManager'); // Delay ensureBridge so a TcpData frame fired back-to-back with // tcp_open_reverse lands while acceptReverseRelay is still // waiting on the target bridge. Without the fix the bytes are // dropped at the lookup-miss path in handleBinaryFrame. vi.spyOn(PilotTunnelManager.getInstance(), 'ensureBridge') .mockImplementation(() => new Promise((r) => setTimeout(() => r(fakeTargetBridge as never), 50))); const { NodeRegistry } = await import('../services/NodeRegistry'); const localNodeId = NodeRegistry.getInstance().getDefaultNodeId(); const remoteTargetNodeId = localNodeId + 99; const s = AGENT_REVERSE_ID_BASE + 7; mockWs.emit('message', encodeJsonFrame({ t: 'tcp_open_reverse', s, targetNodeId: remoteTargetNodeId, stack: 'real', service: 'svc', port: 8080, }), false); // Immediately push the request bytes while ensureBridge is in flight. mockWs.emit('message', encodeBinaryFrame(BinaryFrameType.TcpData, s, Buffer.from('POST /hook HTTP/1.1\r\n\r\n')), true); // Wait until acceptReverseRelay has actually swapped the reservation // to kind:'reverse_relay' (and targetOpen is still false because the // target hasn't emitted 'open'). Polling the bridge's stream state // directly is more deterministic than polling openTcpStream.mock // calls, since the state swap is synchronous after openTcpStream // returns but the JS scheduler decides when the test sees it. const bridgeStreams = (bridge as unknown as { streams: Map }).streams; await waitFor(() => { const cur = bridgeStreams.get(s); return (cur?.kind === 'reverse_relay' && cur.targetOpen === false) ? true : undefined; }); // Second TcpData frame in the targetOpen===false window after the // state swap. Without the reverse_relay branch's pendingData buffer, // these bytes would be silently dropped. mockWs.emit('message', encodeBinaryFrame(BinaryFrameType.TcpData, s, Buffer.from('mid-window')), true); // Fire 'open' on the fake target so the relay flushes its pending buffer. fakeTargetStream.emit('open'); const ack = await waitFor(() => findAck(mockWs, s)); expect(ack.ok).toBe(true); // Both pre-openTcpStream and post-state-swap buffered bytes were // flushed exactly once, intact, in order. await waitFor(() => (Buffer.concat(writes).toString().includes('mid-window')) ? true : undefined); const combined = Buffer.concat(writes).toString(); expect(combined).toBe('POST /hook HTTP/1.1\r\n\r\nmid-window'); // Subsequent post-open writes also flow. mockWs.emit('message', encodeBinaryFrame(BinaryFrameType.TcpData, s, Buffer.from('trailer')), true); await waitFor(() => (Buffer.concat(writes).toString().endsWith('trailer')) ? true : undefined); expect(Buffer.concat(writes).toString()).toBe('POST /hook HTTP/1.1\r\n\r\nmid-windowtrailer'); bridge.close(); vi.restoreAllMocks(); }); });