fix(mesh): proxy peer learns its central-namespace nodeId at tunnel upgrade (#1055)

Proxy-mode peers receive an alias overlay from central with nodeIds in
central's namespace, but had no source for their own nodeId in that
namespace. selfCentralNodeId fell back to the peer's local DB default
(always 1), and handleAccept falsely matched cross-node aliases
(nodeId=1, central's Local) against the peer's self-id. Cross-node
aliases routed to openSameNode and surfaced as route.resolve.denied.

Central now appends ?nodeId=<central-namespace-id> to the proxy-tunnel
WS URL. The peer's upgrade handler parses it with a strict integer
regex, installs into MeshService before the reverse dialer is
registered, and clears on disconnect. handleAccept resolves self-id in
priority order: proxy-tunnel install > SENCHO_ENROLL_TOKEN > local
default. A non-null overwrite to a different value warns loudly
(misconfigured deployment). Missing or malformed query param logs a
warn and proceeds, leaving reverse-direction dispatch undefined until
both sides are upgraded.

Adds 5 test cases: nodeId install + teardown, missing/malformed input
(including decimal, leading zeros, exponent, whitespace), overwrite
warn, and CAS-rejected reverse-dialer leaving the slot unchanged.
This commit is contained in:
Anso
2026-05-15 09:00:05 -04:00
committed by GitHub
parent 2e82eb44fe
commit 6185206323
5 changed files with 273 additions and 8 deletions
+35 -2
View File
@@ -48,14 +48,38 @@ export async function handleMeshProxyTunnel(req: IncomingMessage, socket: Duplex
return reject(socket, 404, 'Not Found');
}
// Central appends `?nodeId=<central-namespace-id>` so this peer can tag
// its own MeshService with the right nodeId for `handleAccept` dispatch.
// The value is unsigned, but the Bearer credential at the upgrade has
// already proven the caller is the upstream central (auth + scope gate
// in `upgradeHandler.ts`); the query param's trust ceiling is the
// token's trust ceiling.
// Strict regex (no parseInt) so `?nodeId=14.5`, `?nodeId=00014`,
// `?nodeId=1e2`, leading whitespace, etc. are rejected rather than
// silently truncated.
let peerNodeId: number | null = null;
try {
const parsed = new URL(req.url ?? '/', `http://${req.headers.host || 'localhost'}`);
const raw = parsed.searchParams.get('nodeId');
if (raw != null && /^[1-9][0-9]*$/.test(raw)) {
const n = Number(raw);
if (Number.isSafeInteger(n)) peerNodeId = n;
}
} catch {
// Malformed URL; treat as missing param.
}
if (peerNodeId === null) {
console.warn('[MeshProxy] proxy-tunnel upgrade missing or malformed nodeId query param; reverse-direction mesh dispatch will be undefined until central upgrades');
}
await new Promise<void>((resolve) => {
wss.handleUpgrade(req, socket as Parameters<typeof wss.handleUpgrade>[1], head, (ws) => {
void attachSwitchboard(ws).finally(resolve);
void attachSwitchboard(ws, peerNodeId).finally(resolve);
});
});
}
async function attachSwitchboard(ws: WebSocket): Promise<void> {
async function attachSwitchboard(ws: WebSocket, peerNodeId: number | null): Promise<void> {
let switchboard: TcpStreamSwitchboard | null = null;
let meshServiceCleanup: (() => void) | null = null;
@@ -87,8 +111,17 @@ async function attachSwitchboard(ws: WebSocket): Promise<void> {
switchboard = null;
return;
}
// Install central's view of this peer's nodeId so handleAccept
// dispatches cross-node aliases correctly. Done after setReverseDialer
// succeeds so a CAS-rejected tunnel does not leak nodeId state.
if (peerNodeId !== null) {
meshService.setProxyTunnelSelfCentralNodeId(peerNodeId);
}
meshServiceCleanup = () => {
meshService.setReverseDialer(null, localDialer);
if (peerNodeId !== null) {
meshService.setProxyTunnelSelfCentralNodeId(null);
}
};
} catch (err) {
if (isDebugEnabled()) {