mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 20:58:04 +00:00
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.
This commit is contained in:
@@ -33,6 +33,7 @@ beforeEach(() => {
|
|||||||
senchoIp: string | null;
|
senchoIp: string | null;
|
||||||
meshSubnet: string;
|
meshSubnet: string;
|
||||||
networkSetupError: string | null;
|
networkSetupError: string | null;
|
||||||
|
selfCentralNodeId: number | null;
|
||||||
};
|
};
|
||||||
svc.aliasCache = new Map();
|
svc.aliasCache = new Map();
|
||||||
svc.aliasByPort = new Map();
|
svc.aliasByPort = new Map();
|
||||||
@@ -43,6 +44,8 @@ beforeEach(() => {
|
|||||||
svc.senchoIp = '172.30.0.2';
|
svc.senchoIp = '172.30.0.2';
|
||||||
svc.meshSubnet = '172.30.0.0/24';
|
svc.meshSubnet = '172.30.0.0/24';
|
||||||
svc.networkSetupError = null;
|
svc.networkSetupError = null;
|
||||||
|
svc.selfCentralNodeId = null;
|
||||||
|
delete process.env.SENCHO_ENROLL_TOKEN;
|
||||||
vi.restoreAllMocks();
|
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<number, unknown>;
|
||||||
|
openSameNode: (t: MeshTarget, s: unknown) => Promise<void>;
|
||||||
|
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<number, unknown>;
|
||||||
|
openSameNode: (t: MeshTarget, s: unknown) => Promise<void>;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -189,6 +189,12 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
|
|||||||
private senchoIp: string | null = null;
|
private senchoIp: string | null = null;
|
||||||
private meshSubnet: string = DEFAULT_MESH_SUBNET;
|
private meshSubnet: string = DEFAULT_MESH_SUBNET;
|
||||||
private networkSetupError: string | null = null;
|
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() {
|
private constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -201,6 +207,24 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
|
|||||||
return MeshService.instance;
|
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<string, unknown>;
|
||||||
|
if (typeof payload.nodeId === 'number') return payload.nodeId;
|
||||||
|
} catch {
|
||||||
|
// Malformed token; fall through to local default.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NodeRegistry.getInstance().getDefaultNodeId();
|
||||||
|
}
|
||||||
|
|
||||||
public async start(): Promise<void> {
|
public async start(): Promise<void> {
|
||||||
if (this.started) return;
|
if (this.started) return;
|
||||||
this.started = true;
|
this.started = true;
|
||||||
@@ -227,6 +251,8 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.selfCentralNodeId = this.resolveSelfCentralNodeId();
|
||||||
|
|
||||||
await this.setupMeshNetwork();
|
await this.setupMeshNetwork();
|
||||||
try {
|
try {
|
||||||
await this.refreshAliasCache();
|
await this.refreshAliasCache();
|
||||||
@@ -259,7 +285,7 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
|
|||||||
const dataPlane = this.senchoIp ? 'ok' : `unavailable (${this.networkSetupError ?? 'unknown'})`;
|
const dataPlane = this.senchoIp ? 'ok' : `unavailable (${this.networkSetupError ?? 'unknown'})`;
|
||||||
this.logActivity({
|
this.logActivity({
|
||||||
source: 'mesh', level: this.senchoIp ? 'info' : 'warn', type: 'mesh.enable',
|
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 */ }
|
try { src.destroy(); } catch { /* ignore */ }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const localNodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
const selfNodeId = this.selfCentralNodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
|
||||||
if (target.nodeId === localNodeId) {
|
if (target.nodeId === selfNodeId) {
|
||||||
await this.openSameNode(target, src);
|
await this.openSameNode(target, src);
|
||||||
} else {
|
} else {
|
||||||
this.openCrossNode(target, src);
|
this.openCrossNode(target, src);
|
||||||
|
|||||||
Reference in New Issue
Block a user