feat(mesh): collapse sidecar into Sencho process via in-process forwarder (#1000)

The separate saelix/sencho-mesh sidecar container is gone. The
forwarder logic that previously lived in mesh-sidecar/src/forwarder.ts
moves into the Sencho process as backend/src/services/MeshForwarder.ts,
a thin per-port net.Server lifecycle wrapper. MeshService implements
the host interface and owns resolve plus splice; MeshForwarder owns
listener boilerplate. One container per node, no separate image to
publish, no control WebSocket.

Operator-facing change: the Sencho container now runs in
network_mode: host so the forwarder can bind alias ports on the host
network where meshed containers' extra_hosts host-gateway entries
point. Without host network mode the listeners would land in the
container's namespace and inbound traffic from peers would never
reach them. The 1852:1852 port publish becomes a no-op under host
mode and is commented out in the operator template.

Same-node forward path now dials the target container's bridge IP
via Dockerode (preferring the compose default network for
deterministic selection across daemon versions) instead of 127.0.0.1.
The legacy 127.0.0.1 path only worked when the target service
published its port to the host; the IP path works regardless.

Cross-node mesh routing in this phase is central -> pilot direction
only via PilotTunnelManager.openTcpStream. Pilot -> central and
pilot <-> pilot via central relay land in Phase B with the
tcp_open_reverse frame.

Deletions:
- mesh-sidecar/ package entirely (Dockerfile, package, sources, tests)
- backend/src/websocket/meshControl.ts
- MeshService sidecar lifecycle: spawnSidecar, stopSidecar,
  isSidecarRunning, mintSidecarToken, verifySidecarToken,
  attachSidecarSocket, handleSidecarResolve, sendSidecar
- POST /api/mesh/nodes/:id/sidecar/restart route
- /api/mesh/control WS dispatch in upgradeHandler

Type cleanup: 'sidecar' literal removed from MeshActivitySource and
MeshProbeResult.where (also the frontend mirror). MeshNodeStatus
sidecarRunning becomes localForwarderListening (boolean | null) so
non-local nodes get a null instead of an unconditional false; the
honest semantic is "this view only knows the local forwarder state;
remote forwarder status lands in Phase B." MeshNodeDiagnostic
sidecar object becomes forwarder { listening, listenerCount }.

Frontend MeshDiagnosticsSheet drops the restart-sidecar action and
sidecar liveness card; surfaces forwarder state plus a "runs
in-process; no separate container" caption.

Resolves audit findings C-1 (data plane non-functional), C-2 (sidecar
control WS not loopback-enforced), C-4 (sidecar lifecycle Dockerode-
on-remote, PR #999 closed), and C-5 (saelix/sencho-mesh:latest
unreachable). C-3 (PR #992) is unchanged. M-12 (PR #994) is
unchanged.
This commit is contained in:
Anso
2026-05-08 15:51:39 -04:00
committed by GitHub
parent b5463e1771
commit f599110386
23 changed files with 496 additions and 2558 deletions
-57
View File
@@ -1,57 +0,0 @@
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 */ } });
});
}
+1 -7
View File
@@ -7,7 +7,6 @@ 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';
@@ -31,8 +30,7 @@ function parseCookies(req: IncomingMessage): Record<string, string> {
*
* Dispatch order (first match wins):
* 1. `/api/pilot/tunnel` -> handlePilotTunnel (own auth, own wss)
* 2. `/api/mesh/control` -> handleMeshControl (sidecar JWT, local-only)
* 3. shared cookie/Bearer auth + JWT verify (rejects unauthenticated)
* 2. 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
@@ -56,10 +54,6 @@ 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.
}