mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-01 13:18:08 +00:00
f6e42535c8
* 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.
149 lines
5.0 KiB
TypeScript
149 lines
5.0 KiB
TypeScript
/**
|
|
* `MeshService.proactiveBridgeFanout` and the bridge-reconcile loop.
|
|
*
|
|
* Central proactively dials every mesh-enabled proxy-mode peer at startup
|
|
* and on every reconcile tick so peer→central reverse traffic always has a
|
|
* live forward WS to multiplex through. The fanout selects ALL such peers
|
|
* regardless of whether they have any `mesh_stacks` rows yet, because the
|
|
* bridge is a control-plane primitive, not stack-scoped.
|
|
*/
|
|
import { afterAll, afterEach, 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-fanout-${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 insertMeshStack(nodeId: number, stackName: string): void {
|
|
const db = DatabaseService.getInstance();
|
|
db.getDb().prepare(
|
|
'INSERT INTO mesh_stacks (node_id, stack_name, created_at, created_by) VALUES (?, ?, ?, ?)',
|
|
).run(nodeId, stackName, Date.now(), 'test');
|
|
}
|
|
|
|
function clearNodesAndMeshStacks(): void {
|
|
const db = DatabaseService.getInstance().getDb();
|
|
db.prepare('DELETE FROM mesh_stacks').run();
|
|
db.prepare('DELETE FROM nodes WHERE is_default = 0').run();
|
|
}
|
|
|
|
function callFanout(): Promise<void> {
|
|
return (MeshService.getInstance() as unknown as {
|
|
proactiveBridgeFanout: () => Promise<void>;
|
|
}).proactiveBridgeFanout();
|
|
}
|
|
|
|
describe('MeshService.proactiveBridgeFanout', () => {
|
|
beforeEach(() => {
|
|
clearNodesAndMeshStacks();
|
|
MeshProxyTunnelDialer.resetForTest();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('iterates every mesh-enabled proxy-mode node regardless of mesh_stacks rows', async () => {
|
|
const calls: number[] = [];
|
|
vi.spyOn(MeshProxyTunnelDialer.getInstance(), 'ensureBridge')
|
|
.mockImplementation(async (id: number) => {
|
|
calls.push(id);
|
|
return null;
|
|
});
|
|
|
|
const withStack = seedProxyNode(true);
|
|
insertMeshStack(withStack, `stack-${uniqueSuffix()}`);
|
|
|
|
// Mesh-enabled proxy node WITHOUT any mesh_stacks row. The old
|
|
// SQL excluded these; this assertion locks in the new behavior.
|
|
const withoutStack = seedProxyNode(true);
|
|
|
|
// Mesh-disabled proxy node WITH a mesh_stacks row. Must still be skipped.
|
|
const meshOff = seedProxyNode(false);
|
|
insertMeshStack(meshOff, `stack-meshoff-${uniqueSuffix()}`);
|
|
|
|
await callFanout();
|
|
|
|
expect(calls.sort((a, b) => a - b)).toEqual([withStack, withoutStack].sort((a, b) => a - b));
|
|
});
|
|
|
|
it('throttles to concurrency 4', async () => {
|
|
let inflight = 0;
|
|
let maxInflight = 0;
|
|
vi.spyOn(MeshProxyTunnelDialer.getInstance(), 'ensureBridge')
|
|
.mockImplementation(async () => {
|
|
inflight++;
|
|
if (inflight > maxInflight) maxInflight = inflight;
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
inflight--;
|
|
return null;
|
|
});
|
|
|
|
for (let i = 0; i < 12; i++) seedProxyNode(true);
|
|
|
|
await callFanout();
|
|
|
|
expect(maxInflight).toBeLessThanOrEqual(4);
|
|
expect(maxInflight).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('failures do not abort the fanout', async () => {
|
|
const ids: number[] = [];
|
|
for (let i = 0; i < 3; i++) ids.push(seedProxyNode(true));
|
|
const failingId = ids[1];
|
|
|
|
const calls: number[] = [];
|
|
vi.spyOn(MeshProxyTunnelDialer.getInstance(), 'ensureBridge')
|
|
.mockImplementation(async (id: number) => {
|
|
calls.push(id);
|
|
if (id === failingId) throw new Error('boom');
|
|
return null;
|
|
});
|
|
|
|
await callFanout();
|
|
|
|
expect(calls.slice().sort((a, b) => a - b)).toEqual(ids.slice().sort((a, b) => a - b));
|
|
});
|
|
|
|
it('repeated fanout calls invoke ensureBridge each tick (reconcile semantics)', async () => {
|
|
const ensureSpy = vi.spyOn(MeshProxyTunnelDialer.getInstance(), 'ensureBridge')
|
|
.mockResolvedValue(null);
|
|
|
|
const id = seedProxyNode(true);
|
|
await callFanout();
|
|
await callFanout();
|
|
|
|
expect(ensureSpy.mock.calls.filter(([n]) => n === id).length).toBe(2);
|
|
});
|
|
});
|