From 6ac7da978c05e93b1239034b6f4ccdcfeb0f5256 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 10 May 2026 02:04:52 -0400 Subject: [PATCH] fix(mesh): push alias port map to pilot so reverse-direction forwarder binds listeners (#1021) * fix(mesh): apply D-1 pushed override on pilot deploys via file-presence fallback When isMeshStackEnabled returns false, ensureStackOverride now checks for an override file already on disk before returning null. On pilot nodes the mesh_stacks table is intentionally empty (opt-in state lives on central per the C-3 design), so the DB gate blocked the override that central had already pushed via applyLocalOverride. File-presence is safe as the fallback because removeOverrideFromNode sends a DELETE to the pilot when a stack is opted out, so a stale file cannot survive past opt-out. Also aligns removeStackOverride to use path.basename consistently with all other override-path construction sites in the same file. Adds two tests: pilot node with a pushed file returns the path; pilot node with no file returns null. * fix(mesh): push alias port map to pilot so reverse-direction forwarder binds listeners Pilots have no mesh_stacks rows by design (C-3), so refreshAliasCache() produced an empty aliasByPort map and syncForwarderListeners() bound zero ports. Reverse-direction TCP probes (pilot prober -> central alias) failed with connection refused at the OS level because no listener existed. Fix: extend the D-1 override push payload to include the full MeshGlobalAlias records (host + port + routing metadata). The pilot stores these in a new pilotAliasOverlay map keyed by stack name. refreshAliasCache() merges the overlay after the (empty) DB loop, populating aliasByPort correctly. syncForwarderListeners() then binds the alias ports immediately after the push completes. removeLocalOverride() clears the overlay entry and re-syncs on opt-out. The two populations (DB rows on central, pilotAliasOverlay on pilots) are mutually exclusive by the C-3 invariant, so the first-write-wins port collision policy in refreshAliasCache is safe. --- backend/src/routes/mesh.ts | 32 +++++++++++++++++++++++--- backend/src/services/MeshService.ts | 35 ++++++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/backend/src/routes/mesh.ts b/backend/src/routes/mesh.ts index f3f6d16f..e981a419 100644 --- a/backend/src/routes/mesh.ts +++ b/backend/src/routes/mesh.ts @@ -1,7 +1,7 @@ import { Router, type Request, type Response } from 'express'; import { DatabaseService } from '../services/DatabaseService'; import { NodeRegistry } from '../services/NodeRegistry'; -import { MeshError, MeshService, type MeshRegenSummary } from '../services/MeshService'; +import { MeshError, MeshService, type MeshGlobalAlias, type MeshRegenSummary } from '../services/MeshService'; import { requireAdmin, requireAdmiral } from '../middleware/tierGates'; import { sanitizeForLog } from '../utils/safeLog'; import { isValidStackName } from '../utils/validation'; @@ -113,6 +113,20 @@ meshRouter.get('/local-services/:stackName', async (req: Request, res: Response) const MAX_ALIASES_PER_PUSH = 1024; +function parsePortAlias(entry: unknown): MeshGlobalAlias | null { + const e = entry as Record; + const { host, nodeId, nodeName, stackName, serviceName, port } = e ?? {}; + if ( + typeof host !== 'string' || host.length === 0 || host.length > 253 || + typeof nodeId !== 'number' || + typeof nodeName !== 'string' || nodeName.length === 0 || + typeof stackName !== 'string' || stackName.length === 0 || + typeof serviceName !== 'string' || serviceName.length === 0 || + typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535 + ) return null; + return { host, nodeId, nodeName, stackName, serviceName, port }; +} + /** * Accepts a fleet-wide alias list from central and writes a mesh override * for the named stack onto THIS Sencho's local DATA_DIR. The pilot looks @@ -125,7 +139,7 @@ meshRouter.put('/local-override/:stackName', async (req: Request, res: Response) if (!requireAdmiral(req, res)) return; const stackName = req.params.stackName as string; if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; } - const body = req.body as { aliases?: unknown }; + const body = req.body as { aliases?: unknown; portAliases?: unknown }; if (!Array.isArray(body?.aliases)) { res.status(400).json({ error: 'Missing aliases array in body' }); return; } if (body.aliases.length > MAX_ALIASES_PER_PUSH) { res.status(413).json({ error: `Alias list exceeds ${MAX_ALIASES_PER_PUSH} entries` }); @@ -142,8 +156,20 @@ meshRouter.put('/local-override/:stackName', async (req: Request, res: Response) } aliases.push({ host }); } + const portAliases: MeshGlobalAlias[] = []; + if (Array.isArray(body?.portAliases)) { + if (body.portAliases.length > MAX_ALIASES_PER_PUSH) { + res.status(413).json({ error: `portAliases list exceeds ${MAX_ALIASES_PER_PUSH} entries` }); + return; + } + for (const entry of body.portAliases) { + const parsed = parsePortAlias(entry); + if (!parsed) { res.status(400).json({ error: 'Invalid portAliases entry' }); return; } + portAliases.push(parsed); + } + } try { - const written = await MeshService.getInstance().applyLocalOverride(stackName, aliases); + const written = await MeshService.getInstance().applyLocalOverride(stackName, aliases, portAliases); if (!written) { res.status(400).json({ error: 'Refused to write override (path validation failed)' }); return; } res.json({ ok: true, path: written }); } catch (err) { diff --git a/backend/src/services/MeshService.ts b/backend/src/services/MeshService.ts index d848874f..bdcaba7b 100644 --- a/backend/src/services/MeshService.ts +++ b/backend/src/services/MeshService.ts @@ -174,6 +174,11 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { private started = false; private aliasCache = new Map(); private aliasByPort = new Map(); + // Populated on pilot nodes via the D-1 override push. Central's + // db.listMeshStacks() is authoritative on central; pilots have no + // mesh_stacks rows (C-3 design), so the push payload carries the alias + // data they need to bind forwarder listeners for the reverse direction. + private pilotAliasOverlay = new Map(); private activity: MeshActivityEvent[] = []; private activeStreams = new Map(); private aliasRefreshTimer?: NodeJS.Timeout; @@ -656,7 +661,11 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { * network with its own subnet. Returns the absolute path on success * or null if path validation rejects the input. */ - public async applyLocalOverride(stackName: string, aliases: MeshAlias[]): Promise { + public async applyLocalOverride( + stackName: string, + aliases: MeshAlias[], + portAliases?: MeshGlobalAlias[], + ): Promise { if (!isValidStackName(stackName)) return null; if (!this.senchoIp) { throw new MeshError( @@ -699,6 +708,11 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { senchoIp: this.senchoIp, }); await fs.writeFile(file, yaml, 'utf8'); + if (portAliases && portAliases.length > 0) { + this.pilotAliasOverlay.set(stackName, portAliases); + await this.refreshAliasCache(); + await this.syncForwarderListeners(); + } return file; } @@ -713,6 +727,10 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { const file = path.resolve(dir, `${path.basename(stackName)}.override.yml`); if (!isPathWithinBase(file, dir)) return; try { await fs.unlink(file); } catch { /* ignore not-exist */ } + if (this.pilotAliasOverlay.delete(stackName)) { + await this.refreshAliasCache(); + await this.syncForwarderListeners(); + } } private async removeStackOverride(nodeId: number, stackName: string): Promise { @@ -834,6 +852,16 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { } } } + // Merge pilot overlay. Invariant: on central, pilotAliasOverlay is + // always empty (DB is authoritative); on pilots, db.listMeshStacks() + // returns empty (C-3), so the two populations are mutually exclusive + // and the first-write-wins portMap policy is safe. + for (const overlayAliases of this.pilotAliasOverlay.values()) { + for (const alias of overlayAliases) { + next.set(alias.host, alias); + if (!portMap.has(alias.port)) portMap.set(alias.port, alias); + } + } this.aliasCache = next; this.aliasByPort = portMap; } @@ -1036,12 +1064,13 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { return; } - const aliases: MeshAlias[] = Array.from(this.aliasCache.values()).map((a) => ({ host: a.host })); + const portAliases: MeshGlobalAlias[] = Array.from(this.aliasCache.values()); + const aliases: MeshAlias[] = portAliases.map((a) => ({ host: a.host })); const res = await this.proxyFetch( nodeId, 'PUT', `/api/mesh/local-override/${encodeURIComponent(stackName)}`, - { aliases }, + { aliases, portAliases }, 5_000, ); if (res.status === 404) {