feat(pilot): add tcp tunnel frames + mesh sidecar package (#857)

Lays the dormant data-plane foundation for Sencho Mesh. The pilot tunnel
gains TCP forwarding frames (tcp_open / tcp_open_ack / tcp_close JSON
plus a 0x04 TcpData binary type) and a TcpStream surface on the bridge
so a future MeshService can ride the existing WSS tunnel for cross-node
container traffic. The agent rejects every tcp_open with mesh_not_enabled
until a follow-up PR wires the Dockerode resolver gated by a
mesh_stacks opt-in table; ships dormant.

A new top-level mesh-sidecar/ package provides the per-node container
that will host the L4 forwarder + control WS in production. Built as a
small Node 22 alpine image and published in lockstep with the main
sencho image via a parallel docker-publish workflow job.

Tests cover protocol roundtrips on both packages and the sidecar
forwarder end-to-end including resolve, splice, close, and stats.
This commit is contained in:
Anso
2026-05-01 00:28:18 -04:00
committed by GitHub
parent b8437e8780
commit 6893ece898
17 changed files with 2706 additions and 6 deletions
@@ -0,0 +1,190 @@
import net from 'net';
import { afterEach, describe, expect, it } from 'vitest';
import { Forwarder, MeshController } from '../forwarder';
interface RecordedCalls {
resolve: Array<{ connId: number; port: number; remoteAddr: string }>;
sendData: Array<{ streamId: number; payload: Buffer }>;
sendClose: number[];
sendStats: Array<{ streamId: number; bytesIn: number; bytesOut: number }>;
}
function makeRecordingController(): { controller: MeshController; calls: RecordedCalls } {
const calls: RecordedCalls = { resolve: [], sendData: [], sendClose: [], sendStats: [] };
const controller: MeshController = {
resolve: (connId, port, remoteAddr) => calls.resolve.push({ connId, port, remoteAddr }),
sendData: (streamId, payload) => calls.sendData.push({ streamId, payload: Buffer.from(payload) }),
sendClose: (streamId) => calls.sendClose.push(streamId),
sendStats: (streamId, bytesIn, bytesOut) => calls.sendStats.push({ streamId, bytesIn, bytesOut }),
};
return { controller, calls };
}
async function getEphemeralPort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
if (!addr || typeof addr === 'string') {
reject(new Error('no address'));
return;
}
const port = addr.port;
server.close(() => resolve(port));
});
});
}
async function dial(port: number): Promise<net.Socket> {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host: '127.0.0.1', port });
socket.once('connect', () => resolve(socket));
socket.once('error', reject);
});
}
async function waitFor<T>(check: () => T | undefined, timeoutMs = 1000): Promise<T> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const value = check();
if (value !== undefined && value !== null && (Array.isArray(value) ? value.length > 0 : true)) {
return value as T;
}
await new Promise((r) => setTimeout(r, 10));
}
throw new Error('timeout waiting for condition');
}
describe('Forwarder', () => {
let forwarder: Forwarder | null = null;
afterEach(async () => {
if (forwarder) await forwarder.shutdown();
forwarder = null;
});
it('asks the controller to resolve when a client connects', async () => {
const { controller, calls } = makeRecordingController();
forwarder = new Forwarder(controller);
forwarder.start();
const port = await getEphemeralPort();
await forwarder.listen(port);
const client = await dial(port);
const resolved = await waitFor(() => calls.resolve.length ? calls.resolve : undefined);
expect(resolved[0].port).toBe(port);
expect(resolved[0].connId).toBeGreaterThan(0);
client.destroy();
});
it('splices bytes both directions after resolve_ok', async () => {
const { controller, calls } = makeRecordingController();
forwarder = new Forwarder(controller);
forwarder.start();
const port = await getEphemeralPort();
await forwarder.listen(port);
const received: Buffer[] = [];
const client = await dial(port);
client.on('data', (chunk: Buffer) => received.push(chunk));
const resolved = await waitFor(() => calls.resolve.length ? calls.resolve : undefined);
const connId = resolved[0].connId;
const streamId = 42;
forwarder.handleResolveOk(connId, streamId, 'test.alias.sencho');
client.write('hello');
const sent = await waitFor(() => calls.sendData.length ? calls.sendData : undefined);
expect(sent[0].streamId).toBe(streamId);
expect(sent[0].payload.toString()).toBe('hello');
forwarder.handleData(streamId, Buffer.from('hi back'));
await waitFor(() => received.length ? received : undefined);
expect(Buffer.concat(received).toString()).toBe('hi back');
client.destroy();
});
it('sends a close frame when the client socket disconnects', async () => {
const { controller, calls } = makeRecordingController();
forwarder = new Forwarder(controller);
forwarder.start();
const port = await getEphemeralPort();
await forwarder.listen(port);
const client = await dial(port);
const resolved = await waitFor(() => calls.resolve.length ? calls.resolve : undefined);
forwarder.handleResolveOk(resolved[0].connId, 7);
client.destroy();
const closed = await waitFor(() => calls.sendClose.length ? calls.sendClose : undefined);
expect(closed[0]).toBe(7);
});
it('drops the local socket on resolve_err', async () => {
const { controller, calls } = makeRecordingController();
forwarder = new Forwarder(controller);
forwarder.start();
const port = await getEphemeralPort();
await forwarder.listen(port);
const client = await dial(port);
const closedPromise = new Promise<void>((resolve) => client.once('close', () => resolve()));
const resolved = await waitFor(() => calls.resolve.length ? calls.resolve : undefined);
forwarder.handleResolveErr(resolved[0].connId, 'tunnel_down');
await closedPromise;
// No data was ever piped, so no close frame sent for this connId.
expect(calls.sendClose.length).toBe(0);
});
it('destroys the local socket on inbound close', async () => {
const { controller, calls } = makeRecordingController();
forwarder = new Forwarder(controller);
forwarder.start();
const port = await getEphemeralPort();
await forwarder.listen(port);
const client = await dial(port);
const closedPromise = new Promise<void>((resolve) => client.once('close', () => resolve()));
const resolved = await waitFor(() => calls.resolve.length ? calls.resolve : undefined);
forwarder.handleResolveOk(resolved[0].connId, 11);
forwarder.handleClose(11);
await closedPromise;
});
it('refuses new connections during shutdown', async () => {
const { controller } = makeRecordingController();
forwarder = new Forwarder(controller);
forwarder.start();
const port = await getEphemeralPort();
await forwarder.listen(port);
await forwarder.shutdown();
await expect(dial(port)).rejects.toThrow();
forwarder = null;
});
it('emits stats for active streams when the timer fires', async () => {
const { controller, calls } = makeRecordingController();
forwarder = new Forwarder(controller, { statsIntervalMs: 50 });
forwarder.start();
const port = await getEphemeralPort();
await forwarder.listen(port);
const client = await dial(port);
const resolved = await waitFor(() => calls.resolve.length ? calls.resolve : undefined);
forwarder.handleResolveOk(resolved[0].connId, 99);
client.write('x');
const stats = await waitFor(() => calls.sendStats.length ? calls.sendStats : undefined, 1500);
expect(stats[0].streamId).toBe(99);
expect(stats[0].bytesOut).toBeGreaterThan(0);
client.destroy();
});
});
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import {
BinaryFrameType,
decodeControl,
decodeData,
encodeControl,
encodeData,
} from '../protocol';
describe('Mesh sidecar control frames', () => {
it('roundtrips a hello frame', () => {
const raw = encodeControl({ t: 'hello', version: 1, nodeId: 7, sidecarVersion: '0.0.1' });
const decoded = decodeControl(raw);
expect(decoded.t).toBe('hello');
if (decoded.t !== 'hello') throw new Error('narrowing');
expect(decoded.nodeId).toBe(7);
});
it('roundtrips a listen / unlisten pair', () => {
const listen = decodeControl(encodeControl({ t: 'listen', port: 5432 }));
const unlisten = decodeControl(encodeControl({ t: 'unlisten', port: 5432 }));
expect(listen.t).toBe('listen');
expect(unlisten.t).toBe('unlisten');
});
it('roundtrips resolve / resolve_ok / resolve_err', () => {
const resolve = decodeControl(encodeControl({ t: 'resolve', connId: 1, port: 5432, remoteAddr: '10.0.0.5' }));
const ok = decodeControl(encodeControl({ t: 'resolve_ok', connId: 1, streamId: 9, alias: 'db.api.opsix.sencho' }));
const err = decodeControl(encodeControl({ t: 'resolve_err', connId: 1, code: 'tunnel_down' }));
if (resolve.t !== 'resolve') throw new Error('narrowing');
if (ok.t !== 'resolve_ok') throw new Error('narrowing');
if (err.t !== 'resolve_err') throw new Error('narrowing');
expect(resolve.port).toBe(5432);
expect(ok.streamId).toBe(9);
expect(err.code).toBe('tunnel_down');
});
it('rejects malformed control frames', () => {
expect(() => decodeControl('not json')).toThrow();
expect(() => decodeControl('{}')).toThrow();
});
});
describe('Mesh sidecar data frames', () => {
it('encodes the 0x01 type discriminator', () => {
const buf = encodeData(1, Buffer.from('hello'));
expect(buf[0]).toBe(BinaryFrameType.Data);
});
it('roundtrips streamId + payload', () => {
const payload = Buffer.from('SELECT 1;');
const buf = encodeData(0xdeadbeef, payload);
const decoded = decodeData(buf);
expect(decoded.streamId).toBe(0xdeadbeef);
expect(decoded.payload.toString()).toBe('SELECT 1;');
});
it('preserves binary payloads byte-for-byte', () => {
const payload = Buffer.from([0x00, 0xff, 0x01, 0x80, 0x7f]);
const decoded = decodeData(encodeData(1, payload));
expect(decoded.payload.equals(payload)).toBe(true);
});
it('rejects an unknown binary frame type', () => {
const buf = Buffer.alloc(5);
buf.writeUInt8(0x99, 0);
expect(() => decodeData(buf)).toThrow(/unknown binary frame type/);
});
it('rejects too-short frames', () => {
expect(() => decodeData(Buffer.alloc(3))).toThrow(/too short/);
});
});