mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix(mesh): bind every alias port on every node, not just local-owned (#1004)
syncForwarderListeners filtered the bind set to ports owned by the local node. That broke cross-node mesh routing end to end: meshed containers' extra_hosts: <alias>:host-gateway entries resolve to the SOURCE node's gateway, so the source node is where the inbound TCP connection lands. With the filter, the source node never bound the remote alias's port and the connection went nowhere. The architecture model documented in docs/internal/architecture/ mesh.md (data flow step 3) is explicit: monolith's Sencho process accepts the connection on its MeshForwarder listener bound to port 5432 — where monolith is the source and opsix is the target. Every meshed node binds every alias port. handleAccept then resolves the alias and dispatches to openSameNode (when target.nodeId equals the local node) or openCrossNode (otherwise). Both branches were already correct; only the bind filter was wrong. Fleet-wide port collisions remain blocked at opt-in time (optInStack checks aliasByPort), so binding every alias port is unambiguous. New vitest case asserts the want-set includes both local-owned and remote-owned ports. Discovered while running PR 0 verification on v0.75.0 against Local + sencho-pilot-test: connections from a Local prober to the pilot's alias port hit Local's host with no listener.
This commit is contained in:
@@ -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<number, unknown> }).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<void>; unlisten: (p: number) => Promise<void>; 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<void> }).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', () => {
|
||||
|
||||
@@ -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: <alias>: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<void> {
|
||||
const localNodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const wantPorts = new Set<number>();
|
||||
for (const alias of this.aliasByPort.values()) {
|
||||
if (alias.nodeId === localNodeId) wantPorts.add(alias.port);
|
||||
}
|
||||
const wantPorts = new Set<number>(this.aliasByPort.keys());
|
||||
const havePorts = new Set(this.forwarder.getListenerPorts());
|
||||
for (const port of havePorts) {
|
||||
if (!wantPorts.has(port)) {
|
||||
|
||||
Reference in New Issue
Block a user