diff --git a/backend/src/__tests__/mesh-service.test.ts b/backend/src/__tests__/mesh-service.test.ts index 6653a1e4..2bf0c9b5 100644 --- a/backend/src/__tests__/mesh-service.test.ts +++ b/backend/src/__tests__/mesh-service.test.ts @@ -79,6 +79,57 @@ describe('MeshService.optInStack', () => { .rejects.toThrow(/invalid stack name/); expect(db.isMeshStackEnabled(localNodeId, '../../etc/passwd')).toBe(false); }); + + it('forwarder binds every alias port across the fleet, not just local-owned ports', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + const remoteNodeId = db.addNode({ + name: 'remote-pilot', type: 'remote', mode: 'pilot_agent', + compose_dir: '/tmp', is_default: false, api_url: '', api_token: '', + }); + + // Seed local- and remote-owned aliases directly into + // aliasByPort so we exercise syncForwarderListeners without a + // live remote Sencho. The model: every meshed node binds every + // alias port because meshed containers' extra_hosts:host-gateway + // entries land on the SOURCE node's gateway, so the source node + // is where the inbound TCP connection is intercepted. + const aliasByPort = (svc as unknown as { aliasByPort: Map }).aliasByPort; + aliasByPort.set(9000, { + host: 'echo.local-stack.Local.sencho', + nodeId: localNodeId, nodeName: 'Local', + stackName: 'local-stack', serviceName: 'echo', port: 9000, + }); + aliasByPort.set(9001, { + host: 'echo.remote-stack.remote-pilot.sencho', + nodeId: remoteNodeId, nodeName: 'remote-pilot', + stackName: 'remote-stack', serviceName: 'echo', port: 9001, + }); + + // Stub the forwarder so the test never touches a real net.Server. + const listened: number[] = []; + const fwd = (svc as unknown as { + forwarder: { listen: (p: number) => Promise; unlisten: (p: number) => Promise; getListenerPorts: () => number[] }; + }).forwarder; + const realListen = fwd.listen.bind(fwd); + const realUnlisten = fwd.unlisten.bind(fwd); + const realGetListenerPorts = fwd.getListenerPorts.bind(fwd); + fwd.listen = async (p: number) => { listened.push(p); }; + fwd.unlisten = async () => { /* no-op */ }; + fwd.getListenerPorts = () => [...listened]; + + try { + await (svc as unknown as { syncForwarderListeners: () => Promise }).syncForwarderListeners(); + expect(listened.sort()).toEqual([9000, 9001]); + } finally { + fwd.listen = realListen; + fwd.unlisten = realUnlisten; + fwd.getListenerPorts = realGetListenerPorts; + aliasByPort.clear(); + db.deleteNode(remoteNodeId); + } + }); }); describe('MeshService activity log', () => { diff --git a/backend/src/services/MeshService.ts b/backend/src/services/MeshService.ts index 618d8914..410448ac 100644 --- a/backend/src/services/MeshService.ts +++ b/backend/src/services/MeshService.ts @@ -188,18 +188,24 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { } /** - * Bind the forwarder's listeners to the local-owned alias ports and - * release any listeners no longer in the alias set. Called from + * Bind the forwarder's listeners to every alias port across the fleet + * and release any listeners no longer in the alias set. Called from * `start`, after each `refreshAliasCache` tick, and after every - * opt-in / opt-out / disable on the local node so the bound port set - * follows the DB state. + * opt-in / opt-out / disable so the bound port set follows the DB + * state. + * + * Every meshed node binds every alias port — not just ports it owns — + * because meshed containers' `extra_hosts: :host-gateway` + * entries resolve to the SOURCE node's gateway, so the source node is + * where the inbound TCP connection lands. `handleAccept` then + * dispatches to the same-node fast path or the cross-node bridge based + * on the resolved alias's owner. Fleet-wide port collisions are + * blocked at opt-in time (`optInStack` checks `aliasByPort`), so + * binding every alias port is unambiguous. */ private async syncForwarderListeners(): Promise { const localNodeId = NodeRegistry.getInstance().getDefaultNodeId(); - const wantPorts = new Set(); - for (const alias of this.aliasByPort.values()) { - if (alias.nodeId === localNodeId) wantPorts.add(alias.port); - } + const wantPorts = new Set(this.aliasByPort.keys()); const havePorts = new Set(this.forwarder.getListenerPorts()); for (const port of havePorts) { if (!wantPorts.has(port)) {