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
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { SystemSheet } from '@/components/ui/system-sheet';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { ConfirmModal } from '@/components/ui/modal';
import type { MeshStackEntry } from '@/types/mesh';
import { Loader2 } from 'lucide-react';
@@ -19,6 +20,7 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pendingStack, setPendingStack] = useState<string | null>(null);
const [confirmStack, setConfirmStack] = useState<MeshStackEntry | null>(null);
useEffect(() => {
if (!open) return;
@@ -40,7 +42,7 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged
return () => { cancelled = true; };
}, [open, nodeId]);
const toggle = async (stack: MeshStackEntry) => {
const performToggle = async (stack: MeshStackEntry) => {
setPendingStack(stack.name);
setError(null);
try {
@@ -54,10 +56,17 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged
setError(body.error || 'Port already claimed by another mesh stack');
return;
}
if (res.status === 503) {
const body = await res.json().catch(() => ({})) as { error?: string };
setError(body.error || 'Mesh data plane unavailable on this node');
return;
}
if (!res.ok) throw new Error(`status ${res.status}`);
setStacks((prev) => prev.map((s) => s.name === stack.name ? { ...s, optedIn: !stack.optedIn } : s));
onChanged();
toast.success(stack.optedIn ? 'Stack removed from mesh' : 'Stack added to mesh');
toast.success(stack.optedIn
? `${stack.name} removed from mesh, redeploying`
: `${stack.name} added to mesh, redeploying`);
} catch (err) {
setError((err as Error).message);
toast.error('Mesh update failed');
@@ -70,54 +79,85 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged
const meta = `${inMeshCount} of ${stacks.length} in mesh`;
return (
<SystemSheet
open={open}
onOpenChange={onOpenChange}
crumb={['Fleet', 'Mesh', nodeName]}
name={nodeName}
meta={meta}
size="sm"
>
<div className="space-y-4">
<p className="text-sm text-stat-subtitle leading-snug">
Adding a stack lets its services be reached from other meshed stacks by hostname.
Toggling a stack redeploys it to refresh hostnames.
</p>
<>
<SystemSheet
open={open}
onOpenChange={onOpenChange}
crumb={['Fleet', 'Mesh', nodeName]}
name={nodeName}
meta={meta}
size="sm"
>
<div className="space-y-4">
<p className="text-sm text-stat-subtitle leading-snug">
Adding a stack lets its services be reached from other meshed stacks by hostname.
Toggling a stack triggers a redeploy on its node so the routing override applies.
</p>
{loading && (
<div className="flex items-center gap-2 text-stat-subtitle text-sm">
<Loader2 className="w-4 h-4 animate-spin" /> Loading stacks
</div>
)}
{error && (
<div className="rounded border border-destructive/30 bg-destructive/10 p-2 text-xs text-destructive">
{error}
</div>
)}
{!loading && stacks.length === 0 && (
<div className="text-sm text-stat-subtitle">No stacks deployed on this node yet.</div>
)}
<div className="space-y-2">
{stacks.map((stack) => (
<div key={stack.name} className="flex items-center justify-between rounded border border-card-border bg-card px-3 py-2">
<div className="flex items-center gap-3">
<Checkbox
id={`mesh-stack-${stack.name}`}
checked={stack.optedIn}
disabled={pendingStack === stack.name}
onCheckedChange={() => { void toggle(stack); }}
/>
<label htmlFor={`mesh-stack-${stack.name}`} className="text-sm font-mono">{stack.name}</label>
</div>
{pendingStack === stack.name && <Loader2 className="w-3 h-3 animate-spin text-stat-subtitle" />}
{stack.optedIn && pendingStack !== stack.name && (
<span className="text-[10px] leading-3 tracking-[0.18em] uppercase text-success/80 font-mono">in mesh</span>
)}
{loading && (
<div className="flex items-center gap-2 text-stat-subtitle text-sm">
<Loader2 className="w-4 h-4 animate-spin" /> Loading stacks
</div>
))}
)}
{error && (
<div className="rounded border border-destructive/30 bg-destructive/10 p-2 text-xs text-destructive">
{error}
</div>
)}
{!loading && stacks.length === 0 && (
<div className="text-sm text-stat-subtitle">No stacks deployed on this node yet.</div>
)}
<div className="space-y-2">
{stacks.map((stack) => (
<div key={stack.name} className="flex items-center justify-between rounded border border-card-border bg-card px-3 py-2">
<div className="flex items-center gap-3">
<span className="text-sm font-mono">{stack.name}</span>
{stack.optedIn && pendingStack !== stack.name && (
<span className="text-[10px] leading-3 tracking-[0.18em] uppercase text-success/80 font-mono">in mesh</span>
)}
</div>
{pendingStack === stack.name ? (
<Loader2 className="w-4 h-4 animate-spin text-stat-subtitle" />
) : (
<Button
size="sm"
variant={stack.optedIn ? 'outline' : 'default'}
onClick={() => setConfirmStack(stack)}
>
{stack.optedIn ? 'Remove from mesh' : 'Add to mesh'}
</Button>
)}
</div>
))}
</div>
</div>
</div>
</SystemSheet>
</SystemSheet>
<ConfirmModal
open={!!confirmStack}
onOpenChange={(o) => { if (!o) setConfirmStack(null); }}
variant={confirmStack?.optedIn ? 'destructive' : 'default'}
kicker={`Mesh / ${nodeName}`}
title={
confirmStack?.optedIn
? `Remove ${confirmStack.name} from mesh?`
: `Add ${confirmStack?.name ?? ''} to mesh?`
}
description={
confirmStack?.optedIn
? `${confirmStack.name} will be redeployed on ${nodeName} so its containers drop the mesh routing entries from /etc/hosts.`
: confirmStack
? `${confirmStack.name} will be redeployed on ${nodeName} so its containers pick up the mesh routing entries.`
: undefined
}
confirmLabel={confirmStack?.optedIn ? 'Remove and redeploy' : 'Add and redeploy'}
onConfirm={() => {
if (confirmStack) void performToggle(confirmStack);
setConfirmStack(null);
}}
onCancel={() => setConfirmStack(null)}
/>
</>
);
}