mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user