mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 19:57:12 +00:00
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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user