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) {