mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 05:58:37 +00:00
feat(fleet): sencho mesh in traffic and routing tab (#858)
* feat(fleet): sencho mesh in traffic and routing tab Lights up Sencho Mesh: cross-node container forwarding rendered as if the container next to you were on localhost. Builds on the dormant TCP frame plumbing from the prior PR (pilot tunnel TCP frames + sencho-mesh sidecar package) and exposes the Admiral-only orchestrator surface. Backend - New mesh_stacks table (per-node opt-ins) + nodes.mesh_enabled column via DatabaseService.migrateMeshTables. - MeshService singleton: sidecar lifecycle via Dockerode, opt-in/out with cascading override regeneration, request-based resolver from sidecar control WS, cross-node TCP forwarding via PilotTunnelManager (same-node fast path included), in-memory 1000-event activity ring buffer with durable mirror to audit_log for state-change events, per-node and per-route diagnostics, and the Test upstream probe. - MeshComposeOverride: pure YAML generator that injects extra_hosts using host-gateway. The user's docker-compose.yml is never mutated; overrides live under DATA_DIR/mesh/overrides. - ComposeService deploy/update splice the override file when the stack is opted in; non-mesh stacks behave identically to today. - Pilot agent resolveMeshTarget consults the local mesh_stacks table (defense in depth) and resolves Compose containers via Dockerode. - /api/mesh router with 13 Admiral-gated endpoints covering status, enable/disable, stack opt-in/out, alias listing, per-route diagnostic, Test upstream probe, per-node diagnostic, sidecar restart, activity log paginated and SSE. - meshControl WS slot at /api/mesh/control validates the mesh_sidecar JWT minted by MeshService; dispatched as upgrade slot 2 (canonical order preserved). Frontend - New Traffic Routing tab in FleetView, gated by isAdmiral and wrapped in AdmiralGate. Tab uses the cyan brand glyph and italic-serif state typography from the audit. - RoutingTab masthead with mesh activity drawer, per-node card grid with TogglePill, alias rows with five-state pill taxonomy (healthy / degraded / unreachable / tunnel-down / not-authorized), inline Test buttons. - Four sheets: opt-in picker with port-collision inline error, per-route detail with diagnostic + filtered activity, per-node diagnostics with active streams + resolver cache + restart action, fleet-wide activity log with filters. - meshRouteState helper centralizes pill-state mapping; pure-function tests cover all five states. Docs - User docs at /docs/features/sencho-mesh.mdx covering opt-in, troubleshooting, security model (4 guarantees + 4 explicit non-guarantees), and V1 limitations. - Internal architecture and runbook pages. - websocket-dispatch internal doc updated with the new slot. * fix(mesh): validate stack name before path use; fix test DB lifecycle Two surgical fixes against the prior PR. Path-injection (CodeQL js/path-injection): MeshService.optInStack, optOutStack, ensureStackOverride, and removeStackOverride now validate stackName via isValidStackName from utils/validation, reject malicious names at the API boundary, and additionally check isPathWithinBase on the resolved override file path for defense in depth. The dataflow from req.params.stackName to fs.writeFile no longer reaches an unsanitized path expression. Test DB lifecycle: mesh-service.test.ts used per-test setupTestDb / cleanupTestDb, which deletes the temp dir while DatabaseService still holds an open SQLite handle. On Linux CI this raises SQLITE_READONLY_DBMOVED on the next prepare() because the inode has been unlinked. Switched to file-scoped beforeAll/afterAll matching agents-routes.test.ts, with a per-test beforeEach that truncates mesh_stacks plus non-default nodes and resets the MeshService singleton in-memory state. Adds a new test case asserting the path-traversal rejection. * fix(compose): use discovered compose filename instead of hardcoded docker-compose.yml composeArgs() hardcoded `-f docker-compose.yml` for every deploy. Sencho writes its canonical compose file as `compose.yaml`, so any stack created via the UI failed to deploy with `open ...docker-compose.yml: no such file or directory`. When no mesh override applies, drop the explicit `-f` so docker compose's built-in discovery resolves the actual filename. When an override exists, look up the real base filename via FileSystemService.getComposeFilename() and pass both files explicitly. Also hoist the MeshService import to module top now that the dependency is known to be acyclic, and revert the matching unit-test assertion.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import type { IncomingMessage } from 'http';
|
||||
import type { Duplex } from 'stream';
|
||||
import type { WebSocketServer, WebSocket } from 'ws';
|
||||
import { MeshService } from '../services/MeshService';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { rejectUpgrade as rejectSocket } from './reject';
|
||||
|
||||
/**
|
||||
* Handle the local Sencho Mesh sidecar's control WebSocket. Authenticated
|
||||
* with a `mesh_sidecar`-scoped JWT minted by MeshService when it spawned the
|
||||
* sidecar; the JWT carries the node id the sidecar serves.
|
||||
*
|
||||
* The control WS is intentionally local-only: the sidecar runs in host
|
||||
* network mode on the same Docker host as Sencho and reaches us via the
|
||||
* loopback interface.
|
||||
*/
|
||||
export async function handleMeshControl(
|
||||
req: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
wss: WebSocketServer,
|
||||
): Promise<void> {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const header = Array.isArray(authHeader) ? authHeader[0] : authHeader;
|
||||
const token = header?.startsWith('Bearer ') ? header.slice(7) : null;
|
||||
if (!token) return rejectSocket(socket, 401, 'Unauthorized');
|
||||
|
||||
const verified = MeshService.getInstance().verifySidecarToken(token);
|
||||
if (!verified) return rejectSocket(socket, 401, 'Unauthorized');
|
||||
|
||||
wss.handleUpgrade(req, socket as never, head, (ws: WebSocket) => {
|
||||
MeshService.getInstance().attachSidecarSocket(ws as unknown as never, verified.nodeId);
|
||||
|
||||
ws.on('message', (data, isBinary) => {
|
||||
if (isBinary) return; // V1: control plane is JSON-only.
|
||||
try {
|
||||
const text = data.toString('utf8');
|
||||
const frame = JSON.parse(text) as { t?: string; connId?: number; port?: number; remoteAddr?: string };
|
||||
if (frame.t === 'resolve' && typeof frame.connId === 'number' && typeof frame.port === 'number') {
|
||||
MeshService.getInstance().handleSidecarResolve(
|
||||
ws as unknown as never,
|
||||
verified.nodeId,
|
||||
frame.connId,
|
||||
frame.port,
|
||||
frame.remoteAddr ?? '',
|
||||
);
|
||||
}
|
||||
// hello / log / stream.stats / close are advisory; we accept
|
||||
// them silently in V1. Future revisions can expand handling.
|
||||
} catch (err) {
|
||||
console.warn('[meshControl] bad frame:', sanitizeForLog((err as Error).message));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', () => { try { ws.close(); } catch { /* ignore */ } });
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { DatabaseService, type UserRole } from '../services/DatabaseService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { COOKIE_NAME } from '../helpers/constants';
|
||||
import { handlePilotTunnel } from './pilotTunnel';
|
||||
import { handleMeshControl } from './meshControl';
|
||||
import { handleNotificationsWs } from './notifications';
|
||||
import { handleRemoteForwarder } from './remoteForwarder';
|
||||
import { handleLogsWs } from './logs';
|
||||
@@ -30,7 +31,8 @@ function parseCookies(req: IncomingMessage): Record<string, string> {
|
||||
*
|
||||
* Dispatch order (first match wins):
|
||||
* 1. `/api/pilot/tunnel` -> handlePilotTunnel (own auth, own wss)
|
||||
* 2. shared cookie/Bearer auth + JWT verify (rejects unauthenticated)
|
||||
* 2. `/api/mesh/control` -> handleMeshControl (sidecar JWT, local-only)
|
||||
* 3. shared cookie/Bearer auth + JWT verify (rejects unauthenticated)
|
||||
* 3. API token scope gate (read-only / deploy-only restricted to logs + notifications)
|
||||
* 4. `/ws/notifications` local -> handleNotificationsWs
|
||||
* 5. remote nodeId path -> handleRemoteForwarder
|
||||
@@ -54,6 +56,10 @@ export function attachUpgrade(
|
||||
await handlePilotTunnel(req, socket, head, pilotTunnelWss);
|
||||
return;
|
||||
}
|
||||
if (reqUrl.pathname === '/api/mesh/control') {
|
||||
await handleMeshControl(req, socket, head, wss);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// URL parse error falls through and will be rejected below.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user