mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 19:27:41 +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,175 @@
|
||||
import WebSocket from 'ws';
|
||||
import { Forwarder, MeshController } from './forwarder';
|
||||
import {
|
||||
ControlFrame,
|
||||
PROTOCOL_VERSION,
|
||||
decodeControl,
|
||||
decodeData,
|
||||
encodeControl,
|
||||
encodeData,
|
||||
wsDataToBuffer,
|
||||
wsDataToString,
|
||||
} from './protocol';
|
||||
|
||||
const RECONNECT_MIN_MS = 1_000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
const PING_INTERVAL_MS = 30_000;
|
||||
|
||||
export interface ControlClientOptions {
|
||||
controlUrl: string;
|
||||
token: string;
|
||||
nodeId: number;
|
||||
sidecarVersion: string;
|
||||
forwarder: Forwarder;
|
||||
}
|
||||
|
||||
/**
|
||||
* WS-backed implementation of MeshController. Keeps a single long-lived
|
||||
* connection to the local Sencho instance, surfaces inbound frames to the
|
||||
* Forwarder, and queues outbound frames when reconnecting.
|
||||
*/
|
||||
export class ControlClient implements MeshController {
|
||||
private readonly options: ControlClientOptions;
|
||||
private ws: WebSocket | null = null;
|
||||
private backoff = RECONNECT_MIN_MS;
|
||||
private pingTimer?: NodeJS.Timeout;
|
||||
private reconnectTimer?: NodeJS.Timeout;
|
||||
private shuttingDown = false;
|
||||
|
||||
constructor(options: ControlClientOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
public async shutdown(): Promise<void> {
|
||||
this.shuttingDown = true;
|
||||
if (this.pingTimer) clearInterval(this.pingTimer);
|
||||
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; }
|
||||
try { this.ws?.close(1000, 'sidecar shutdown'); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// --- MeshController surface ---
|
||||
|
||||
public resolve(connId: number, port: number, remoteAddr: string): void {
|
||||
this.send({ t: 'resolve', connId, port, remoteAddr });
|
||||
}
|
||||
|
||||
public sendData(streamId: number, payload: Buffer): void {
|
||||
const ws = this.ws;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try { ws.send(encodeData(streamId, payload), { binary: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
public sendClose(streamId: number): void {
|
||||
this.send({ t: 'close', streamId });
|
||||
}
|
||||
|
||||
public sendStats(streamId: number, bytesIn: number, bytesOut: number, lastActivity: number): void {
|
||||
this.send({ t: 'stream.stats', streamId, bytesIn, bytesOut, lastActivity });
|
||||
}
|
||||
|
||||
public sendLog(level: 'info' | 'warn' | 'error', message: string, details?: Record<string, unknown>): void {
|
||||
this.send({ t: 'log', level, message, details });
|
||||
}
|
||||
|
||||
// --- Connection lifecycle ---
|
||||
|
||||
private connect(): void {
|
||||
if (this.shuttingDown) return;
|
||||
|
||||
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = undefined; }
|
||||
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; }
|
||||
|
||||
const ws = new WebSocket(this.options.controlUrl, {
|
||||
headers: { Authorization: `Bearer ${this.options.token}` },
|
||||
handshakeTimeout: 15_000,
|
||||
});
|
||||
this.ws = ws;
|
||||
|
||||
ws.on('open', () => {
|
||||
this.backoff = RECONNECT_MIN_MS;
|
||||
this.send({
|
||||
t: 'hello',
|
||||
version: PROTOCOL_VERSION,
|
||||
nodeId: this.options.nodeId,
|
||||
sidecarVersion: this.options.sidecarVersion,
|
||||
});
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.ping(); } catch { /* error event will handle */ }
|
||||
}
|
||||
}, PING_INTERVAL_MS);
|
||||
});
|
||||
|
||||
ws.on('message', (data, isBinary) => this.onMessage(data, isBinary));
|
||||
ws.on('close', () => {
|
||||
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = undefined; }
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
ws.on('error', () => {
|
||||
// 'close' will follow.
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.shuttingDown) return;
|
||||
const jitter = Math.floor(Math.random() * 500);
|
||||
const delay = this.backoff + jitter;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = undefined;
|
||||
this.connect();
|
||||
}, delay);
|
||||
this.backoff = Math.min(this.backoff * 2, RECONNECT_MAX_MS);
|
||||
}
|
||||
|
||||
private onMessage(data: WebSocket.RawData, isBinary: boolean): void {
|
||||
try {
|
||||
if (isBinary) {
|
||||
const buf = wsDataToBuffer(data);
|
||||
if (!buf) return;
|
||||
const decoded = decodeData(buf);
|
||||
this.options.forwarder.handleData(decoded.streamId, decoded.payload);
|
||||
} else {
|
||||
const text = wsDataToString(data);
|
||||
if (text == null) return;
|
||||
const frame = decodeControl(text);
|
||||
this.dispatchControl(frame);
|
||||
}
|
||||
} catch {
|
||||
// Malformed frames are dropped; tunnel stays up.
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchControl(frame: ControlFrame): void {
|
||||
switch (frame.t) {
|
||||
case 'listen':
|
||||
void this.options.forwarder.listen(frame.port);
|
||||
break;
|
||||
case 'unlisten':
|
||||
void this.options.forwarder.unlisten(frame.port);
|
||||
break;
|
||||
case 'resolve_ok':
|
||||
this.options.forwarder.handleResolveOk(frame.connId, frame.streamId, frame.alias);
|
||||
break;
|
||||
case 'resolve_err':
|
||||
this.options.forwarder.handleResolveErr(frame.connId, frame.code);
|
||||
break;
|
||||
case 'close':
|
||||
this.options.forwarder.handleClose(frame.streamId);
|
||||
break;
|
||||
default:
|
||||
// hello / resolve / stream.stats / log are sidecar-originated;
|
||||
// ignore on the inbound path.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private send(frame: ControlFrame): void {
|
||||
const ws = this.ws;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try { ws.send(encodeControl(frame)); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user