mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +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:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* `MeshService.getStatus().reverseCallbackStatus` discriminator.
|
||||
*
|
||||
* Surfaces the per-node state of the peer→central reverse path: forward
|
||||
* bridge open (`connected`), dial in flight (`connecting`), no bridge and
|
||||
* not dialing (`unavailable`), or non-proxy node (`not_applicable`). The
|
||||
* Routing tab consumes this to render a transient pill while central
|
||||
* reconciles a dropped bridge.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import { MeshService } from '../services/MeshService';
|
||||
import { MeshProxyTunnelDialer } from '../services/MeshProxyTunnelDialer';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
|
||||
let tmpDir: string;
|
||||
beforeAll(async () => { tmpDir = await setupTestDb(); });
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
function uniqueSuffix(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function seedProxyNode(meshEnabled: boolean): number {
|
||||
const db = DatabaseService.getInstance();
|
||||
const id = db.addNode({
|
||||
name: `peer-status-${uniqueSuffix()}`,
|
||||
type: 'remote',
|
||||
mode: 'proxy',
|
||||
api_url: `https://peer-${uniqueSuffix()}.example.com`,
|
||||
api_token: `tok-${uniqueSuffix()}`,
|
||||
compose_dir: '/tmp',
|
||||
is_default: false,
|
||||
});
|
||||
if (meshEnabled) db.setNodeMeshEnabled(id, true);
|
||||
return id;
|
||||
}
|
||||
|
||||
function seedPilotNode(): number {
|
||||
const db = DatabaseService.getInstance();
|
||||
return db.addNode({
|
||||
name: `pilot-${uniqueSuffix()}`,
|
||||
type: 'remote',
|
||||
mode: 'pilot_agent',
|
||||
api_url: `https://pilot-${uniqueSuffix()}.example.com`,
|
||||
api_token: `tok-${uniqueSuffix()}`,
|
||||
compose_dir: '/tmp',
|
||||
is_default: false,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
DatabaseService.getInstance().getDb().prepare('DELETE FROM nodes WHERE is_default = 0').run();
|
||||
MeshProxyTunnelDialer.resetForTest();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('MeshService.getStatus reverseCallbackStatus', () => {
|
||||
it('returns connected when the dialer has a bridge for a mesh-enabled proxy peer', async () => {
|
||||
const id = seedProxyNode(true);
|
||||
vi.spyOn(MeshProxyTunnelDialer.getInstance(), 'hasBridge')
|
||||
.mockImplementation((n: number) => n === id);
|
||||
|
||||
const statuses = await MeshService.getInstance().getStatus();
|
||||
const entry = statuses.find((s) => s.nodeId === id);
|
||||
expect(entry?.reverseCallbackStatus).toBe('connected');
|
||||
});
|
||||
|
||||
it('returns connecting when a dial is in flight for the peer', async () => {
|
||||
const id = seedProxyNode(true);
|
||||
vi.spyOn(MeshProxyTunnelDialer.getInstance(), 'hasBridge').mockReturnValue(false);
|
||||
vi.spyOn(MeshProxyTunnelDialer.getInstance(), 'isDialing')
|
||||
.mockImplementation((n: number) => n === id);
|
||||
|
||||
const statuses = await MeshService.getInstance().getStatus();
|
||||
const entry = statuses.find((s) => s.nodeId === id);
|
||||
expect(entry?.reverseCallbackStatus).toBe('connecting');
|
||||
});
|
||||
|
||||
it('returns unavailable when no bridge and no dial in flight', async () => {
|
||||
const id = seedProxyNode(true);
|
||||
vi.spyOn(MeshProxyTunnelDialer.getInstance(), 'hasBridge').mockReturnValue(false);
|
||||
vi.spyOn(MeshProxyTunnelDialer.getInstance(), 'isDialing').mockReturnValue(false);
|
||||
|
||||
const statuses = await MeshService.getInstance().getStatus();
|
||||
const entry = statuses.find((s) => s.nodeId === id);
|
||||
expect(entry?.reverseCallbackStatus).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('returns not_applicable for the local node', async () => {
|
||||
const statuses = await MeshService.getInstance().getStatus();
|
||||
const local = statuses.find((s) => s.nodeName === 'Local' || s.nodeId === 1);
|
||||
expect(local?.reverseCallbackStatus).toBe('not_applicable');
|
||||
});
|
||||
|
||||
it('returns not_applicable for pilot-mode peers', async () => {
|
||||
const id = seedPilotNode();
|
||||
const statuses = await MeshService.getInstance().getStatus();
|
||||
const entry = statuses.find((s) => s.nodeId === id);
|
||||
expect(entry?.reverseCallbackStatus).toBe('not_applicable');
|
||||
});
|
||||
|
||||
it('returns not_applicable for mesh-disabled proxy peers', async () => {
|
||||
const id = seedProxyNode(false);
|
||||
const statuses = await MeshService.getInstance().getStatus();
|
||||
const entry = statuses.find((s) => s.nodeId === id);
|
||||
expect(entry?.reverseCallbackStatus).toBe('not_applicable');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user