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:
Anso
2026-05-17 22:00:31 -04:00
committed by GitHub
parent 54c07d4930
commit f6e42535c8
37 changed files with 414 additions and 3107 deletions
@@ -34,7 +34,6 @@ export const CAPABILITIES = [
'registries',
'self-update',
'vulnerability-scanning',
'mesh_proxy_callback_bootstrap',
] as const;
export type Capability = (typeof CAPABILITIES)[number];
+7 -17
View File
@@ -1489,23 +1489,13 @@ export class DatabaseService {
} catch (e) {
console.warn('[DatabaseService] mesh_stacks migration:', (e as Error).message);
}
try {
this.db.prepare(`
CREATE TABLE IF NOT EXISTS mesh_centrals (
central_instance_id TEXT PRIMARY KEY,
central_api_url TEXT NOT NULL,
callback_jwt TEXT NOT NULL,
jwt_issued_at INTEGER NOT NULL,
jwt_expires_at INTEGER NOT NULL,
last_bootstrap_at INTEGER NOT NULL,
last_used_at INTEGER,
last_rejected_at INTEGER,
last_reject_reason TEXT
)
`).run();
} catch (e) {
console.warn('[DatabaseService] Could not create mesh_centrals:', (e as Error).message);
}
// mesh_centrals was the peer-side cache of the reverse-callback JWT
// (central → peer bootstrap material). Peer→central traffic now
// multiplexes over the existing forward WS via `tcp_open_reverse`,
// so the table is no longer written or read. Drop it on every boot;
// idempotent.
try { this.db.prepare('DROP TABLE IF EXISTS mesh_centrals').run(); }
catch (e) { console.warn('[DatabaseService] Could not drop mesh_centrals:', (e as Error).message); }
this.tryAddColumn('nodes', 'mesh_enabled', 'INTEGER NOT NULL DEFAULT 0');
}
-119
View File
@@ -1,119 +0,0 @@
import { EventEmitter } from 'events';
import { DatabaseService } from './DatabaseService';
export interface MeshCentralMaterial {
centralInstanceId: string;
centralApiUrl: string;
callbackJwt: string;
jwtIssuedAt: number;
jwtExpiresAt: number;
}
export interface MeshCentralRow extends MeshCentralMaterial {
lastBootstrapAt: number;
lastUsedAt: number | null;
lastRejectedAt: number | null;
lastRejectReason: string | null;
}
export class MeshCentralRegistry extends EventEmitter {
private static instance: MeshCentralRegistry | null = null;
private warnedMultiRow = false;
private constructor() { super(); }
public static getInstance(): MeshCentralRegistry {
if (!this.instance) this.instance = new MeshCentralRegistry();
return this.instance;
}
public static resetForTest(): void {
this.instance = null;
}
public upsert(material: MeshCentralMaterial): void {
const db = DatabaseService.getInstance().getDb();
const prior = this.getActive();
db.prepare(`
INSERT INTO mesh_centrals (
central_instance_id, central_api_url, callback_jwt,
jwt_issued_at, jwt_expires_at, last_bootstrap_at,
last_used_at, last_rejected_at, last_reject_reason
) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, NULL)
ON CONFLICT(central_instance_id) DO UPDATE SET
central_api_url = excluded.central_api_url,
callback_jwt = excluded.callback_jwt,
jwt_issued_at = excluded.jwt_issued_at,
jwt_expires_at = excluded.jwt_expires_at,
last_bootstrap_at = excluded.last_bootstrap_at
`).run(
material.centralInstanceId,
material.centralApiUrl,
material.callbackJwt,
material.jwtIssuedAt,
material.jwtExpiresAt,
Date.now(),
);
const isInstanceChange = prior && prior.centralInstanceId !== material.centralInstanceId;
if (isInstanceChange) {
this.emit('central-instance-changed', {
previousInstanceId: prior!.centralInstanceId,
newInstanceId: material.centralInstanceId,
});
}
this.emit('central-bootstrap', { centralInstanceId: material.centralInstanceId });
}
public getActive(): MeshCentralRow | null {
const db = DatabaseService.getInstance().getDb();
const rows = db.prepare(`
SELECT * FROM mesh_centrals ORDER BY last_bootstrap_at DESC
`).all() as Array<{
central_instance_id: string;
central_api_url: string;
callback_jwt: string;
jwt_issued_at: number;
jwt_expires_at: number;
last_bootstrap_at: number;
last_used_at: number | null;
last_rejected_at: number | null;
last_reject_reason: string | null;
}>;
if (rows.length === 0) return null;
if (rows.length > 1 && !this.warnedMultiRow) {
console.warn(`[MeshCentralRegistry] multiple central rows present (${rows.length}), multi-central unsupported in v0.79`);
this.warnedMultiRow = true;
}
const r = rows[0];
return {
centralInstanceId: r.central_instance_id,
centralApiUrl: r.central_api_url,
callbackJwt: r.callback_jwt,
jwtIssuedAt: r.jwt_issued_at,
jwtExpiresAt: r.jwt_expires_at,
lastBootstrapAt: r.last_bootstrap_at,
lastUsedAt: r.last_used_at,
lastRejectedAt: r.last_rejected_at,
lastRejectReason: r.last_reject_reason,
};
}
public clearForInstance(centralInstanceId: string): void {
DatabaseService.getInstance().getDb()
.prepare(`DELETE FROM mesh_centrals WHERE central_instance_id = ?`)
.run(centralInstanceId);
}
public markUsed(centralInstanceId: string): void {
DatabaseService.getInstance().getDb()
.prepare(`UPDATE mesh_centrals SET last_used_at = ? WHERE central_instance_id = ?`)
.run(Date.now(), centralInstanceId);
}
public markRejected(centralInstanceId: string, reason: string): void {
DatabaseService.getInstance().getDb()
.prepare(`UPDATE mesh_centrals SET last_rejected_at = ?, last_reject_reason = ? WHERE central_instance_id = ?`)
.run(Date.now(), reason, centralInstanceId);
}
}
+11 -113
View File
@@ -1,12 +1,9 @@
import { EventEmitter } from 'events';
import { createHash } from 'crypto';
import jwt from 'jsonwebtoken';
import WebSocket from 'ws';
import { MAX_FRAME_SIZE_BYTES } from '../pilot/protocol';
import { PilotTunnelBridge, type MeshTunnelHandle } from './PilotTunnelBridge';
import { PilotTunnelManager } from './PilotTunnelManager';
import { NodeRegistry } from './NodeRegistry';
import { DatabaseService } from './DatabaseService';
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
import { httpUrlToWs } from '../utils/wsUrl';
import { isDebugEnabled } from '../utils/debug';
@@ -29,15 +26,16 @@ import type { MeshActivityType } from './MeshService';
* Lifecycle:
* - `ensureBridge(nodeId)` dials if not connected; concurrent callers
* dedupe through an in-flight promise map.
* - A periodic check tears down bridges that have seen no active streams
* for `SENCHO_MESH_PROXY_TUNNEL_IDLE_MS` (default 5 minutes). Setting
* the env var to `0` disables idle close (tunnel persists until the
* remote drops it or central shuts down).
* - The bridge is a persistent bidirectional control-plane channel:
* central → peer `tcp_open` frames and peer → central `tcp_open_reverse`
* frames flow over the same WS. The default idle TTL is 0 (no idle
* close); `SENCHO_MESH_PROXY_TUNNEL_IDLE_MS` overrides the default for
* operators who still want stream-scoped tunnels.
* - Recent failures are cached for 60 seconds so a misconfigured remote
* does not cause a continuous redial storm; `MeshService.getStatus`
* consults the cache to surface a `reachableReason` to the UI.
*/
const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000;
const DEFAULT_IDLE_TTL_MS = 0;
const IDLE_CHECK_INTERVAL_MS = 60 * 1000;
const HANDSHAKE_TIMEOUT_MS = 15_000;
const FAILURE_CACHE_TTL_MS = 60 * 1000;
@@ -148,6 +146,11 @@ export class MeshProxyTunnelDialer extends EventEmitter {
return this.bridges.get(nodeId) ?? null;
}
/** True when a dial for this node is currently in flight. */
public isDialing(nodeId: number): boolean {
return this.inflight.has(nodeId);
}
public getRecentFailure(nodeId: number): DialFailure | null {
const entry = this.recentFailures.get(nodeId);
if (!entry) return null;
@@ -262,18 +265,6 @@ export class MeshProxyTunnelDialer extends EventEmitter {
return null;
}
// Capability-gated mesh_handshake: pushes a signed `mesh_tunnel` JWT
// and the central origin so the peer can dial central back for
// reverse cross-fleet TCP streams (R2). Skipped silently when the
// peer lacks the capability or `SENCHO_PRIMARY_URL` is unset so
// legacy peers keep their forward-only TCP path untouched.
await this.maybeSendBootstrap(nodeId, ws);
if (this.stopped) {
try { ws.close(1001, 'dialer shutdown'); } catch { /* ignore */ }
return null;
}
const bridge = new PilotTunnelBridge(nodeId, ws);
try {
await bridge.start();
@@ -451,47 +442,6 @@ export class MeshProxyTunnelDialer extends EventEmitter {
this.redialTimers.set(nodeId, timer);
}
/**
* If the peer advertises `mesh_proxy_callback_bootstrap` and central has
* a canonical origin (`SENCHO_PRIMARY_URL`), mint a `mesh_tunnel`-scoped
* JWT bound to the peer's `api_token` fingerprint and push it as the
* first text frame on the freshly-opened WS. The peer's
* `meshProxyTunnel` first-frame state machine persists the bootstrap
* material before yielding to TCP traffic. Every other path is a silent
* skip so legacy peers continue to receive raw TCP frames immediately.
*/
private async maybeSendBootstrap(nodeId: number, ws: WebSocket): Promise<void> {
const canonicalOrigin = (process.env.SENCHO_PRIMARY_URL ?? '').replace(/\/+$/, '');
if (!canonicalOrigin) return;
let meta;
try { meta = await NodeRegistry.getInstance().fetchMetaForNode(nodeId); }
catch { return; }
if (!meta?.online) return;
if (!meta.capabilities?.includes('mesh_proxy_callback_bootstrap')) return;
const db = DatabaseService.getInstance();
const node = db.getNode(nodeId);
if (!node || node.type !== 'remote' || node.mode !== 'proxy' || !node.api_token) return;
const authSecret = db.getGlobalSettings().auth_jwt_secret;
const centralInstanceId = db.getSystemState('instance_id');
if (!authSecret || !centralInstanceId) return;
const built = buildHandshakeFrame(nodeId, node.api_token, canonicalOrigin, centralInstanceId, authSecret);
try {
ws.send(JSON.stringify(built.frame));
void this.logActivity(nodeId, 'open.ok', {
bootstrapSent: true,
centralApiUrl: redactSensitiveText(canonicalOrigin),
kid: 'v1',
jwtExpiresAt: built.expSec,
});
} catch (err) {
console.warn(`[MeshProxyDialer] mesh_handshake send failed for node ${nodeId}: ${(err as Error).message}`.replace(/[\n\r]/g, ''));
}
}
private async logActivity(
nodeId: number,
event: ProxyTunnelEvent,
@@ -517,58 +467,6 @@ export class MeshProxyTunnelDialer extends EventEmitter {
}
}
interface HandshakeFrame {
t: 'mesh_handshake';
v: 1;
peerNodeId: number;
centralInstanceId: string;
centralApiUrl: string;
meshTunnelJwt: string;
jwtExpiresAt: number;
}
/**
* Build the mesh_handshake frame and the associated JWT. Pure helper so the
* dialer's `maybeSendBootstrap` stays under the 30-line ceiling.
*/
function buildHandshakeFrame(
nodeId: number,
apiToken: string,
canonicalOrigin: string,
centralInstanceId: string,
authSecret: string,
): { frame: HandshakeFrame; expSec: number } {
const peerTokenFp = createHash('sha256').update(apiToken).digest('hex').slice(0, 16);
const nowSec = Math.floor(Date.now() / 1000);
const expSec = nowSec + 365 * 24 * 3600;
const meshTunnelJwt = jwt.sign(
{
sub: String(nodeId),
iss: centralInstanceId,
aud: canonicalOrigin,
scope: 'mesh_tunnel',
iat: nowSec,
exp: expSec,
kid: 'v1',
peer_token_fp: peerTokenFp,
},
authSecret,
{ algorithm: 'HS256' },
);
return {
frame: {
t: 'mesh_handshake',
v: 1,
peerNodeId: nodeId,
centralInstanceId,
centralApiUrl: canonicalOrigin,
meshTunnelJwt,
jwtExpiresAt: expSec,
},
expSec,
};
}
function classifyDialError(err: unknown): { code: DialFailureCode; message: string } {
const message = sanitizeForLog((err as Error).message || String(err));
const httpStatus = (err as Error & { httpStatus?: number }).httpStatus;
+107 -109
View File
@@ -4,7 +4,7 @@ import fs from 'fs/promises';
import { EventEmitter } from 'events';
import * as YAML from 'yaml';
import { ComposeService } from './ComposeService';
import { DatabaseService } from './DatabaseService';
import { DatabaseService, type NodeMode } from './DatabaseService';
import DockerController from './DockerController';
import { FileSystemService } from './FileSystemService';
import { LicenseService } from './LicenseService';
@@ -14,7 +14,6 @@ import { NodeRegistry } from './NodeRegistry';
import { PilotTunnelManager } from './PilotTunnelManager';
import { MeshProxyTunnelDialer, type DialFailureCode } from './MeshProxyTunnelDialer';
import { generateOverrideYaml, MeshAlias, SENCHO_MESH_NETWORK } from './MeshComposeOverride';
import { disableCapability, enableCapability } from './CapabilityRegistry';
import { lookupContainerIp } from '../mesh/containerLookup';
import { sanitizeForLog } from '../utils/safeLog';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
@@ -97,7 +96,7 @@ export type MeshActivityType =
| 'forwarder.listen' | 'forwarder.unlisten' | 'forwarder.error'
| 'proxy-tunnel.open.ok' | 'proxy-tunnel.open.fail' | 'proxy-tunnel.close'
| 'mesh.proxy_tunnel.identify'
| 'mesh_handshake.received';
| 'mesh.reconcile.fail';
export interface MeshActivityEvent {
ts: number;
@@ -148,13 +147,28 @@ export interface MeshRegenSummary {
* - `pilot`: a remote with a pilot agent. Live-tunnel state is captured
* separately in `pilotConnected`.
* - `proxy`: a remote that central reaches via the long-lived api_token.
* Mesh tunnels are opened on demand by `MeshProxyTunnelDialer`; the
* operator sees no badge while the configuration is sound.
* Central maintains a persistent bidirectional WS to each mesh-enabled
* proxy peer, reconciled periodically; the operator sees no badge while
* the bridge is up.
* - `unreachable`: configuration or runtime problem keeps mesh traffic
* from flowing. `reachableReason` carries an actionable hint.
*/
export type MeshReachableMode = 'local' | 'pilot' | 'proxy' | 'unreachable';
/**
* State of the peer→central reverse path. The forward WS to a proxy-mode
* peer is bidirectional; peer→central traffic flows over the same WS via
* `tcp_open_reverse`. This discriminator surfaces whether that bridge is
* currently usable so the Routing tab can show a transient pill while the
* dialer is reconnecting.
* - `connected`: forward WS is open; peer can dispatch reverse streams.
* - `connecting`: dial in flight; transient.
* - `unavailable`: no bridge and no dial in flight (peer just rebooted, or
* last dial cached a failure).
* - `not_applicable`: not a proxy-mode peer, or mesh disabled on this node.
*/
export type MeshReverseCallbackStatus = 'connected' | 'connecting' | 'unavailable' | 'not_applicable';
export interface MeshNodeStatus {
nodeId: number;
nodeName: string;
@@ -174,6 +188,8 @@ export interface MeshNodeStatus {
reachableMode: MeshReachableMode;
/** Short, operator-facing reason when `reachableMode === 'unreachable'`. Null otherwise. */
reachableReason: string | null;
/** Peer→central reverse path state. `not_applicable` for non-proxy peers. */
reverseCallbackStatus: MeshReverseCallbackStatus;
optedInStacks: string[];
activeStreamCount: number;
}
@@ -245,6 +261,7 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
private activity: MeshActivityEvent[] = [];
private activeStreams = new Map<number, ActiveStreamRecord>();
private aliasRefreshTimer?: NodeJS.Timeout;
private bridgeReconcileTimer?: NodeJS.Timeout;
private routeErrorMap = new Map<string, { ts: number; message: string }>();
private routeLatencyMap = new Map<string, number>();
private activityListeners = new Set<(e: MeshActivityEvent) => void>();
@@ -335,8 +352,6 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
this.selfCentralNodeId = this.resolveSelfCentralNodeId();
this.maybeWarnUnsetPrimaryUrl();
await this.setupMeshNetwork();
try {
await this.refreshAliasCache();
@@ -355,10 +370,12 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
});
}
await this.regenerateAllOverrides();
// Trigger 3: proactively re-establish central->peer bridges so any
// peer that is online and capable receives bootstrap material on
// startup. Fire-and-forget so start() does not block on remote I/O.
void this.proactiveBootstrapFanout();
// Proactively dial every mesh-enabled proxy peer so the forward WS
// (which also carries peer→central reverse traffic) is up before any
// user request hits it. Fire-and-forget so start() does not block on
// remote I/O.
void this.proactiveBridgeFanout();
this.startBridgeReconcileLoop();
this.aliasRefreshTimer = setInterval(() => {
void (async () => {
try {
@@ -388,49 +405,25 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
clearInterval(this.aliasRefreshTimer);
this.aliasRefreshTimer = undefined;
}
this.stopBridgeReconcileLoop();
await this.forwarder.shutdown();
}
/**
* Operator-facing preflight: if `SENCHO_PRIMARY_URL` is unset at
* startup and the central has at least one mesh-enabled proxy-mode
* node, warn that the Task 8 capability-gated handshake will fall
* back to an inferred origin for the callback bootstrap. The matching
* fail-safe in `MeshProxyTunnelDialer` silently skips the bootstrap
* when the env is unset; this warn makes the consequence visible.
* Walk every mesh-enabled proxy-mode peer and call
* `MeshProxyTunnelDialer.ensureBridge(nodeId)` on each. The bridge is
* the persistent bidirectional control-plane channel: central→peer
* `tcp_open` and peer→central `tcp_open_reverse` both flow over the
* same WS. Bounded concurrency 4 with a 250 ms stagger; failures are
* logged and never abort the fan-out. Called both at startup and on
* every reconcile tick. `ensureBridge` short-circuits on already-open
* bridges, so steady-state cost is one Map lookup per peer.
*/
private maybeWarnUnsetPrimaryUrl(): void {
if (process.env.SENCHO_PRIMARY_URL) return;
const row = DatabaseService.getInstance().getDb().prepare(`
SELECT COUNT(*) as n FROM nodes
WHERE type='remote' AND mode='proxy' AND mesh_enabled=1
`).get() as { n: number };
if (row.n > 0) {
console.warn(
'[Mesh] SENCHO_PRIMARY_URL is unset; mesh callback bootstrap will use ' +
'inferred origin. Set SENCHO_PRIMARY_URL to make peer-initiated mesh ' +
'callbacks robust against reverse-proxy edge cases.'
);
}
}
/**
* Trigger 3: at central startup, walk every mesh-enabled proxy-mode
* peer that already has at least one `mesh_stacks` row and call
* `MeshProxyTunnelDialer.ensureBridge(nodeId)`. The dialer's
* capability-gated handshake then mints and ships bootstrap material
* to peers that just came online or just got upgraded to a build that
* advertises `mesh_proxy_callback_bootstrap`. Bounded concurrency 4
* with a 250ms stagger; failures are logged and never abort the
* fan-out.
*/
private async proactiveBootstrapFanout(): Promise<void> {
private async proactiveBridgeFanout(): Promise<void> {
const rows = DatabaseService.getInstance().getDb().prepare(`
SELECT DISTINCT n.id AS id
FROM nodes n
INNER JOIN mesh_stacks ms ON ms.node_id = n.id
WHERE n.type = 'remote' AND n.mode = 'proxy' AND n.mesh_enabled = 1
ORDER BY n.id
SELECT id FROM nodes
WHERE type = 'remote' AND mode = 'proxy' AND mesh_enabled = 1
ORDER BY id
`).all() as Array<{ id: number }>;
const queue = rows.map((r) => r.id);
@@ -445,8 +438,8 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
this.logActivity({
source: 'mesh', level: 'warn', type: 'proxy-tunnel.open.fail',
nodeId,
message: `boot proactive bootstrap failed: ${sanitizeForLog((err as Error).message)}`,
details: { trigger: 'startup_fanout' },
message: `proxy-tunnel reconcile dial failed: ${sanitizeForLog((err as Error).message)}`,
details: { trigger: 'reconcile' },
});
}
await new Promise((r) => setTimeout(r, 250));
@@ -456,6 +449,35 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
await Promise.all(Array.from({ length: workerCount }, () => worker()));
}
/**
* Schedule the proxy-tunnel reconcile tick. Interval is overridable via
* `SENCHO_MESH_RECONCILE_INTERVAL_MS` (default 60_000 ms) so an operator
* can tune the peer-reboot detection window. Idempotent: a second call
* is a no-op while the timer is live.
*/
private startBridgeReconcileLoop(): void {
if (this.bridgeReconcileTimer) return;
const raw = process.env.SENCHO_MESH_RECONCILE_INTERVAL_MS;
const parsed = raw === undefined ? Number.NaN : Number(raw);
const intervalMs = Number.isFinite(parsed) && parsed >= 1000 ? parsed : 60_000;
this.bridgeReconcileTimer = setInterval(() => {
void this.proactiveBridgeFanout().catch((err) => {
this.logActivity({
source: 'mesh', level: 'error', type: 'mesh.reconcile.fail',
message: `bridge reconcile threw: ${sanitizeForLog((err as Error).message)}`,
});
});
}, intervalMs);
this.bridgeReconcileTimer.unref?.();
}
private stopBridgeReconcileLoop(): void {
if (this.bridgeReconcileTimer) {
clearInterval(this.bridgeReconcileTimer);
this.bridgeReconcileTimer = undefined;
}
}
public getSenchoIp(): string | null {
return this.senchoIp;
}
@@ -481,7 +503,6 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
/**
* Single recording path for every mesh-setup failure. Keeps the legacy
* `networkSetupError` string in sync, sets the typed `dataPlaneStatus`,
* strips `mesh_proxy_callback_bootstrap` from advertised capabilities,
* and emits a `mesh.disable` activity entry. Callers pass `level: 'warn'`
* for expected conditions (`not_in_docker` in dev mode) and `'error'` for
* real failures.
@@ -500,7 +521,6 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
this.networkSetupError = message;
this.dataPlaneStatus = { ok: false, reason, message, subnet };
this.senchoIp = null;
disableCapability('mesh_proxy_callback_bootstrap');
this.logActivity({
source: 'mesh',
level,
@@ -580,7 +600,6 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
this.networkSetupError = null;
this.dataPlaneStatus = { ok: true, reason: 'ok', message: null, subnet };
enableCapability('mesh_proxy_callback_bootstrap');
}
/**
@@ -856,15 +875,14 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
source: 'mesh', level: 'info', type: 'mesh.enable',
nodeId, message: `mesh enabled on node ${nodeId}`,
});
// Trigger 1: proactive bootstrap. If the peer is a proxy-mode remote,
// dial the mesh callback bridge immediately so the capability-gated
// handshake can ship `mesh_handshake` material without waiting for
// the next forwarder dial. Fire-and-forget; failures are logged by
// the dialer.
// When mesh is enabled on a proxy peer, dial the persistent bridge
// immediately so the next forward (or peer-initiated reverse)
// request has the WS already up. Fire-and-forget; failures are
// logged by the dialer.
const node = DatabaseService.getInstance().getNode(nodeId);
if (node && node.type === 'remote' && node.mode === 'proxy') {
void MeshProxyTunnelDialer.getInstance().ensureBridge(nodeId).catch((err) => {
console.warn(`[Mesh] proactive bootstrap on mesh-enable failed for node ${nodeId}: ${(err as Error).message}`);
console.warn(`[Mesh] proxy-tunnel dial on mesh-enable failed for node ${nodeId}: ${(err as Error).message}`);
});
}
}
@@ -1812,59 +1830,23 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
message: `cross-node dispatch to ${target.alias} on node ${target.nodeId}`,
});
// Peer-side recovery: when this Sencho is acting as a proxy-mode peer
// (mesh_centrals has a row from a prior bootstrap) and the
// reverseDialer is not currently installed, the inbound bridge from
// central is either cold-start (peer just rebooted) or has been torn
// down (idle close, central restart). Kick the symmetric-dial path
// so the peer re-opens its callback WS to central before attempting
// the cross-node dispatch.
//
// Central instances never have a mesh_centrals row (central is not a
// peer of itself), so this branch is correctly skipped on central.
// Central falls straight through to dialMeshTcpStream which uses its
// own PilotTunnelManager + MeshProxyTunnelDialer to reach the target
// peer. Without this gate, central enters the branch on every
// forward dispatch, finds no session, and destroys the inbound
// socket with route.resolve.fail forward-from-peer no_session.
if (!this.reverseDialer) {
const { MeshCentralRegistry } = await import('./MeshCentralRegistry');
const isProxyPeer = MeshCentralRegistry.getInstance().getActive() !== null;
if (isProxyPeer) {
try {
const { PeerToCentralMeshSessionDialer } = await import('./PeerToCentralMeshSessionDialer');
const session = await PeerToCentralMeshSessionDialer.getInstance().ensureSession();
if (!session) {
this.logActivity({
source: 'mesh', level: 'warn', type: 'route.resolve.fail',
nodeId: target.nodeId, alias: target.alias,
message: `peer cross-node dispatch failed: no central callback session available`,
details: { direction: 'forward-from-peer', reason: 'no_session' },
});
try { src.destroy(); } catch { /* ignore */ }
return;
}
} catch (err) {
this.logActivity({
source: 'mesh', level: 'warn', type: 'route.resolve.fail',
nodeId: target.nodeId, alias: target.alias,
message: `peer cross-node dispatch failed: bootstrap threw`,
details: { direction: 'forward-from-peer', reason: 'bootstrap_threw' },
});
try { src.destroy(); } catch { /* ignore */ }
return;
}
}
}
const tcpStream = await this.dialMeshTcpStream(target);
if (!tcpStream) {
// Three failure shapes on this path:
// - central with reverseDialer somehow installed (unusual; relay edge case)
// - central without reverseDialer (normal): ensureBridge could not reach the target peer
// - peer side: target nodeId is central's id, which the peer's NodeRegistry does
// not know as a remote proxy target, so ensureBridge returns null. The actionable
// condition is "central has not dialed the bridge yet"; tell the operator.
const targetIsLocalKnownRemote = NodeRegistry.getInstance().getNode(target.nodeId)?.type === 'remote';
const message = this.reverseDialer
? `cannot open reverse mesh stream to node ${target.nodeId}`
: targetIsLocalKnownRemote
? `no mesh tunnel reachable for node ${target.nodeId}`
: `peer cross-node dispatch deferred: waiting for central to dial the reverse bridge`;
this.logActivity({
source: 'pilot', level: 'error', type: 'tunnel.fail',
nodeId: target.nodeId, alias: target.alias,
message: this.reverseDialer
? `cannot open reverse mesh stream to node ${target.nodeId}`
: `no mesh tunnel reachable for node ${target.nodeId}`,
nodeId: target.nodeId, alias: target.alias, message,
});
try { src.destroy(); } catch { /* ignore */ }
return;
@@ -2148,12 +2130,14 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
const localNodeId = NodeRegistry.getInstance().getDefaultNodeId();
const localListening = this.isLocalForwarderActive();
const ptm = PilotTunnelManager.getInstance();
const dialer = MeshProxyTunnelDialer.getInstance();
return nodes.map((node) => {
const reach = this.computeReachable(node, localNodeId);
const meshEnabled = db.getNodeMeshEnabled(node.id);
return {
nodeId: node.id,
nodeName: node.name,
enabled: db.getNodeMeshEnabled(node.id),
enabled: meshEnabled,
localForwarderListening: node.id === localNodeId ? localListening : null,
// `pilotConnected` stays at its original meaning: a pilot
// tunnel is currently registered for this node. The
@@ -2163,12 +2147,26 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
pilotConnected: node.type !== 'remote' || ptm.hasActiveTunnel(node.id),
reachableMode: reach.mode,
reachableReason: reach.reason,
reverseCallbackStatus: this.computeReverseCallbackStatus(node, meshEnabled, dialer),
optedInStacks: db.listMeshStacks(node.id).map((s) => s.stack_name),
activeStreamCount: this.activeStreams.size,
};
});
}
private computeReverseCallbackStatus(
node: { id: number; type: 'local' | 'remote'; mode: NodeMode },
meshEnabled: boolean,
dialer: MeshProxyTunnelDialer,
): MeshReverseCallbackStatus {
if (node.type !== 'remote' || node.mode !== 'proxy' || !meshEnabled) {
return 'not_applicable';
}
if (dialer.hasBridge(node.id)) return 'connected';
if (dialer.isDialing(node.id)) return 'connecting';
return 'unavailable';
}
/** True when the local Sencho's forwarder is started and bound to at least one alias port. */
private isLocalForwarderActive(): boolean {
return this.started && this.forwarder.getListenerPorts().length > 0;
@@ -1,276 +0,0 @@
/**
* PeerToCentralMeshSessionDialer: peer-side counterpart to the central
* ingress at `/api/mesh/proxy-tunnel-from-peer`. Reads cached central
* material from `MeshCentralRegistry`, opens a WebSocket to central
* carrying the bootstrapped JWT in the Authorization header, and on a
* successful upgrade installs a local `PilotTunnelBridge` whose loopback
* URL the local proxy can target as if central had dialed us.
*
* Failure handling branches on the central's machine-readable reason
* code returned in the 401 body: terminal codes (stale,
* signature_invalid, fingerprint, node_deleted, instance_mismatch,
* audience_mismatch) clear the cache so we stop dialing with a known-bad
* credential. Transient codes (clock_skew, mode_mismatch) keep the cache
* but record the rejection so an operator can see why we stalled.
* HTTP 404 (older central without the peer ingress endpoint) keeps the
* cache and arms a longer backoff window so we do not hammer the remote.
*
* Process-local rate limit: at most 5 dial attempts per 60s window.
* Concurrency: a single in-flight promise is shared across callers so
* parallel `ensureSession()` calls coalesce into one dial.
*/
import { EventEmitter } from 'events';
import WebSocket from 'ws';
import {
MAX_FRAME_SIZE_BYTES,
decodeBinaryFrame,
decodeJsonFrame,
wsDataToBuffer,
wsDataToString,
} from '../pilot/protocol';
import { MeshCentralRegistry } from './MeshCentralRegistry';
import {
attachTcpStreamSwitchboard,
resolveByComposeLabels,
type TcpStreamSwitchboard,
type ReverseTcpStreamHandle,
} from '../mesh/tcpStreamSwitchboard';
import { PilotMetrics } from './PilotMetrics';
import { httpUrlToWs } from '../utils/wsUrl';
import { sanitizeForLog } from '../utils/safeLog';
interface SwitchboardReverseDialer {
openMeshTcpStream(target: { nodeId: number; stack: string; service: string; port: number }): ReverseTcpStreamHandle | null;
}
const HANDSHAKE_TIMEOUT_MS = 15_000;
const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_MAX = 5;
const ENDPOINT_UNAVAILABLE_BACKOFF_MS = 5 * 60_000;
const CALLBACK_PATH = '/api/mesh/proxy-tunnel-from-peer';
/**
* Reasons that indicate the cached central material is permanently bad
* (wrong secret, wrong fingerprint, node removed on central, etc.) and
* should be evicted so the next mesh-enable cycle remints fresh
* material. `endpoint_not_found` is explicitly NOT in this set because
* a 404 typically means "central is older than the peer ingress",
* which is recoverable by waiting.
*/
const REJECT_REASONS_CLEAR = new Set([
'stale',
'token_fingerprint_mismatch',
'node_deleted',
'instance_mismatch',
'signature_invalid',
'audience_mismatch',
'unknown',
]);
interface DialError extends Error {
reason?: string;
httpStatus?: number;
}
export class PeerToCentralMeshSessionDialer extends EventEmitter {
private static instance: PeerToCentralMeshSessionDialer | null = null;
private currentSession: TcpStreamSwitchboard | null = null;
private currentWs: WebSocket | null = null;
private inflight: Promise<TcpStreamSwitchboard | null> | null = null;
private recentDials: number[] = [];
private endpointUnavailableUntil = 0;
private constructor() { super(); }
public static getInstance(): PeerToCentralMeshSessionDialer {
if (!this.instance) this.instance = new PeerToCentralMeshSessionDialer();
return this.instance;
}
public static resetForTest(): void {
if (this.instance) {
try { this.instance.currentSession?.cleanup('test reset'); } catch { /* ignore */ }
try { this.instance.currentWs?.close(1000, 'test reset'); } catch { /* ignore */ }
}
this.instance = null;
}
public hasSession(): boolean {
return this.currentSession !== null;
}
public async ensureSession(): Promise<TcpStreamSwitchboard | null> {
if (this.currentSession) return this.currentSession;
if (Date.now() < this.endpointUnavailableUntil) return null;
if (this.isRateLimited()) return null;
if (this.inflight) return this.inflight;
this.inflight = this.dial().finally(() => { this.inflight = null; });
return this.inflight;
}
private isRateLimited(): boolean {
const now = Date.now();
this.recentDials = this.recentDials.filter((ts) => now - ts < RATE_LIMIT_WINDOW_MS);
return this.recentDials.length >= RATE_LIMIT_MAX;
}
private async dial(): Promise<TcpStreamSwitchboard | null> {
const material = MeshCentralRegistry.getInstance().getActive();
if (!material) return null;
this.recentDials.push(Date.now());
const wsUrl = httpUrlToWs(material.centralApiUrl) + CALLBACK_PATH;
const ws = new WebSocket(wsUrl, {
headers: { Authorization: `Bearer ${material.callbackJwt}` },
handshakeTimeout: HANDSHAKE_TIMEOUT_MS,
maxPayload: MAX_FRAME_SIZE_BYTES,
});
try {
await this.awaitOpen(ws);
} catch (err) {
ws.on('error', () => { /* swallow tail */ });
try { ws.close(); } catch { /* ignore */ }
this.handleDialFailure(err, material.centralInstanceId);
return null;
}
return this.attachSwitchboard(ws, material.centralInstanceId);
}
/**
* Wire the peer-initiated callback WS into the local MeshService. The
* R1-A2 design puts the peer end of the bridge in TcpStreamSwitchboard
* mode (peer multiplexes streams; central side runs PilotTunnelBridge).
* Without this wiring the WS opens cleanly but MeshService.reverseDialer
* stays null, so MeshService.dialMeshTcpStream falls through to
* PilotTunnelManager.ensureBridge(centralNodeId), which has no record
* for central on a proxy-mode peer (peers do not enroll central) and
* fails with proxy-tunnel.open.fail reason=no_target. End state matches
* the v0.78.1 reverse-direction failure even though the callback bridge
* is alive.
*
* Wiring symmetric to the central-initiated handler at
* `meshProxyTunnel.ts:115-163`:
* - attachTcpStreamSwitchboard with the same compose-label resolver
* - SwitchboardReverseDialer that delegates to switchboard.openReverseStream
* - setReverseDialer(localDialer, null) with CAS so a concurrent
* central-initiated tunnel does not get silently overwritten
* - ws.on('message') dispatches JSON/binary frames to the switchboard
* - ws.on('close'/'error') tears down switchboard + clears reverseDialer
*/
private async attachSwitchboard(ws: WebSocket, instanceId: string): Promise<TcpStreamSwitchboard | null> {
let switchboard: TcpStreamSwitchboard;
try {
switchboard = attachTcpStreamSwitchboard({
ws,
resolveTarget: resolveByComposeLabels,
logLabel: 'MeshCallback',
});
} catch (err) {
try { ws.close(1011, 'switchboard attach failed'); } catch { /* ignore */ }
PilotMetrics.increment('mesh_callback_dials_failed_total');
console.warn(`[PeerToCentralMeshSessionDialer] attach failed: ${sanitizeForLog((err as Error).message)}`);
return null;
}
const { MeshService } = await import('./MeshService');
const meshService = MeshService.getInstance();
const localDialer: SwitchboardReverseDialer = {
openMeshTcpStream(target) {
return switchboard.openReverseStream(target);
},
};
const installed = meshService.setReverseDialer(localDialer, null);
if (!installed) {
console.warn('[PeerToCentralMeshSessionDialer] reverse dialer already installed; rejecting concurrent callback bridge');
switchboard.cleanup('reverse dialer already installed');
try { ws.close(1013, 'reverse dialer already installed'); } catch { /* ignore */ }
PilotMetrics.increment('mesh_callback_dials_failed_total');
return null;
}
const onMessage = (data: unknown, isBinary: boolean): void => {
try {
if (isBinary) {
const buf = wsDataToBuffer(data);
if (!buf) return;
switchboard.handleBinaryFrame(decodeBinaryFrame(buf));
return;
}
const text = wsDataToString(data);
if (text == null) return;
switchboard.handleJsonFrame(decodeJsonFrame(text));
} catch (err) {
console.warn(`[PeerToCentralMeshSessionDialer] malformed frame: ${sanitizeForLog((err as Error).message)}`);
}
};
let tornDown = false;
const teardown = (): void => {
if (tornDown) return;
tornDown = true;
ws.off('message', onMessage);
try { switchboard.cleanup('mesh callback bridge closed'); } catch { /* ignore */ }
meshService.setReverseDialer(null, localDialer);
if (this.currentSession === switchboard) this.currentSession = null;
if (this.currentWs === ws) this.currentWs = null;
};
ws.on('message', onMessage);
ws.once('close', teardown);
ws.once('error', (err) => {
console.warn(`[PeerToCentralMeshSessionDialer] ws error: ${sanitizeForLog(err.message)}`);
teardown();
});
this.currentSession = switchboard;
this.currentWs = ws;
PilotMetrics.increment('mesh_central_bootstraps_total');
MeshCentralRegistry.getInstance().markUsed(instanceId);
return switchboard;
}
private awaitOpen(ws: WebSocket): Promise<void> {
return new Promise<void>((resolve, reject) => {
const cleanup = (): void => {
ws.removeAllListeners('open');
ws.removeAllListeners('error');
ws.removeAllListeners('unexpected-response');
};
ws.once('open', () => { cleanup(); resolve(); });
ws.once('error', (err) => { cleanup(); reject(err); });
ws.once('unexpected-response', (_req, res) => {
cleanup();
let body = '';
res.on('data', (chunk: Buffer) => { body += chunk.toString('utf8'); });
res.on('end', () => reject(this.buildUpgradeError(res.statusCode, body)));
});
});
}
private buildUpgradeError(statusCode: number | undefined, body: string): DialError {
let reason = 'unknown';
try {
const parsed = JSON.parse(body) as { reason?: unknown };
if (typeof parsed.reason === 'string') reason = parsed.reason;
} catch { /* keep unknown */ }
if (statusCode === 404) reason = 'endpoint_not_found';
const err = new Error(`upgrade rejected: HTTP ${statusCode ?? 'unknown'} reason=${reason}`) as DialError;
err.reason = reason;
err.httpStatus = statusCode;
return err;
}
private handleDialFailure(err: unknown, instanceId: string): void {
const reason = (err as DialError).reason ?? 'unknown';
PilotMetrics.increment('mesh_callback_dials_failed_total');
if (REJECT_REASONS_CLEAR.has(reason)) {
MeshCentralRegistry.getInstance().clearForInstance(instanceId);
PilotMetrics.increment('mesh_callback_auth_failures_total');
} else {
MeshCentralRegistry.getInstance().markRejected(instanceId, reason);
if (reason === 'endpoint_not_found') {
this.endpointUnavailableUntil = Date.now() + ENDPOINT_UNAVAILABLE_BACKOFF_MS;
}
}
console.warn(`[PeerToCentralMeshSessionDialer] dial failed: ${sanitizeForLog(`reason=${reason}`)}`);
}
}
+1 -13
View File
@@ -28,16 +28,8 @@ export interface Counters {
proxy_bridges_total: number;
/** Failed proxy-tunnel dial attempts (all reasons). Pair with `proxy_bridges_total` for an attempt/success ratio; pair with `proxy_idle_closes` for retention. */
proxy_dials_failed: number;
/** Proxy-tunnel teardowns initiated by the dialer's idle sweep (zero active streams for the configured TTL). */
/** Proxy-tunnel teardowns initiated by the dialer's idle sweep (zero active streams for the configured TTL). Effectively 0 by default since the dialer no longer idle-closes the bridge. */
proxy_idle_closes: number;
/** Proxy bridges registered via the peer-initiated dial-back path (`/api/mesh/proxy-tunnel-from-peer`). Disjoint from `proxy_bridges_total`, which counts central-initiated dials. */
proxy_bridges_peer_initiated_total: number;
/** Successful peer-to-central dial-back sessions (counted on WS open + bridge start). Disjoint from `proxy_bridges_peer_initiated_total`, which is incremented by the central-side ingress at register time. Useful for operators investigating asymmetric counts (peer thinks it dialed, central never registered). */
mesh_central_bootstraps_total: number;
/** Peer-to-central dial-back attempts that did not complete a WS open. Covers transport errors, upgrade rejections, and bridge.start failures. */
mesh_callback_dials_failed_total: number;
/** Subset of `mesh_callback_dials_failed_total` where central responded 401 with a terminal reason code (stale, signature_invalid, ...) that caused the peer to clear its cached central material. */
mesh_callback_auth_failures_total: number;
}
const ZERO_COUNTERS: Counters = {
@@ -49,10 +41,6 @@ const ZERO_COUNTERS: Counters = {
proxy_bridges_total: 0,
proxy_dials_failed: 0,
proxy_idle_closes: 0,
proxy_bridges_peer_initiated_total: 0,
mesh_central_bootstraps_total: 0,
mesh_callback_dials_failed_total: 0,
mesh_callback_auth_failures_total: 0,
};
export const PILOT_METRICS_FLUSH_INTERVAL_MS = 1_000;
+8 -43
View File
@@ -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'