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.
This commit is contained in:
Anso
2026-05-10 02:04:52 -04:00
committed by GitHub
parent d1c538c790
commit 6ac7da978c
2 changed files with 61 additions and 6 deletions
+29 -3
View File
@@ -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<string, unknown>;
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) {
+32 -3
View File
@@ -174,6 +174,11 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
private started = false;
private aliasCache = new Map<string, MeshGlobalAlias>();
private aliasByPort = new Map<number, MeshGlobalAlias>();
// 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<string, MeshGlobalAlias[]>();
private activity: MeshActivityEvent[] = [];
private activeStreams = new Map<number, ActiveStreamRecord>();
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<string | null> {
public async applyLocalOverride(
stackName: string,
aliases: MeshAlias[],
portAliases?: MeshGlobalAlias[],
): Promise<string | null> {
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<void> {
@@ -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) {