Files
sencho/mesh-sidecar/src/protocol.ts
T
Anso 6893ece898 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.
2026-05-01 00:28:18 -04:00

147 lines
3.9 KiB
TypeScript

/**
* Mesh sidecar control protocol. Wire-compatible shape with the pilot tunnel:
* JSON text frames for control + a single binary frame type carrying the
* tunneled bytes.
*
* [ 1 byte: BinaryFrameType ][ 4 bytes: streamId (BE) ][ payload bytes... ]
*/
export const PROTOCOL_VERSION = 1;
export enum BinaryFrameType {
Data = 0x01,
}
export type ControlFrame =
| ListenFrame
| UnlistenFrame
| HelloFrame
| ResolveFrame
| ResolveOkFrame
| ResolveErrFrame
| StreamStatsFrame
| LogFrame
| CloseFrame;
export interface HelloFrame {
t: 'hello';
version: number;
nodeId: number;
sidecarVersion: string;
}
/** Sencho -> sidecar: start listening on a TCP port for inbound app traffic. */
export interface ListenFrame {
t: 'listen';
port: number;
}
/** Sencho -> sidecar: stop listening on the given port. */
export interface UnlistenFrame {
t: 'unlisten';
port: number;
}
/**
* Sidecar -> Sencho: a new local TCP connection arrived on this port; what
* stream should I attach it to?
*/
export interface ResolveFrame {
t: 'resolve';
connId: number;
port: number;
remoteAddr?: string;
}
/** Sencho -> sidecar: forward bytes for connId via streamId. */
export interface ResolveOkFrame {
t: 'resolve_ok';
connId: number;
streamId: number;
alias?: string;
}
/** Sencho -> sidecar: drop the connection with a reason. */
export interface ResolveErrFrame {
t: 'resolve_err';
connId: number;
code: 'no_route' | 'tunnel_down' | 'denied' | 'unreachable' | 'agent_error';
message?: string;
}
/** Sidecar -> Sencho: per-stream byte counters every ~5 seconds while open. */
export interface StreamStatsFrame {
t: 'stream.stats';
streamId: number;
bytesIn: number;
bytesOut: number;
lastActivity: number;
}
/** Sidecar -> Sencho: structured log line surfaced into the activity log. */
export interface LogFrame {
t: 'log';
level: 'info' | 'warn' | 'error';
message: string;
details?: Record<string, unknown>;
}
/** Either side -> close a stream. */
export interface CloseFrame {
t: 'close';
streamId: number;
}
export function encodeControl(frame: ControlFrame): string {
return JSON.stringify(frame);
}
export function decodeControl(raw: string): ControlFrame {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || typeof parsed.t !== 'string') {
throw new Error('invalid control frame: missing type discriminator');
}
return parsed as ControlFrame;
}
export function encodeData(streamId: number, payload: Buffer): Buffer {
if (!Number.isInteger(streamId) || streamId < 0 || streamId > 0xffffffff) {
throw new Error(`invalid streamId: ${streamId}`);
}
const out = Buffer.allocUnsafe(5 + payload.length);
out.writeUInt8(BinaryFrameType.Data, 0);
out.writeUInt32BE(streamId, 1);
payload.copy(out, 5);
return out;
}
export interface DecodedData {
streamId: number;
payload: Buffer;
}
export function decodeData(buf: Buffer): DecodedData {
if (buf.length < 5) throw new Error(`data frame too short: ${buf.length} bytes`);
const type = buf.readUInt8(0);
if (type !== BinaryFrameType.Data) throw new Error(`unknown binary frame type: ${type}`);
return {
streamId: buf.readUInt32BE(1),
payload: buf.subarray(5),
};
}
export type WsRawData = Buffer | ArrayBuffer | Buffer[] | string;
export function wsDataToBuffer(data: WsRawData): Buffer | null {
if (Buffer.isBuffer(data)) return data;
if (data instanceof ArrayBuffer) return Buffer.from(data);
if (Array.isArray(data)) return Buffer.concat(data.map((d) => Buffer.isBuffer(d) ? d : Buffer.from(d as ArrayBuffer)));
return null;
}
export function wsDataToString(data: WsRawData): string | null {
if (typeof data === 'string') return data;
const buf = wsDataToBuffer(data);
return buf ? buf.toString('utf8') : null;
}