feat(mesh): replace host-mode with shared sencho_mesh Docker network (#1009)

* feat(mesh): replace host-mode with shared sencho_mesh Docker network

Phase D of the mesh redesign: drop the operator's `network_mode: host`
requirement and the `host-gateway` extra_hosts pattern that did not work
on cloud iptables-restrictive distros (OCI, etc.) or Docker Desktop.

Each Sencho creates a shared `sencho_mesh` Docker bridge network on
boot (default subnet 172.30.0.0/24, override via SENCHO_MESH_SUBNET),
pins itself at `<network>+2`, and attaches every meshed user service to
the same bridge. Compose overrides now emit IP-based `extra_hosts` plus
a top-level `networks` block declaring `sencho_mesh` external.

Override delivery: central renders for local stacks; for remote stacks
it sends the fleet alias list to the remote's new `PUT /api/mesh/local-
override/:stackName` endpoint, which renders against the remote's OWN
local senchoIp and writes under its OWN DATA_DIR. Each node may use a
different subnet without coordination beyond the env var.

Opt-in / opt-out now trigger an automatic redeploy of the affected
stack via the existing deploy code path (local: ComposeService; remote:
HTTP POST through proxyFetch). The frontend opt-in sheet shows a
confirmation modal (ConfirmModal) before the mutation. Failed
redeploys emit both a mesh activity event and a durable audit-log row.

Hardening:
- Reserve port 1852 at opt-in (prevents user containers from racing
  the Sencho API listener).
- ensureMeshNetwork refuses to continue if `sencho_mesh` exists with a
  mismatched subnet rather than silently routing to the wrong IP.
- Idempotent network connect/disconnect helpers in DockerController.
- optInStack rolls back the DB row if the just-inserted stack's
  override push fails (no half-states surviving across calls).
- regenerateOverridesForNode runs in parallel and skips the just-
  pushed stack on opt-in.

Operator template: drop `network_mode: host`, restore
`ports: ["1852:1852"]`. Mesh now works identically on Linux LAN, OCI,
and Docker Desktop without firewall changes.

Docs: rewrite docs/features/sencho-mesh.mdx around the shared bridge
network, document SENCHO_MESH_SUBNET, surface the host-network-service
opt-in restriction, and cross-link with the Pilot Agent docs.

BREAKING CHANGE: the operator's `docker-compose.yml` no longer uses
`network_mode: host`. After upgrading, redeploy any meshed stacks once
so they pick up the new IP-based override and join `sencho_mesh`.

* fix(mesh): wrap stackName with path.basename in local-override fs ops

CodeQL flagged js/path-injection on the new applyLocalOverride and
removeLocalOverride methods because they are publicly reachable and
its data-flow model does not recognize isValidStackName /
isPathWithinBase as sanitizers. The validation IS sufficient (the
allowlist regex blocks path separators, the path-prefix check blocks
escape), but path.basename is a model CodeQL recognizes and is purely
defensive: for any input that already passes isValidStackName,
basename is the identity.
This commit is contained in:
Anso
2026-05-09 00:11:09 -04:00
committed by GitHub
parent ccad5c925b
commit 23bbee4f45
11 changed files with 1023 additions and 122 deletions
+67
View File
@@ -70,6 +70,69 @@ meshRouter.get('/local-services/:stackName', async (req: Request, res: Response)
}
});
const MAX_ALIASES_PER_PUSH = 1024;
/**
* 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
* up its own service names and uses its own static IP on `sencho_mesh`,
* so alias hostnames in user containers always resolve to the LOCAL
* Sencho IP on the deploying node. Always writes against the LOCAL
* Sencho's default node id.
*/
meshRouter.put('/local-override/:stackName', async (req: Request, res: Response): Promise<void> => {
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 };
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` });
return;
}
const aliases: { host: string }[] = [];
for (const entry of body.aliases) {
const host = (entry as { host?: unknown } | null | undefined)?.host;
if (typeof host !== 'string' || host.length === 0 || host.length > 253) {
// 253 octets is the DNS hostname ceiling. Defensive against a
// malicious or buggy central sending a multi-KB host string.
res.status(400).json({ error: 'Invalid alias entry' });
return;
}
aliases.push({ host });
}
try {
const written = await MeshService.getInstance().applyLocalOverride(stackName, aliases);
if (!written) { res.status(400).json({ error: 'Refused to write override (path validation failed)' }); return; }
res.json({ ok: true, path: written });
} catch (err) {
if (err instanceof MeshError && err.code === 'push_failed') {
res.status(503).json({ error: err.message, code: err.code });
return;
}
console.warn('[mesh] /local-override failed:', sanitizeForLog((err as Error).message));
res.status(500).json({ error: 'Failed to write local override' });
}
});
/**
* Delete a previously written local override. Mirror of the PUT endpoint;
* called by central when a stack is opted out so stale overrides do not
* linger on the deploying node.
*/
meshRouter.delete('/local-override/:stackName', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; }
try {
await MeshService.getInstance().removeLocalOverride(stackName);
res.json({ ok: true });
} catch (err) {
console.warn('[mesh] DELETE /local-override failed:', sanitizeForLog((err as Error).message));
res.status(500).json({ error: 'Failed to remove local override' });
}
});
meshRouter.get('/nodes/:nodeId/stacks', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
@@ -105,6 +168,10 @@ meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-in', async (req: Request,
res.status(409).json({ error: err.message, code: err.code });
return;
}
if (err instanceof MeshError && err.code === 'push_failed') {
res.status(503).json({ error: err.message, code: err.code });
return;
}
if (err instanceof MeshError) {
res.status(400).json({ error: err.message, code: err.code });
return;