mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 01:43:55 +00:00
f599110386
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.
102 lines
4.3 KiB
TypeScript
102 lines
4.3 KiB
TypeScript
import net from 'net';
|
|
import { sanitizeForLog } from '../utils/safeLog';
|
|
|
|
/**
|
|
* In-process mesh TCP forwarder. Owns per-port `net.Server` listeners on the
|
|
* host network and delegates accepted sockets to the host (MeshService) for
|
|
* resolve + splice. Replaces the separate `saelix/sencho-mesh` sidecar
|
|
* container that previously did this job over a control WebSocket. The
|
|
* resolve step is now a sync map lookup rather than a round-trip, so
|
|
* MeshForwarder is just a thin lifecycle layer; all routing + splicing
|
|
* lives on MeshService.
|
|
*
|
|
* Sencho's container must run in `network_mode: host` (Linux) for the
|
|
* listeners to bind on the host's network where meshed containers'
|
|
* `extra_hosts: <alias>:host-gateway` entries point. Without host network
|
|
* mode, `net.createServer().listen(port)` lands inside the container's
|
|
* namespace and inbound traffic from peers never reaches it.
|
|
*/
|
|
|
|
export interface MeshForwarderHost {
|
|
/** Called on each accepted inbound socket. The host owns the splice
|
|
* lifecycle; MeshForwarder only manages listener boilerplate. */
|
|
handleAccept(port: number, source: net.Socket): Promise<void>;
|
|
}
|
|
|
|
export class MeshForwarder {
|
|
private readonly listeners = new Map<number, net.Server>();
|
|
/**
|
|
* In-flight `listen(port)` promises so concurrent callers race-safely
|
|
* deduplicate. Without this guard, two concurrent calls to listen on
|
|
* the same port would both pass the `listeners.has(port)` check (which
|
|
* is only populated after the listening event resolves) and the second
|
|
* would fail with EADDRINUSE.
|
|
*/
|
|
private readonly pending = new Map<number, Promise<void>>();
|
|
private shuttingDown = false;
|
|
|
|
constructor(private readonly host: MeshForwarderHost) {}
|
|
|
|
public async listen(port: number): Promise<void> {
|
|
if (this.shuttingDown) return;
|
|
if (this.listeners.has(port)) return;
|
|
const inflight = this.pending.get(port);
|
|
if (inflight) return inflight;
|
|
const promise = (async () => {
|
|
const server = net.createServer((socket) => this.acceptConnection(port, socket));
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const onError = (err: Error) => { server.removeListener('listening', onListening); reject(err); };
|
|
const onListening = () => { server.removeListener('error', onError); resolve(); };
|
|
server.once('error', onError);
|
|
server.once('listening', onListening);
|
|
// Bind on all interfaces. Under host network mode this is
|
|
// the host's own network; under bridge mode (mesh disabled
|
|
// at boot) this would be the container's namespace.
|
|
server.listen(port, '0.0.0.0');
|
|
});
|
|
this.listeners.set(port, server);
|
|
} finally {
|
|
this.pending.delete(port);
|
|
}
|
|
})();
|
|
this.pending.set(port, promise);
|
|
return promise;
|
|
}
|
|
|
|
public async unlisten(port: number): Promise<void> {
|
|
const server = this.listeners.get(port);
|
|
if (!server) return;
|
|
this.listeners.delete(port);
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
|
|
public async shutdown(): Promise<void> {
|
|
this.shuttingDown = true;
|
|
const ports = Array.from(this.listeners.keys());
|
|
await Promise.all(ports.map((p) => this.unlisten(p)));
|
|
}
|
|
|
|
public getListenerPorts(): number[] {
|
|
return Array.from(this.listeners.keys());
|
|
}
|
|
|
|
public isListening(port: number): boolean {
|
|
return this.listeners.has(port);
|
|
}
|
|
|
|
private acceptConnection(port: number, source: net.Socket): void {
|
|
if (this.shuttingDown) {
|
|
try { source.destroy(); } catch { /* ignore */ }
|
|
return;
|
|
}
|
|
// Defer to the host for resolve + splice. MeshForwarder itself does
|
|
// not look at the source bytes; routing lives on MeshService where
|
|
// the alias map and the cross-node bridge dispatch are.
|
|
this.host.handleAccept(port, source).catch((err) => {
|
|
console.warn('[MeshForwarder] accept handler failed:', sanitizeForLog((err as Error).message));
|
|
try { source.destroy(); } catch { /* ignore */ }
|
|
});
|
|
}
|
|
}
|