mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +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.
167 lines
6.0 KiB
TypeScript
167 lines
6.0 KiB
TypeScript
/**
|
|
* Unit tests for MeshForwarder. Exercises the per-port listener lifecycle
|
|
* (listen/unlisten/shutdown) and the accept dispatch into the host
|
|
* (MeshService surrogate). MeshForwarder itself does not splice bytes —
|
|
* the host's `handleAccept` does — so the test injects a recording
|
|
* surrogate and asserts the call shape.
|
|
*/
|
|
import net from 'net';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import { MeshForwarder, type MeshForwarderHost } from '../services/MeshForwarder';
|
|
|
|
interface Accept {
|
|
port: number;
|
|
socket: net.Socket;
|
|
}
|
|
|
|
function makeRecordingHost(): { host: MeshForwarderHost; accepts: Accept[]; resolveAfter: (cb: (a: Accept) => void) => void } {
|
|
const accepts: Accept[] = [];
|
|
const subscribers: Array<(a: Accept) => void> = [];
|
|
const host: MeshForwarderHost = {
|
|
async handleAccept(port, socket) {
|
|
const a = { port, socket };
|
|
accepts.push(a);
|
|
for (const s of subscribers.splice(0)) s(a);
|
|
},
|
|
};
|
|
return {
|
|
host,
|
|
accepts,
|
|
resolveAfter: (cb) => { subscribers.push(cb); },
|
|
};
|
|
}
|
|
|
|
async function getEphemeralPort(): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
const s = net.createServer();
|
|
s.unref();
|
|
s.listen(0, '127.0.0.1', () => {
|
|
const addr = s.address();
|
|
if (!addr || typeof addr === 'string') {
|
|
reject(new Error('no address'));
|
|
return;
|
|
}
|
|
const port = addr.port;
|
|
s.close(() => resolve(port));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function dial(port: number, host = '127.0.0.1'): Promise<net.Socket> {
|
|
return new Promise((resolve, reject) => {
|
|
const s = net.createConnection({ host, port });
|
|
s.once('connect', () => resolve(s));
|
|
s.once('error', reject);
|
|
});
|
|
}
|
|
|
|
async function waitFor<T>(check: () => T | undefined, timeoutMs = 1000): Promise<T> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const v = check();
|
|
if (v !== undefined && v !== null && (Array.isArray(v) ? (v as unknown[]).length > 0 : true)) {
|
|
return v as T;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
}
|
|
throw new Error('timeout waiting for condition');
|
|
}
|
|
|
|
describe('MeshForwarder', () => {
|
|
let forwarder: MeshForwarder | null = null;
|
|
|
|
afterEach(async () => {
|
|
if (forwarder) await forwarder.shutdown();
|
|
forwarder = null;
|
|
});
|
|
|
|
beforeEach(() => { /* fresh per test */ });
|
|
|
|
it('binds a listener on the requested port and reports it via getListenerPorts', async () => {
|
|
const { host } = makeRecordingHost();
|
|
forwarder = new MeshForwarder(host);
|
|
const port = await getEphemeralPort();
|
|
await forwarder.listen(port);
|
|
|
|
expect(forwarder.getListenerPorts()).toEqual([port]);
|
|
expect(forwarder.isListening(port)).toBe(true);
|
|
});
|
|
|
|
it('hands accepted sockets to the host with the original destination port', async () => {
|
|
const { host, accepts } = makeRecordingHost();
|
|
forwarder = new MeshForwarder(host);
|
|
const port = await getEphemeralPort();
|
|
await forwarder.listen(port);
|
|
|
|
const client = await dial(port);
|
|
const seen = await waitFor(() => accepts.length ? accepts : undefined);
|
|
expect(seen[0].port).toBe(port);
|
|
expect(seen[0].socket).toBeInstanceOf(net.Socket);
|
|
|
|
client.destroy();
|
|
seen[0].socket.destroy();
|
|
});
|
|
|
|
it('listen is idempotent (repeated calls on the same port no-op)', async () => {
|
|
const { host } = makeRecordingHost();
|
|
forwarder = new MeshForwarder(host);
|
|
const port = await getEphemeralPort();
|
|
await forwarder.listen(port);
|
|
await forwarder.listen(port);
|
|
expect(forwarder.getListenerPorts()).toEqual([port]);
|
|
});
|
|
|
|
it('two concurrent listen() calls on the same port produce a single bind, not EADDRINUSE', async () => {
|
|
const { host } = makeRecordingHost();
|
|
forwarder = new MeshForwarder(host);
|
|
const port = await getEphemeralPort();
|
|
// Fire both calls before either resolves. Without the in-flight
|
|
// dedup map, the second call would race past the listeners.has()
|
|
// check and fail with EADDRINUSE on its own bind attempt.
|
|
const [a, b] = await Promise.all([forwarder.listen(port), forwarder.listen(port)]);
|
|
expect(a).toBeUndefined();
|
|
expect(b).toBeUndefined();
|
|
expect(forwarder.getListenerPorts()).toEqual([port]);
|
|
});
|
|
|
|
it('unlisten releases the port so a new listener can bind it', async () => {
|
|
const { host } = makeRecordingHost();
|
|
forwarder = new MeshForwarder(host);
|
|
const port = await getEphemeralPort();
|
|
await forwarder.listen(port);
|
|
await forwarder.unlisten(port);
|
|
expect(forwarder.isListening(port)).toBe(false);
|
|
|
|
// Verify the port is genuinely free by binding a fresh net.Server.
|
|
await new Promise<void>((resolve, reject) => {
|
|
const probe = net.createServer();
|
|
probe.once('error', reject);
|
|
probe.listen(port, '127.0.0.1', () => probe.close(() => resolve()));
|
|
});
|
|
});
|
|
|
|
it('rejects new connections after shutdown', async () => {
|
|
const { host } = makeRecordingHost();
|
|
forwarder = new MeshForwarder(host);
|
|
const port = await getEphemeralPort();
|
|
await forwarder.listen(port);
|
|
await forwarder.shutdown();
|
|
|
|
await expect(dial(port)).rejects.toThrow();
|
|
forwarder = null;
|
|
});
|
|
|
|
it('destroys the source socket when the host handler throws', async () => {
|
|
const host: MeshForwarderHost = {
|
|
async handleAccept() { throw new Error('host blew up'); },
|
|
};
|
|
forwarder = new MeshForwarder(host);
|
|
const port = await getEphemeralPort();
|
|
await forwarder.listen(port);
|
|
|
|
const client = await dial(port);
|
|
const closed = new Promise<void>((resolve) => client.once('close', () => resolve()));
|
|
await closed;
|
|
});
|
|
});
|