From 41a1df279d2fbc64637ac05c50141f60b7fb791c Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 10 May 2026 03:09:53 -0400 Subject: [PATCH] fix(mesh): fix pilot handleAccept dispatch by deriving self nodeId from enrollment token (#1023) On a pilot node, handleAccept compared target.nodeId (from central's perspective via pilotAliasOverlay) against NodeRegistry.getDefaultNodeId() which always returns 1. This inverted same-node vs cross-node dispatch: - central's alias (nodeId=1) matched localNodeId=1 -> openSameNode on pilot (no container -> silent close, 0 bytes) - pilot's own alias (nodeId=14) != 1 -> openCrossNode -> tunnel loop Fix: add resolveSelfCentralNodeId() that decodes the nodeId claim from SENCHO_ENROLL_TOKEN (present on every pilot; payload decode only, no signature verification). Returns getDefaultNodeId() on central (env unset). Cached in selfCentralNodeId at start(); handleAccept reads the cache. After this fix the reverse direction (pilot-prober -> central echo) routes through openCrossNode (reverse tunnel) and the same-node path (pilot-prober -> pilot echo) routes through openSameNode (local Dockerode dial). Closes BUG-3 banner data-path regression. Tests: 5 new cases covering resolveSelfCentralNodeId token extraction, fallback paths, and handleAccept dispatch routing on a simulated pilot. --- backend/src/__tests__/mesh-service.test.ts | 95 ++++++++++++++++++++++ backend/src/services/MeshService.ts | 32 +++++++- 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/backend/src/__tests__/mesh-service.test.ts b/backend/src/__tests__/mesh-service.test.ts index 8daf5f16..09b7531c 100644 --- a/backend/src/__tests__/mesh-service.test.ts +++ b/backend/src/__tests__/mesh-service.test.ts @@ -33,6 +33,7 @@ beforeEach(() => { senchoIp: string | null; meshSubnet: string; networkSetupError: string | null; + selfCentralNodeId: number | null; }; svc.aliasCache = new Map(); svc.aliasByPort = new Map(); @@ -43,6 +44,8 @@ beforeEach(() => { svc.senchoIp = '172.30.0.2'; svc.meshSubnet = '172.30.0.0/24'; svc.networkSetupError = null; + svc.selfCentralNodeId = null; + delete process.env.SENCHO_ENROLL_TOKEN; vi.restoreAllMocks(); }); @@ -809,3 +812,95 @@ describe('MeshService.openCrossNode (BUG-4)', () => { } }); }); + +describe('MeshService pilot handleAccept dispatch', () => { + function makeEnrollToken(nodeId: number): string { + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ scope: 'pilot_enroll', nodeId })).toString('base64url'); + return `${header}.${payload}.fakesig`; + } + + it('resolveSelfCentralNodeId extracts nodeId from SENCHO_ENROLL_TOKEN', () => { + process.env.SENCHO_ENROLL_TOKEN = makeEnrollToken(14); + const svc = MeshService.getInstance() as unknown as { + resolveSelfCentralNodeId: () => number; + }; + expect(svc.resolveSelfCentralNodeId()).toBe(14); + }); + + it('resolveSelfCentralNodeId falls back to local default when token is absent', () => { + delete process.env.SENCHO_ENROLL_TOKEN; + const svc = MeshService.getInstance() as unknown as { + resolveSelfCentralNodeId: () => number; + }; + const db = DatabaseService.getInstance(); + const defaultId = db.getDefaultNode()?.id ?? 1; + expect(svc.resolveSelfCentralNodeId()).toBe(defaultId); + }); + + it('resolveSelfCentralNodeId falls back to local default for a malformed token', () => { + process.env.SENCHO_ENROLL_TOKEN = 'not.a.jwt'; + const svc = MeshService.getInstance() as unknown as { + resolveSelfCentralNodeId: () => number; + }; + const db = DatabaseService.getInstance(); + const defaultId = db.getDefaultNode()?.id ?? 1; + expect(svc.resolveSelfCentralNodeId()).toBe(defaultId); + }); + + it('handleAccept routes same-node alias to openSameNode on a pilot', async () => { + const svc = MeshService.getInstance(); + const internals = svc as unknown as { + selfCentralNodeId: number | null; + aliasByPort: Map; + openSameNode: (t: MeshTarget, s: unknown) => Promise; + openCrossNode: (t: MeshTarget, s: unknown) => void; + }; + internals.selfCentralNodeId = 14; + internals.aliasByPort.set(9001, { + host: 'echo.audit-mesh-pilot.sencho-pilot-test.sencho', + nodeId: 14, + nodeName: 'sencho-pilot-test', + stackName: 'audit-mesh-pilot', + serviceName: 'echo', + port: 9001, + }); + + const openSame = vi.spyOn(internals, 'openSameNode').mockResolvedValue(undefined); + const openCross = vi.spyOn(internals, 'openCrossNode').mockImplementation(() => undefined); + const fakeSrc = { remoteAddress: '127.0.0.1', destroy: vi.fn() } as unknown as import('net').Socket; + + await svc.handleAccept(9001, fakeSrc); + + expect(openSame).toHaveBeenCalledOnce(); + expect(openCross).not.toHaveBeenCalled(); + }); + + it('handleAccept routes cross-node alias to openCrossNode on a pilot', async () => { + const svc = MeshService.getInstance(); + const internals = svc as unknown as { + selfCentralNodeId: number | null; + aliasByPort: Map; + openSameNode: (t: MeshTarget, s: unknown) => Promise; + openCrossNode: (t: MeshTarget, s: unknown) => void; + }; + internals.selfCentralNodeId = 14; + internals.aliasByPort.set(9000, { + host: 'echo.audit-mesh-prod.Local.sencho', + nodeId: 1, + nodeName: 'Local', + stackName: 'audit-mesh-prod', + serviceName: 'echo', + port: 9000, + }); + + const openSame = vi.spyOn(internals, 'openSameNode').mockResolvedValue(undefined); + const openCross = vi.spyOn(internals, 'openCrossNode').mockImplementation(() => undefined); + const fakeSrc = { remoteAddress: '127.0.0.1', destroy: vi.fn() } as unknown as import('net').Socket; + + await svc.handleAccept(9000, fakeSrc); + + expect(openCross).toHaveBeenCalledOnce(); + expect(openSame).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/services/MeshService.ts b/backend/src/services/MeshService.ts index bdcaba7b..213f4b84 100644 --- a/backend/src/services/MeshService.ts +++ b/backend/src/services/MeshService.ts @@ -189,6 +189,12 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { private senchoIp: string | null = null; private meshSubnet: string = DEFAULT_MESH_SUBNET; private networkSetupError: string | null = null; + // On a pilot node, central's DB id for this node (e.g. 14). Used by + // handleAccept to decide same-node vs cross-node; the pilotAliasOverlay + // carries nodeIds from central's perspective, so comparing against the + // pilot's own local DB id (always 1) inverts dispatch. Null on central + // (fallback to getDefaultNodeId()). Populated from SENCHO_ENROLL_TOKEN. + private selfCentralNodeId: number | null = null; private constructor() { super(); @@ -201,6 +207,24 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { return MeshService.instance; } + private resolveSelfCentralNodeId(): number { + const tok = process.env.SENCHO_ENROLL_TOKEN; + if (tok) { + try { + // Extract payload only — signature verification not needed here; + // we only need the nodeId claim, not auth. + const [, b64] = tok.split('.'); + const payload = JSON.parse( + Buffer.from(b64, 'base64url').toString('utf8'), + ) as Record; + if (typeof payload.nodeId === 'number') return payload.nodeId; + } catch { + // Malformed token; fall through to local default. + } + } + return NodeRegistry.getInstance().getDefaultNodeId(); + } + public async start(): Promise { if (this.started) return; this.started = true; @@ -227,6 +251,8 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { }); }); + this.selfCentralNodeId = this.resolveSelfCentralNodeId(); + await this.setupMeshNetwork(); try { await this.refreshAliasCache(); @@ -259,7 +285,7 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { const dataPlane = this.senchoIp ? 'ok' : `unavailable (${this.networkSetupError ?? 'unknown'})`; this.logActivity({ source: 'mesh', level: this.senchoIp ? 'info' : 'warn', type: 'mesh.enable', - message: `MeshService started (data plane ${dataPlane})`, + message: `MeshService started (data plane ${dataPlane}, self nodeId ${this.selfCentralNodeId})`, }); } @@ -1208,8 +1234,8 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { try { src.destroy(); } catch { /* ignore */ } return; } - const localNodeId = NodeRegistry.getInstance().getDefaultNodeId(); - if (target.nodeId === localNodeId) { + const selfNodeId = this.selfCentralNodeId ?? NodeRegistry.getInstance().getDefaultNodeId(); + if (target.nodeId === selfNodeId) { await this.openSameNode(target, src); } else { this.openCrossNode(target, src);