mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
6893ece898
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.
176 lines
5.9 KiB
TypeScript
176 lines
5.9 KiB
TypeScript
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 */ }
|
|
}
|
|
}
|