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
+62
View File
@@ -463,6 +463,68 @@ class DockerController {
return await this.docker.createNetwork(options);
}
/**
* Attach a container to a Docker network. Idempotent: if the container is
* already attached the call resolves silently. Optionally pins the
* container's IPv4 address inside the network so other services can use
* static `extra_hosts` entries against it.
*/
public async connectContainerToNetwork(
networkName: string,
containerId: string,
opts: { ipv4Address?: string } = {},
): Promise<void> {
const network = this.docker.getNetwork(networkName);
const payload: { Container: string; EndpointConfig?: { IPAMConfig?: { IPv4Address: string } } } = {
Container: containerId,
};
if (opts.ipv4Address) {
payload.EndpointConfig = { IPAMConfig: { IPv4Address: opts.ipv4Address } };
}
try {
await network.connect(payload);
} catch (err) {
if (DockerController.isAlreadyConnectedError(err)) return;
throw err;
}
}
/**
* Detach a container from a Docker network. Idempotent: if the container
* is not attached the call resolves silently.
*/
public async disconnectContainerFromNetwork(
networkName: string,
containerId: string,
): Promise<void> {
const network = this.docker.getNetwork(networkName);
try {
await network.disconnect({ Container: containerId, Force: true });
} catch (err) {
if (DockerController.isNotConnectedError(err)) return;
throw err;
}
}
private static isAlreadyConnectedError(err: unknown): boolean {
const e = err as { statusCode?: number; message?: string };
const msg = (e?.message || '').toLowerCase();
// Docker daemon returns 403 for several distinct cases (already
// attached, host-network containers, permission denied), so match the
// message body too rather than treating any 403 as idempotent success.
if (e?.statusCode === 403 && (msg.includes('already exists') || msg.includes('already attached'))) {
return true;
}
return msg.includes('already exists') || msg.includes('already attached');
}
private static isNotConnectedError(err: unknown): boolean {
const e = err as { statusCode?: number; message?: string };
if (e?.statusCode === 404) return true;
const msg = (e?.message || '').toLowerCase();
return msg.includes('is not connected') || msg.includes('no such container');
}
public async getRunningContainers() {
const containers = await this.docker.listContainers({ all: false });
return this.validateApiData<any[]>(containers);