mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +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:
@@ -36,11 +36,10 @@ export class PilotTunnelCapacityError extends Error {
|
||||
/**
|
||||
* Discriminator for the two flavors of bridge that share the `bridges`
|
||||
* map: `'pilot'` is set by `registerTunnel` (agent-initiated long-lived
|
||||
* tunnel); `'proxy'` is set by `registerProxyBridge` /
|
||||
* `replaceOrRegisterProxyBridge` (central- or peer-initiated short-lived
|
||||
* proxy bridge). Used by the rejection-message formatter and by
|
||||
* `replaceOrRegisterProxyBridge` so a peer-initiated dial can supersede a
|
||||
* previous proxy bridge but never shadow a live pilot tunnel.
|
||||
* tunnel); `'proxy'` is set by `registerProxyBridge` (central-initiated
|
||||
* persistent bridge dialed by `MeshProxyTunnelDialer`). Used by the
|
||||
* rejection-message formatter so a pilot tunnel and a proxy bridge for the
|
||||
* same nodeId cannot coexist silently.
|
||||
*/
|
||||
export type BridgeKind = 'pilot' | 'proxy';
|
||||
|
||||
@@ -282,46 +281,12 @@ export class PilotTunnelManager extends EventEmitter {
|
||||
this.emit('proxy-bridge-up', nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a peer-initiated proxy bridge for an existing remote. If a proxy
|
||||
* bridge already exists for this nodeId, close it (the new dial is the source
|
||||
* of truth) and replace. If a pilot tunnel exists, refuse: pilot tunnels
|
||||
* always win over peer-initiated proxy bridges.
|
||||
*/
|
||||
public replaceOrRegisterProxyBridge(nodeId: number, bridge: PilotTunnelBridge): void {
|
||||
const existingKind = this.bridgeKinds.get(nodeId);
|
||||
if (existingKind === 'pilot') {
|
||||
throw new Error(this.formatBridgeConflict(nodeId, existingKind));
|
||||
}
|
||||
if (existingKind === 'proxy') {
|
||||
const old = this.bridges.get(nodeId);
|
||||
this.bridges.delete(nodeId);
|
||||
this.bridgeKinds.delete(nodeId);
|
||||
try { old?.close(1000, 'replaced-by-newer-proxy'); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
if (this.bridges.size >= PILOT_TUNNEL_HARD_LIMIT) {
|
||||
PilotMetrics.increment('tunnels_rejected_capacity');
|
||||
throw new PilotTunnelCapacityError(PILOT_TUNNEL_HARD_LIMIT);
|
||||
}
|
||||
bridge.once('closed', () => {
|
||||
if (this.bridges.get(nodeId) === bridge) {
|
||||
this.bridges.delete(nodeId);
|
||||
this.bridgeKinds.delete(nodeId);
|
||||
this.emit('proxy-bridge-down', nodeId, 'remote_closed');
|
||||
}
|
||||
});
|
||||
this.bridges.set(nodeId, bridge);
|
||||
this.bridgeKinds.set(nodeId, 'proxy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for the "slot already held" rejection
|
||||
* message thrown by `registerProxyBridge` and
|
||||
* `replaceOrRegisterProxyBridge`. Picks the bridge-kind subject and
|
||||
* the rejection tail so a stale call site cannot drift from the
|
||||
* other. `undefined` collapses to the pilot branch defensively;
|
||||
* production code always sets `bridgeKinds` whenever `bridges` is
|
||||
* set, so this is unreachable in practice.
|
||||
* message thrown by `registerProxyBridge`. Picks the bridge-kind
|
||||
* subject and the rejection tail. `undefined` collapses to the pilot
|
||||
* branch defensively; production code always sets `bridgeKinds`
|
||||
* whenever `bridges` is set, so this is unreachable in practice.
|
||||
*/
|
||||
private formatBridgeConflict(nodeId: number, existingKind: BridgeKind | undefined): string {
|
||||
return existingKind === 'proxy'
|
||||
|
||||
Reference in New Issue
Block a user