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,106 @@
/**
* Tests for the Sencho Mesh TCP frames added to the pilot tunnel protocol.
*/
import { describe, it, expect } from 'vitest';
import {
BinaryFrameType,
decodeBinaryFrame,
decodeJsonFrame,
encodeBinaryFrame,
encodeJsonFrame,
} from '../pilot/protocol';
describe('Mesh TCP JSON frames', () => {
it('roundtrips a tcp_open frame', () => {
const raw = encodeJsonFrame({
t: 'tcp_open',
s: 42,
stack: 'api',
service: 'db',
port: 5432,
});
const decoded = decodeJsonFrame(raw);
expect(decoded.t).toBe('tcp_open');
if (decoded.t !== 'tcp_open') throw new Error('narrowing');
expect(decoded.s).toBe(42);
expect(decoded.stack).toBe('api');
expect(decoded.service).toBe('db');
expect(decoded.port).toBe(5432);
});
it('roundtrips a tcp_open_ack success', () => {
const raw = encodeJsonFrame({ t: 'tcp_open_ack', s: 42, ok: true });
const decoded = decodeJsonFrame(raw);
expect(decoded.t).toBe('tcp_open_ack');
if (decoded.t !== 'tcp_open_ack') throw new Error('narrowing');
expect(decoded.ok).toBe(true);
expect(decoded.err).toBeUndefined();
});
it('roundtrips a tcp_open_ack failure with error code', () => {
const raw = encodeJsonFrame({
t: 'tcp_open_ack',
s: 42,
ok: false,
err: 'unreachable',
});
const decoded = decodeJsonFrame(raw);
if (decoded.t !== 'tcp_open_ack') throw new Error('narrowing');
expect(decoded.ok).toBe(false);
expect(decoded.err).toBe('unreachable');
});
it('roundtrips a tcp_close frame', () => {
const raw = encodeJsonFrame({ t: 'tcp_close', s: 42 });
const decoded = decodeJsonFrame(raw);
expect(decoded.t).toBe('tcp_close');
if (decoded.t !== 'tcp_close') throw new Error('narrowing');
expect(decoded.s).toBe(42);
});
});
describe('Mesh TcpData binary frames', () => {
it('encodes the 0x04 type discriminator', () => {
const payload = Buffer.from('hello');
const encoded = encodeBinaryFrame(BinaryFrameType.TcpData, 1, payload);
expect(encoded[0]).toBe(0x04);
});
it('roundtrips streamId + payload', () => {
const payload = Buffer.from('SELECT 1;');
const encoded = encodeBinaryFrame(BinaryFrameType.TcpData, 0xdeadbeef, payload);
const decoded = decodeBinaryFrame(encoded);
expect(decoded.type).toBe(BinaryFrameType.TcpData);
expect(decoded.streamId).toBe(0xdeadbeef);
expect(decoded.payload.toString()).toBe('SELECT 1;');
});
it('roundtrips an empty payload', () => {
const encoded = encodeBinaryFrame(BinaryFrameType.TcpData, 7, Buffer.alloc(0));
const decoded = decodeBinaryFrame(encoded);
expect(decoded.type).toBe(BinaryFrameType.TcpData);
expect(decoded.streamId).toBe(7);
expect(decoded.payload.length).toBe(0);
});
it('preserves binary payloads byte-for-byte', () => {
const payload = Buffer.from([0x00, 0xff, 0x01, 0x80, 0x7f, 0x10]);
const encoded = encodeBinaryFrame(BinaryFrameType.TcpData, 1, payload);
const decoded = decodeBinaryFrame(encoded);
expect(decoded.payload.equals(payload)).toBe(true);
});
it('rejects an unknown binary frame type', () => {
const buf = Buffer.alloc(5);
buf.writeUInt8(0x99, 0);
buf.writeUInt32BE(1, 1);
expect(() => decodeBinaryFrame(buf)).toThrow(/unknown binary frame type/);
});
it('continues to accept the existing http and ws binary types', () => {
for (const t of [BinaryFrameType.HttpReqBody, BinaryFrameType.HttpResBody, BinaryFrameType.WsMessageBinary]) {
const buf = encodeBinaryFrame(t, 1, Buffer.from('x'));
expect(() => decodeBinaryFrame(buf)).not.toThrow();
}
});
});
+108
View File
@@ -1,10 +1,12 @@
import fs from 'fs';
import net from 'net';
import path from 'path';
import http from 'http';
import WebSocket from 'ws';
import { getSenchoVersion } from '../services/CapabilityRegistry';
import {
BinaryFrameType,
MeshErrCode,
PROTOCOL_VERSION,
decodeBinaryFrame,
decodeJsonFrame,
@@ -66,6 +68,7 @@ class PilotAgent {
private reconnectTimer?: NodeJS.Timeout;
private readonly httpStreams = new Map<number, { req: http.ClientRequest }>();
private readonly wsStreams = new Map<number, WebSocket>();
private readonly tcpStreams = new Map<number, MeshTcpStream>();
private shuttingDown = false;
private readonly agentVersion: string;
@@ -143,6 +146,10 @@ class PilotAgent {
try { ws.close(1006, 'tunnel closed'); } catch { /* ignore */ }
}
this.wsStreams.clear();
for (const [, stream] of this.tcpStreams) {
try { stream.socket.destroy(); } catch { /* ignore */ }
}
this.tcpStreams.clear();
}
private scheduleReconnect(): void {
@@ -199,6 +206,8 @@ class PilotAgent {
case 'ws_open': this.onWsOpen(frame); break;
case 'ws_msg_text': this.onWsMsgText(frame.s, frame.data); break;
case 'ws_close': this.onWsClose(frame.s, frame.code, frame.reason); break;
case 'tcp_open': this.onTcpOpen(frame); break;
case 'tcp_close': this.onTcpClose(frame.s); break;
default:
// Other frame types are primary-bound only; agent ignores.
break;
@@ -219,6 +228,12 @@ class PilotAgent {
try { ws.send(frame.payload, { binary: true }); } catch { /* ignore */ }
break;
}
case BinaryFrameType.TcpData: {
const stream = this.tcpStreams.get(frame.streamId);
if (!stream) return;
try { stream.socket.write(frame.payload); } catch { /* ignore */ }
break;
}
default:
break;
}
@@ -329,8 +344,101 @@ class PilotAgent {
try { ws.close(code, reason); } catch { /* ignore */ }
this.wsStreams.delete(streamId);
}
// --- Sencho Mesh TCP dispatch (tunnel -> Compose service container) ---
//
// PR 1 rejects every tcp_open with mesh_not_enabled; the dial path is
// exercised by tests via setMeshResolver but never lit in production until
// PR 2 wires Dockerode resolution gated by the local mesh_stacks table.
private async onTcpOpen(frame: Extract<ReturnType<typeof decodeJsonFrame>, { t: 'tcp_open' }>): Promise<void> {
const ws = this.ws;
if (!ws) return;
const target = await this.resolveMeshTarget(frame.stack, frame.service, frame.port);
if (!target.ok) {
try {
ws.send(encodeJsonFrame({ t: 'tcp_open_ack', s: frame.s, ok: false, err: target.err }));
} catch { /* ignore */ }
return;
}
const socket = net.createConnection({ host: target.host, port: target.port });
socket.setTimeout(MESH_CONNECT_TIMEOUT_MS);
const entry: MeshTcpStream = { socket, accepted: false };
this.tcpStreams.set(frame.s, entry);
const sendAck = (ok: boolean, err?: MeshErrCode) => {
try { ws.send(encodeJsonFrame({ t: 'tcp_open_ack', s: frame.s, ok, err })); } catch { /* ignore */ }
};
socket.once('connect', () => {
entry.accepted = true;
socket.setTimeout(0);
sendAck(true);
});
socket.on('data', (chunk: Buffer) => {
try {
ws.send(encodeBinaryFrame(BinaryFrameType.TcpData, frame.s, chunk), { binary: true });
} catch { /* ignore */ }
});
socket.on('timeout', () => {
if (entry.accepted) return;
entry.accepted = true;
sendAck(false, 'unreachable');
this.tcpStreams.delete(frame.s);
try { socket.destroy(); } catch { /* ignore */ }
});
socket.on('error', (err) => {
if (!entry.accepted) {
entry.accepted = true;
sendAck(false, 'unreachable');
this.tcpStreams.delete(frame.s);
return;
}
console.warn('[Pilot] tcp stream error:', sanitizeForLog(err.message));
if (this.tcpStreams.delete(frame.s)) {
try { ws.send(encodeJsonFrame({ t: 'tcp_close', s: frame.s })); } catch { /* ignore */ }
}
});
socket.on('close', () => {
if (this.tcpStreams.delete(frame.s)) {
try { ws.send(encodeJsonFrame({ t: 'tcp_close', s: frame.s })); } catch { /* ignore */ }
}
});
}
private onTcpClose(streamId: number): void {
const entry = this.tcpStreams.get(streamId);
if (!entry) return;
this.tcpStreams.delete(streamId);
try { entry.socket.destroy(); } catch { /* ignore */ }
}
/**
* Always returns ok:false in PR 1. Tests inject a real resolver via
* setMeshResolver; PR 2 will install the Dockerode-backed implementation.
*/
private async resolveMeshTarget(
_stack: string,
_service: string,
_port: number,
): Promise<MeshResolveResult> {
return { ok: false, err: 'mesh_not_enabled' };
}
}
const MESH_CONNECT_TIMEOUT_MS = 10_000;
interface MeshTcpStream {
socket: net.Socket;
accepted: boolean;
}
type MeshResolveResult =
| { ok: true; host: string; port: number }
| { ok: false; err: MeshErrCode };
function readPersistedToken(): string | null {
try {
if (fs.existsSync(TOKEN_PATH)) {
+34 -2
View File
@@ -23,6 +23,7 @@ export enum BinaryFrameType {
HttpReqBody = 0x01,
HttpResBody = 0x02,
WsMessageBinary = 0x03,
TcpData = 0x04,
}
// --- JSON envelope types ---
@@ -39,7 +40,10 @@ export type JsonFrame =
| WsRejectFrame
| WsMessageTextFrame
| WsCloseFrame
| ControlFrame;
| ControlFrame
| TcpOpenFrame
| TcpOpenAckFrame
| TcpCloseFrame;
export interface HelloFrame {
t: 'hello';
@@ -119,6 +123,33 @@ export interface ControlFrame {
payload?: Record<string, unknown>;
}
/**
* Sencho Mesh TCP frames. The primary asks the agent to open a TCP connection
* to a Compose service on the agent's local Docker host. Bytes flow as
* BinaryFrameType.TcpData. Mid-stream failures send tcp_close.
*/
export interface TcpOpenFrame {
t: 'tcp_open';
s: number;
stack: string;
service: string;
port: number;
}
export type MeshErrCode = 'mesh_not_enabled' | 'denied' | 'no_target' | 'unreachable' | 'agent_error';
export interface TcpOpenAckFrame {
t: 'tcp_open_ack';
s: number;
ok: boolean;
err?: MeshErrCode;
}
export interface TcpCloseFrame {
t: 'tcp_close';
s: number;
}
// --- Serialize / parse ---
export function encodeJsonFrame(frame: JsonFrame): string {
@@ -161,7 +192,8 @@ export function decodeBinaryFrame(buf: Buffer): DecodedBinaryFrame {
const type = buf.readUInt8(0) as BinaryFrameType;
if (type !== BinaryFrameType.HttpReqBody &&
type !== BinaryFrameType.HttpResBody &&
type !== BinaryFrameType.WsMessageBinary) {
type !== BinaryFrameType.WsMessageBinary &&
type !== BinaryFrameType.TcpData) {
throw new Error(`unknown binary frame type: ${type}`);
}
const streamId = buf.readUInt32BE(1);
+161 -4
View File
@@ -31,7 +31,62 @@ interface WsStreamState {
clientWs?: WebSocket;
}
type StreamState = HttpStreamState | WsStreamState;
interface TcpStreamState {
kind: 'tcp';
handle: TcpStream;
bytesIn: number;
bytesOut: number;
openedAt: number;
accepted: boolean;
}
type StreamState = HttpStreamState | WsStreamState | TcpStreamState;
export interface TcpStreamSummary {
streamId: number;
bytesIn: number;
bytesOut: number;
openedAt: number;
}
/**
* Sencho Mesh TCP stream handle. EventEmitter-based duplex-like surface that
* MeshService consumes to bridge a local socket to a Compose service on the
* remote node behind this pilot tunnel.
*
* Events:
* 'open' tcp_open_ack ok received; safe to write/read
* 'data' (Buffer) bytes from the remote socket
* 'drain' send buffer below high-water mark
* 'error' (err) open rejected or mid-stream tunnel error
* 'close' stream closed (graceful or otherwise)
*/
export class TcpStream extends EventEmitter {
public readonly streamId: number;
private readonly bridge: PilotTunnelBridge;
constructor(streamId: number, bridge: PilotTunnelBridge) {
super();
this.streamId = streamId;
this.bridge = bridge;
}
/**
* Returns false when the underlying tunnel buffer is above the high-water
* mark; caller should pause its source until 'drain' fires.
*/
public write(chunk: Buffer): boolean {
return this.bridge._writeTcpData(this.streamId, chunk);
}
public end(): void {
this.bridge._closeTcpStream(this.streamId);
}
public destroy(): void {
this.end();
}
}
/**
* Per-tunnel bridge: hosts a loopback HTTP server that demuxes requests into
@@ -86,14 +141,85 @@ export class PilotTunnelBridge extends EventEmitter {
});
});
this.pingTimer = setInterval(() => {
if (this.tunnelWs.readyState === WebSocket.OPEN) {
try { this.tunnelWs.ping(); } catch { /* surfaced via 'error' */ }
if (this.tunnelWs.readyState !== WebSocket.OPEN) return;
try { this.tunnelWs.ping(); } catch { /* surfaced via 'error' */ }
// Coarse drain fan-out for TCP streams: ws does not expose a
// socket-level 'drain' we can hook, so we let backpressure clear
// by the next ping cycle.
if (this.tunnelWs.bufferedAmount <= BUFFER_HIGH_WATER_MARK) {
for (const s of this.streams.values()) {
if (s.kind === 'tcp' && s.accepted) s.handle.emit('drain');
}
}
}, PING_INTERVAL_MS);
}
public getLoopbackUrl(): string { return this.loopbackUrl; }
public getConnectedAt(): number { return this.connectedAt; }
public getBufferedAmount(): number { return this.tunnelWs.bufferedAmount; }
public isOpen(): boolean { return !this.closed && this.tunnelWs.readyState === WebSocket.OPEN; }
/**
* Open a TCP stream to a Compose service on the remote node. Caller listens
* on the returned TcpStream for 'open' (when the remote agent has accepted),
* 'data', 'error', and 'close'. Returns null if the tunnel is not open.
*/
public openTcpStream(target: { stack: string; service: string; port: number }): TcpStream | null {
if (!this.isOpen()) return null;
const streamId = this.streamIds.allocate();
const handle = new TcpStream(streamId, this);
this.streams.set(streamId, {
kind: 'tcp',
handle,
bytesIn: 0,
bytesOut: 0,
openedAt: Date.now(),
accepted: false,
});
this.sendJson({
t: 'tcp_open',
s: streamId,
stack: target.stack,
service: target.service,
port: target.port,
});
return handle;
}
/**
* @internal Called only by TcpStream.write; applies the same 4 MB
* backpressure rule used by HTTP request bodies. Public for cross-class
* access only; not part of the bridge's outward API.
*/
public _writeTcpData(streamId: number, payload: Buffer): boolean {
const s = this.streams.get(streamId);
if (!s || s.kind !== 'tcp') return false;
if (!this.isOpen()) return false;
this.sendBinary(BinaryFrameType.TcpData, streamId, payload);
s.bytesOut += payload.length;
return this.tunnelWs.bufferedAmount <= BUFFER_HIGH_WATER_MARK;
}
/** @internal Called only by TcpStream.end / .destroy. */
public _closeTcpStream(streamId: number): void {
if (!this.streams.has(streamId)) return;
this.streams.delete(streamId);
this.sendJson({ t: 'tcp_close', s: streamId });
}
/**
* Snapshot of active TCP streams for the diagnostics sheet. Cheap; called
* on demand by MeshService.
*/
public listTcpStreams(): TcpStreamSummary[] {
const out: TcpStreamSummary[] = [];
for (const [streamId, s] of this.streams) {
if (s.kind === 'tcp') {
out.push({ streamId, bytesIn: s.bytesIn, bytesOut: s.bytesOut, openedAt: s.openedAt });
}
}
return out;
}
public close(code = 1000, reason = 'closed by primary'): void {
if (this.closed) return;
@@ -314,6 +440,26 @@ export class PilotTunnelBridge extends EventEmitter {
// called, and ping/pong are handled by the WS layer.
break;
}
case 'tcp_open_ack': {
const s = this.streams.get(frame.s);
if (!s || s.kind !== 'tcp') return;
if (frame.ok) {
s.accepted = true;
s.handle.emit('open');
} else {
this.streams.delete(frame.s);
s.handle.emit('error', new Error(frame.err ?? 'tcp_open rejected'));
s.handle.emit('close');
}
break;
}
case 'tcp_close': {
const s = this.streams.get(frame.s);
if (!s || s.kind !== 'tcp') return;
this.streams.delete(frame.s);
s.handle.emit('close');
break;
}
default:
// Ignore unknown JSON frame types for forward compatibility.
break;
@@ -342,6 +488,12 @@ export class PilotTunnelBridge extends EventEmitter {
case BinaryFrameType.HttpReqBody:
// Agent never originates request bodies; ignore for defense-in-depth.
break;
case BinaryFrameType.TcpData: {
if (s.kind !== 'tcp') return;
s.bytesIn += frame.payload.length;
s.handle.emit('data', frame.payload);
break;
}
default:
break;
}
@@ -374,12 +526,17 @@ export class PilotTunnelBridge extends EventEmitter {
state.res.end();
}
} catch { /* ignore */ }
} else {
} else if (state.kind === 'ws') {
if (state.clientWs) {
try { state.clientWs.close(1011, 'tunnel closed'); } catch { /* ignore */ }
} else if (state.rawSocket) {
try { state.rawSocket.destroy(); } catch { /* ignore */ }
}
} else {
try {
if (!state.accepted) state.handle.emit('error', new Error('tunnel closed before accept'));
state.handle.emit('close');
} catch { /* ignore */ }
}
}
}