Files
sencho/backend/src/__tests__/pilot-bridge-reverse.test.ts
T
Anso 567e524450 feat(mesh): bidirectional routing via tcp_open_reverse and central relay (#1003)
* feat(mesh): bidirectional routing via tcp_open_reverse and central relay

Phase B of the mesh redesign. Adds the reverse-direction protocol so a
pilot's MeshForwarder can route cross-node traffic to central or to
another pilot. Central relays pilot-to-pilot streams transparently;
pilots keep their existing single outbound WS to central.

Protocol additions (backend/src/pilot/protocol.ts):
- TcpOpenReverseFrame { s, targetNodeId, stack, service, port } sent
  agent to primary. Reuses existing tcp_open_ack, tcp_close, and
  TcpData binary frames for the response and byte plane.
- AGENT_REVERSE_ID_BASE = 0x40000001 splits the 32-bit id space so
  agent-allocated reverse stream ids never collide with primary-
  allocated forward ids on the same tunnel.
- StreamIdAllocator now wraps to its configured start (not always 1)
  so an allocator parameterized with the agent base stays in the
  agent half across the wrap.

Agent (backend/src/pilot/agent.ts):
- New ReverseTcpStreamHandle exported class, EventEmitter facade
  matching PilotTunnelBridge.TcpStream's surface (write, end,
  destroy plus open, data, error, close events).
- Public openMeshTcpStream(target) allocates a reverse id, sends
  tcp_open_reverse, returns the handle.
- onTcpOpenAckReverse handles inbound ack, dispatches open or
  error+close to the matching handle.
- TcpData and tcp_close binary/JSON paths route ids in the reverse
  range to reverseTcpStreams; existing primary-allocated paths
  unchanged.
- streamCount, cleanupAfterDisconnect, onStreamIdle include reverse
  streams. The allocator is reset to a fresh base on disconnect so
  long-lived agents that reconnect many times do not drift up the id
  space.
- startPilotAgent registers the agent as MeshService's reverse
  dialer via lazy import.

Bridge (backend/src/services/PilotTunnelBridge.ts):
- Two new StreamState kinds: reverse_local (target = central, dial
  Dockerode container IP) and reverse_relay (target = another pilot,
  open a forward TcpStream on the target pilot's bridge).
- handleTcpOpenReverse validates the agent-id range and stream cap,
  then dispatches to acceptReverseLocal or acceptReverseRelay via
  lazy imports of MeshService, NodeRegistry, and PilotTunnelManager
  (avoids module cycles).
- acceptReverseLocal calls MeshService.resolveContainerIp (now
  public), opens net.createConnection, splices bytes through the
  tunnel. Pre-connect error handler is removed inside connect to
  avoid double-firing with the mid-stream error path.
- acceptReverseRelay opens a forward TcpStream on the target
  bridge, splices bytes between the two tunnels.
- Existing tcp_close and TcpData handlers extended for the new
  state kinds. teardownStream extended.

MeshService (backend/src/services/MeshService.ts):
- resolveContainerIp made public so the bridge's local-target path
  can dial the same shape.
- New reverseDialer field plus setReverseDialer setter. Pilot mode
  registers the agent at boot; central mode leaves it null.
- New private dialMeshTcpStream dispatcher: pilot side uses the
  reverse dialer, central side uses PilotTunnelManager.getBridge.
- openCrossNode refactored to call the dispatcher and use the
  active-stream record's id for activity logging.
- New exported MeshTcpStreamLike interface that both
  PilotTunnelBridge.TcpStream and ReverseTcpStreamHandle satisfy
  structurally; ReverseMeshDialer is the contract for the setter.

Tests (3 files, 16 new cases, 172 total pass):
- pilot-protocol-tcp.test.ts: tcp_open_reverse round-trip; agent
  base invariant.
- pilot-agent-reverse-stream.test.ts: openMeshTcpStream allocation,
  ws-not-open, frame shape, write encoding, end emits tcp_close,
  ack-success, ack-failure, low-id ack ignored, inbound TcpData
  routing.
- pilot-bridge-reverse.test.ts: id-range validation, no-target
  failure, successful local dial against a real upstream server.

Together with PR #1000 (Phase A) and PR #1001 (deps), Phase B
completes the central-pilot-pilot mesh routing matrix. Phase C
(mesh over proxy-mode remotes) is the planned follow-up.

* test(pilot): drop unused encodeJsonFrame import

Lint failed on pilot-agent-reverse-stream.test.ts after the test
changed from constructing tcp_open_reverse frames inline to driving
the agent's frame-dispatch path with synthetic objects. The import
is no longer referenced; ESLint's no-unused-vars rule rejected it.
2026-05-08 16:21:34 -04:00

162 lines
6.1 KiB
TypeScript

/**
* 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,
decodeJsonFrame,
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<T>(check: () => T | undefined): Promise<T> {
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('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<void>((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<number, { kind: string }> }).streams;
const state = streams.get(s);
expect(state?.kind).toBe('reverse_local');
upstream.close();
bridge.close();
vi.restoreAllMocks();
});
});