From 489aab451691d95bdc1b2c4cd8f5f14a17b9aebb Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 15 May 2026 09:00:39 -0400 Subject: [PATCH] fix(mesh): testUpstream dials via ensureBridge so proxy-mode targets are probeable (#1057) testUpstream short-circuited on hasActiveTunnel + getBridge (pilot-only) when probing a cross-node alias. Proxy-mode remotes never have a "pilot tunnel" in the manager's sense, so any operator hitting "Test alias" on a proxy-mode target returned tunnel_down even when the cross-node dial via dialMeshTcpStream worked fine. Replace with await ptm.ensureBridge(target.nodeId), matching the mode- agnostic dispatch dialMeshTcpStream already uses. ensureBridge returns an existing pilot tunnel, an existing proxy bridge, or asks MeshProxyTunnelDialer to dial on demand. The 'no pilot tunnel' vs 'no bridge' distinction goes away (they meant the same thing). Adds two test cases: probing a proxy-mode remote via on-demand bridge, and the tunnel_down path when ensureBridge yields null. --- backend/src/__tests__/mesh-service.test.ts | 77 ++++++++++++++++++++++ backend/src/services/MeshService.ts | 10 +-- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/backend/src/__tests__/mesh-service.test.ts b/backend/src/__tests__/mesh-service.test.ts index fe120c26..a17eea4d 100644 --- a/backend/src/__tests__/mesh-service.test.ts +++ b/backend/src/__tests__/mesh-service.test.ts @@ -261,6 +261,83 @@ describe('MeshService.testUpstream tunnel-down path', () => { expect(result.where).toBe('no_route'); expect(result.code).toBe('no_route'); }); + + it('probes a proxy-mode remote via PilotTunnelManager.ensureBridge (bridge dialed on demand)', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + const remoteNodeId = db.addNode({ + name: 'edge', type: 'remote', is_default: false, + compose_dir: '/tmp', api_url: 'https://edge.example', + api_token: 'tok', mode: 'proxy', + }); + + (svc as unknown as { aliasCache: Map }).aliasCache = new Map([ + ['db.api.edge.sencho', { + host: 'db.api.edge.sencho', + nodeId: remoteNodeId, + nodeName: 'edge', + stackName: 'api', + serviceName: 'db', + port: 5432, + }], + ]); + db.insertMeshStack(remoteNodeId, 'api', 'tester'); + + const { PilotTunnelManager } = await import('../services/PilotTunnelManager'); + const fakeStream = new EventEmitter() as EventEmitter & { destroy: () => void }; + fakeStream.destroy = vi.fn(); + const fakeBridge = { + openTcpStream: vi.fn().mockReturnValue(fakeStream), + getActiveStreamCount: () => 0, + close: vi.fn(), + }; + vi.spyOn(PilotTunnelManager.getInstance(), 'ensureBridge') + .mockResolvedValue(fakeBridge as unknown as Awaited>); + + const probe = svc.testUpstream('db.api.edge.sencho', localNodeId); + // Emit `open` so the probe resolves cleanly. + setImmediate(() => fakeStream.emit('open')); + const result = await probe; + + expect(fakeBridge.openTcpStream).toHaveBeenCalledWith({ stack: 'api', service: 'db', port: 5432 }); + expect(result.ok).toBe(true); + + db.deleteNode(remoteNodeId); + }); + + it('returns ok:false where=pilot_tunnel when ensureBridge yields null (no reachable bridge)', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + const remoteNodeId = db.addNode({ + name: 'edge2', type: 'remote', is_default: false, + compose_dir: '/tmp', api_url: 'https://edge2.example', + api_token: 'tok', mode: 'proxy', + }); + + (svc as unknown as { aliasCache: Map }).aliasCache = new Map([ + ['db.api.edge2.sencho', { + host: 'db.api.edge2.sencho', + nodeId: remoteNodeId, + nodeName: 'edge2', + stackName: 'api', + serviceName: 'db', + port: 5432, + }], + ]); + db.insertMeshStack(remoteNodeId, 'api', 'tester'); + + const { PilotTunnelManager } = await import('../services/PilotTunnelManager'); + vi.spyOn(PilotTunnelManager.getInstance(), 'ensureBridge').mockResolvedValue(null); + + const result = await svc.testUpstream('db.api.edge2.sencho', localNodeId); + expect(result.ok).toBe(false); + expect(result.where).toBe('pilot_tunnel'); + expect(result.code).toBe('tunnel_down'); + + db.deleteNode(remoteNodeId); + }); }); describe('getSenchoIpFromSubnet', () => { diff --git a/backend/src/services/MeshService.ts b/backend/src/services/MeshService.ts index 52cf814c..854b6d3c 100644 --- a/backend/src/services/MeshService.ts +++ b/backend/src/services/MeshService.ts @@ -1604,11 +1604,13 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { } if (target.nodeId !== sourceNodeId) { + // Use ensureBridge so proxy-mode remotes get a bridge dialed + // on demand. Pre-fix this called hasActiveTunnel + getBridge, + // which only checked the pilot-tunnel slot and returned + // tunnel_down for proxy-mode targets even when the regular + // dialMeshTcpStream path worked. const ptm = PilotTunnelManager.getInstance(); - if (!ptm.hasActiveTunnel(target.nodeId)) { - return { ok: false, where: 'pilot_tunnel', code: 'tunnel_down', message: 'no pilot tunnel' }; - } - const bridge = ptm.getBridge(target.nodeId); + const bridge = await ptm.ensureBridge(target.nodeId); if (!bridge) { return { ok: false, where: 'pilot_tunnel', code: 'tunnel_down', message: 'no bridge' }; }