diff --git a/backend/src/__tests__/mesh-service.test.ts b/backend/src/__tests__/mesh-service.test.ts index daa59175..fb166ec5 100644 --- a/backend/src/__tests__/mesh-service.test.ts +++ b/backend/src/__tests__/mesh-service.test.ts @@ -920,6 +920,114 @@ describe('MeshService.openCrossNode (BUG-4)', () => { }); }); +describe('MeshService.openCrossNode peer-recovery gating', () => { + // Regression cover for the v0.81.0 forward-direction break: on central + // (no mesh_centrals row, no reverseDialer because central is the bridge + // initiator side) the peer-recovery branch incorrectly fired, found no + // PeerToCentralMeshSessionDialer session, and destroyed the inbound + // socket with route.resolve.fail forward-from-peer no_session. The + // intent of the branch was peer cold-start; the guard + // `!this.reverseDialer` alone could not distinguish "I am central" from + // "I am a peer waiting for central to dial in", because both states + // present as reverseDialer===null. + function makeFakeStream(streamId: number): MeshTcpStreamLike & EventEmitter { + const ee = new EventEmitter() as MeshTcpStreamLike & EventEmitter & { destroyed: boolean }; + ee.destroyed = false; + Object.defineProperty(ee, 'streamId', { value: streamId, writable: false }); + ee.write = vi.fn().mockReturnValue(true); + ee.end = vi.fn(); + ee.destroy = vi.fn(() => { ee.destroyed = true; }); + return ee; + } + function makeFakeSocket(): { destroy: ReturnType; end: ReturnType; on: ReturnType; write: ReturnType } { + return { destroy: vi.fn(), end: vi.fn(), on: vi.fn(), write: vi.fn() }; + } + + // No stub reverseDialer here: the whole point is to exercise the + // !reverseDialer code path. Reset the registry per test so the mesh_centrals + // row state is deterministic. + beforeEach(async () => { + const { MeshCentralRegistry } = await import('../services/MeshCentralRegistry'); + MeshCentralRegistry.resetForTest(); + DatabaseService.getInstance().getDb().prepare('DELETE FROM mesh_centrals').run(); + MeshService.getInstance().setReverseDialer(null); + }); + + it('central (no mesh_centrals row) skips peer-recovery and calls dialMeshTcpStream', async () => { + const svc = MeshService.getInstance(); + const target: MeshTarget = { + nodeId: 7, stack: 'audit-mesh-proxy', service: 'echo', + port: 9002, alias: 'echo.audit-mesh-proxy.sencho-test-03.sencho', + }; + const fakeStream = makeFakeStream(101); + const dialSpy = vi.spyOn( + svc as unknown as { dialMeshTcpStream: (t: MeshTarget) => MeshTcpStreamLike | null }, + 'dialMeshTcpStream', + ).mockReturnValue(fakeStream); + + const fakeSrc = makeFakeSocket(); + await (svc as unknown as { openCrossNode: (t: MeshTarget, s: unknown) => Promise }) + .openCrossNode(target, fakeSrc); + + const events = svc.getActivity({ limit: 50 }); + const dispatch = events.find((e) => e.type === 'route.dispatch'); + expect(dispatch).toBeDefined(); + + // The bug surface: a route.resolve.fail with direction=forward-from-peer + // would mean central wrongly went through the peer-recovery branch. + const wrongFail = events.find((e) => + e.type === 'route.resolve.fail' + && (e.details as { direction?: string } | undefined)?.direction === 'forward-from-peer', + ); + expect(wrongFail).toBeUndefined(); + expect(dialSpy).toHaveBeenCalledTimes(1); + expect(fakeSrc.destroy).not.toHaveBeenCalled(); + fakeStream.emit('close'); + }); + + it('proxy-peer (mesh_centrals row + no session) still emits route.resolve.fail forward-from-peer no_session', async () => { + const { MeshCentralRegistry } = await import('../services/MeshCentralRegistry'); + const { PeerToCentralMeshSessionDialer } = await import('../services/PeerToCentralMeshSessionDialer'); + MeshCentralRegistry.getInstance().upsert({ + centralInstanceId: 'central-uuid-test', + centralApiUrl: 'https://central.example.com', + callbackJwt: 'eyJhbGciOiJIUzI1NiJ9.fake.token', + jwtIssuedAt: Math.floor(Date.now() / 1000), + jwtExpiresAt: Math.floor(Date.now() / 1000) + 90 * 24 * 3600, + }); + const ensureSpy = vi.spyOn( + PeerToCentralMeshSessionDialer.getInstance(), + 'ensureSession', + ).mockResolvedValue(null); + + const svc = MeshService.getInstance(); + const target: MeshTarget = { + nodeId: 1, stack: 'audit-mesh-central', service: 'echo', + port: 9000, alias: 'echo.audit-mesh-central.Local.sencho', + }; + const dialSpy = vi.spyOn( + svc as unknown as { dialMeshTcpStream: (t: MeshTarget) => MeshTcpStreamLike | null }, + 'dialMeshTcpStream', + ); + + const fakeSrc = makeFakeSocket(); + await (svc as unknown as { openCrossNode: (t: MeshTarget, s: unknown) => Promise }) + .openCrossNode(target, fakeSrc); + + const events = svc.getActivity({ limit: 50 }); + const fail = events.find((e) => + e.type === 'route.resolve.fail' + && (e.details as { direction?: string; reason?: string } | undefined)?.direction === 'forward-from-peer' + && (e.details as { reason?: string } | undefined)?.reason === 'no_session', + ); + expect(fail).toBeDefined(); + expect(ensureSpy).toHaveBeenCalled(); + // Peer-recovery aborted dispatch before reaching dialMeshTcpStream. + expect(dialSpy).not.toHaveBeenCalled(); + expect(fakeSrc.destroy).toHaveBeenCalled(); + }); +}); + describe('MeshService pilot handleAccept dispatch', () => { function makeEnrollToken(nodeId: number): string { const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); diff --git a/backend/src/services/MeshService.ts b/backend/src/services/MeshService.ts index 3d528db3..06c73a88 100644 --- a/backend/src/services/MeshService.ts +++ b/backend/src/services/MeshService.ts @@ -1589,36 +1589,48 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { message: `cross-node dispatch to ${target.alias} on node ${target.nodeId}`, }); - // Peer-side recovery: when no reverseDialer is installed, kick the - // symmetric-dial path so central learns about this peer. The - // proactive back-dial from central (registered as a side effect via - // the proxy-tunnel WS upgrade) is what actually installs the - // reverseDialer on this peer. If the session cannot be established - // (cache miss, central down, auth rejected), log route.resolve.fail - // and drop the inbound socket. + // Peer-side recovery: when this Sencho is acting as a proxy-mode peer + // (mesh_centrals has a row from a prior bootstrap) and the + // reverseDialer is not currently installed, the inbound bridge from + // central is either cold-start (peer just rebooted) or has been torn + // down (idle close, central restart). Kick the symmetric-dial path + // so the peer re-opens its callback WS to central before attempting + // the cross-node dispatch. + // + // Central instances never have a mesh_centrals row (central is not a + // peer of itself), so this branch is correctly skipped on central. + // Central falls straight through to dialMeshTcpStream which uses its + // own PilotTunnelManager + MeshProxyTunnelDialer to reach the target + // peer. Without this gate, central enters the branch on every + // forward dispatch, finds no session, and destroys the inbound + // socket with route.resolve.fail forward-from-peer no_session. if (!this.reverseDialer) { - try { - const { PeerToCentralMeshSessionDialer } = await import('./PeerToCentralMeshSessionDialer'); - const session = await PeerToCentralMeshSessionDialer.getInstance().ensureSession(); - if (!session) { + const { MeshCentralRegistry } = await import('./MeshCentralRegistry'); + const isProxyPeer = MeshCentralRegistry.getInstance().getActive() !== null; + if (isProxyPeer) { + try { + const { PeerToCentralMeshSessionDialer } = await import('./PeerToCentralMeshSessionDialer'); + const session = await PeerToCentralMeshSessionDialer.getInstance().ensureSession(); + if (!session) { + this.logActivity({ + source: 'mesh', level: 'warn', type: 'route.resolve.fail', + nodeId: target.nodeId, alias: target.alias, + message: `peer cross-node dispatch failed: no central callback session available`, + details: { direction: 'forward-from-peer', reason: 'no_session' }, + }); + try { src.destroy(); } catch { /* ignore */ } + return; + } + } catch (err) { this.logActivity({ source: 'mesh', level: 'warn', type: 'route.resolve.fail', nodeId: target.nodeId, alias: target.alias, - message: `peer cross-node dispatch failed: no central callback session available`, - details: { direction: 'forward-from-peer', reason: 'no_session' }, + message: `peer cross-node dispatch failed: bootstrap threw`, + details: { direction: 'forward-from-peer', reason: 'bootstrap_threw' }, }); try { src.destroy(); } catch { /* ignore */ } return; } - } catch (err) { - this.logActivity({ - source: 'mesh', level: 'warn', type: 'route.resolve.fail', - nodeId: target.nodeId, alias: target.alias, - message: `peer cross-node dispatch failed: bootstrap threw`, - details: { direction: 'forward-from-peer', reason: 'bootstrap_threw' }, - }); - try { src.destroy(); } catch { /* ignore */ } - return; } }