mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 12:48:10 +00:00
fix(mesh): route peer→central traffic over the existing forward WS (#1094)
* fix(mesh): route peer→central traffic over the existing forward WS The reverse mesh callback path (`/api/mesh/proxy-tunnel-from-peer`) needed SENCHO_PRIMARY_URL on central plus a publicly reachable origin from the peer's perspective. In a typical homelab where central sits behind NAT, peer→central dispatch silently failed at the dialer's short-circuit and the headline "call any service on any node by hostname" worked one way only. The forward WS at `/api/mesh/proxy-tunnel` is already bidirectional end to end. Make the bridge a persistent control-plane primitive: dial every mesh-enabled proxy peer at startup, reconcile every 60 s, never idle-close. Peer→central traffic multiplexes over the same WS via `tcp_open_reverse`. Removed: - `meshProxyTunnelFromPeer.ts` WS handler and dispatch - `MeshCentralRegistry`, `PeerToCentralMeshSessionDialer` - `mesh_handshake` first-frame state machine in `meshProxyTunnel.ts` - `maybeSendBootstrap`, `buildHandshakeFrame` in the dialer - `mesh_proxy_callback_bootstrap` capability and `maybeWarnUnsetPrimaryUrl` - `mesh_centrals` table (drop migration; greenfield, no users) - `PilotTunnelManager.replaceOrRegisterProxyBridge` (dead after handler removal) - twelve associated unit/integration tests plus the peer-recovery branch in `MeshService.openCrossNode` Added: - `MeshService.proactiveBridgeFanout` selects every mesh-enabled proxy peer (no longer gated on `mesh_stacks` rows) - `startBridgeReconcileLoop` runs the fanout every 60 s (override via `SENCHO_MESH_RECONCILE_INTERVAL_MS`) - `MeshProxyTunnelDialer` default idle TTL is now `0` and exposes `isDialing(nodeId)` for the status surface - `MeshNodeStatus.reverseCallbackStatus` discriminator (`connected | connecting | unavailable | not_applicable`) surfaced via `/api/mesh/status` and rendered as a pill in the Routing tab - `openCrossNode` error message distinguishes "no proxy target" from "waiting for central to dial the reverse bridge" - New tests: `mesh-service-proxy-tunnel-reconcile`, `mesh-status-reverse-callback`, `mesh-proxy-tunnel-dialer-no-idle-close` SENCHO_PRIMARY_URL is no longer required for any mesh function. * fix(mesh): rewrite proxy-tunnel reconcile test contents The previous commit renamed the file but the rewritten test bodies stayed unstaged on top of the rename. This commit lands the actual rewrite: the fanout assertion now requires every mesh-enabled proxy peer to be dialed, not just those with `mesh_stacks` rows, and adds a reconcile-tick repeated-call test.
This commit is contained in:
@@ -1021,10 +1021,9 @@ describe('MeshService.openCrossNode (BUG-4)', () => {
|
||||
};
|
||||
}
|
||||
|
||||
// Install a stub reverseDialer so openCrossNode skips the peer-side
|
||||
// bootstrap path (PeerToCentralMeshSessionDialer.ensureSession). The
|
||||
// dispatch behavior under test relies on dialMeshTcpStream being called
|
||||
// directly; the bootstrap kick would short-circuit before that mock fires.
|
||||
// Install a stub reverseDialer so openCrossNode routes through the
|
||||
// multiplex path rather than depending on a live MeshProxyTunnelDialer
|
||||
// bridge being open against a real peer.
|
||||
const stubDialer = { openMeshTcpStream: vi.fn() };
|
||||
beforeEach(() => { MeshService.getInstance().setReverseDialer(stubDialer); });
|
||||
afterEach(() => { MeshService.getInstance().setReverseDialer(null); });
|
||||
@@ -1092,16 +1091,13 @@ 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.
|
||||
describe('MeshService.openCrossNode without reverseDialer', () => {
|
||||
// Forward + reverse mesh traffic now share the same central-initiated
|
||||
// bridge. When openCrossNode runs without a reverseDialer installed it
|
||||
// falls straight through to dialMeshTcpStream, which on central uses
|
||||
// MeshProxyTunnelDialer.ensureBridge to reach a proxy peer. The
|
||||
// previous peer-recovery branch (which depended on the now-removed
|
||||
// mesh_centrals table) is gone.
|
||||
function makeFakeStream(streamId: number): MeshTcpStreamLike & EventEmitter {
|
||||
const ee = new EventEmitter() as MeshTcpStreamLike & EventEmitter & { destroyed: boolean };
|
||||
ee.destroyed = false;
|
||||
@@ -1115,17 +1111,11 @@ describe('MeshService.openCrossNode peer-recovery gating', () => {
|
||||
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();
|
||||
beforeEach(() => {
|
||||
MeshService.getInstance().setReverseDialer(null);
|
||||
});
|
||||
|
||||
it('central (no mesh_centrals row) skips peer-recovery and calls dialMeshTcpStream', async () => {
|
||||
it('central (no reverseDialer) falls through to dialMeshTcpStream without route.resolve.fail forward-from-peer', async () => {
|
||||
const svc = MeshService.getInstance();
|
||||
const target: MeshTarget = {
|
||||
nodeId: 7, stack: 'audit-mesh-proxy', service: 'echo',
|
||||
@@ -1142,11 +1132,7 @@ describe('MeshService.openCrossNode peer-recovery gating', () => {
|
||||
.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.
|
||||
expect(events.some((e) => e.type === 'route.dispatch')).toBe(true);
|
||||
const wrongFail = events.find((e) =>
|
||||
e.type === 'route.resolve.fail'
|
||||
&& (e.details as { direction?: string } | undefined)?.direction === 'forward-from-peer',
|
||||
@@ -1156,48 +1142,6 @@ describe('MeshService.openCrossNode peer-recovery gating', () => {
|
||||
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<void> })
|
||||
.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', () => {
|
||||
|
||||
Reference in New Issue
Block a user