mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 09:46:47 +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:
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user