fix(mesh): auto-fallback through candidate subnets when default overlaps (#1156)

The default mesh subnet 172.30.0.0/24 is fully contained in linuxserver/*
default networks (sonarr_default 172.30.0.0/16, etc.), so libnetwork
rejects the IPAM allocation with "Pool overlaps with other one on this
address space" on a typical homelab Docker host. The single hard-coded
default left first-run operators with a silently broken mesh.

MeshService.setupMeshNetwork now resolves the subnet via three paths:

1. Operator-explicit (SENCHO_MESH_SUBNET set): use that subnet, strict.
   Pre-existing sencho_mesh with a different subnet still raises
   subnet_mismatch.
2. Adopt-existing (env unset, sencho_mesh already on the daemon): adopt
   the existing subnet. Docker is the source of truth across restarts.
3. Candidate iteration (env unset, no existing network): walk
   172.30.0.0/24, 172.31.0.0/24, 10.42.0.0/24, 10.43.0.0/24 in order.
   First subnet Docker accepts wins. If every candidate overlaps,
   record subnet_overlap with a message naming every attempt.

The dashboard's Fleet Heartbeat card now surfaces the down state via a
compact banner above the per-node rows, plus a "mesh down" counter
suffix on the right of the title. The existing Routing-tab banner is
extracted into a shared MeshDataPlaneBanner component with tab and
card variants. Dashboard polling is gated on Admiral tier so non-paid
users do not fire the Admiral-only /mesh/status endpoint.

Six new tests in mesh-setup-error-classification cover: iterates past
first overlap, all candidates overlap, adopts existing network,
inspectNetwork non-404 failure classified as attach_failed, env-matches-
existing skip-create, and operator-explicit strict (no fallback).

Fixes F-1 in the pre-1.0 audit. Closes the silent-failure mode that
left the mesh down on the most common homelab Docker layout.
This commit is contained in:
Anso
2026-05-22 13:20:27 -04:00
committed by GitHub
parent 606bdb6b67
commit 1a03cf82af
9 changed files with 454 additions and 110 deletions
@@ -2,7 +2,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Radio, CheckCircle2 } from 'lucide-react';
import { formatRelativeTime } from '@/lib/utils';
import { useFleetHeartbeat } from './useFleetHeartbeat';
import { useMeshDataPlane } from './useMeshDataPlane';
import { useNodes } from '@/context/NodeContext';
import { MeshDataPlaneBanner } from '@/components/fleet/MeshDataPlaneBanner';
import type { FleetNodeOverview } from './useFleetHeartbeat';
import type { Node } from '@/context/NodeContext';
@@ -54,7 +56,11 @@ function SkeletonRow() {
export function FleetHeartbeat() {
const { nodes: overviewNodes, loading, error } = useFleetHeartbeat();
const { status: meshDataPlane } = useMeshDataPlane();
const { nodes: contextNodes } = useNodes();
const meshDown = meshDataPlane?.ok === false
&& meshDataPlane.reason !== 'not_in_docker'
&& meshDataPlane.reason !== 'not_started';
if (loading) {
return (
@@ -102,11 +108,15 @@ export function FleetHeartbeat() {
{unreachableCount > 0 && (
<span className="text-destructive"> · {unreachableCount} unreachable</span>
)}
{meshDown && (
<span className="text-destructive"> · mesh down</span>
)}
</span>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
{meshDown && <MeshDataPlaneBanner status={meshDataPlane} variant="card" />}
{sorted.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-6 text-stat-subtitle">
<CheckCircle2 className="h-4 w-4 text-success" strokeWidth={1.5} />
@@ -9,6 +9,7 @@ export { StackRestartMap } from './StackRestartMap';
export { useDashboardData } from './useDashboardData';
export { useConfigurationStatus } from './useConfigurationStatus';
export { useFleetHeartbeat } from './useFleetHeartbeat';
export { useMeshDataPlane } from './useMeshDataPlane';
export { useStackRestartMap } from './useStackRestartMap';
export type * from './types';
export type { ConfigurationStatus as ConfigurationStatusPayload } from './useConfigurationStatus';
@@ -0,0 +1,56 @@
import { useState, useEffect, useCallback } from 'react';
import { apiFetch } from '@/lib/api';
import { useAuth } from '@/context/AuthContext';
import { visibilityInterval } from '@/lib/utils';
import type { MeshDataPlaneStatus } from '@/types/mesh';
export interface MeshDataPlaneResult {
status: MeshDataPlaneStatus | null;
loading: boolean;
}
/**
* Poll `/mesh/status` for the local data-plane health so dashboard surfaces
* can flag a down mesh without opening the Routing tab. The endpoint is
* Admiral-gated, so the hook short-circuits on non-Admiral tiers (no
* request fired, no banner rendered). On the rare 403 from an Admiral
* tier (token race during downgrade) we leave `status` at null. 30 s
* cadence matches `useFleetHeartbeat` so the dashboard refresh feel is
* consistent.
*/
export function useMeshDataPlane(): MeshDataPlaneResult {
const { permissions } = useAuth();
const isAdmiral = permissions?.isAdmiral ?? false;
const [status, setStatus] = useState<MeshDataPlaneStatus | null>(null);
const [loading, setLoading] = useState(true);
const fetchStatus = useCallback(async () => {
try {
const res = await apiFetch('/mesh/status', { localOnly: true });
if (res.status === 403) {
setStatus(null);
return;
}
if (!res.ok) return;
const body = await res.json() as { localDataPlane?: MeshDataPlaneStatus };
if (body.localDataPlane) setStatus(body.localDataPlane);
} catch {
// Background poll; transient network errors stay silent so the
// card does not flicker on every refresh failure.
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!isAdmiral) {
setStatus(null);
setLoading(false);
return;
}
void fetchStatus();
return visibilityInterval(() => { void fetchStatus(); }, 30_000);
}, [isAdmiral, fetchStatus]);
return { status, loading };
}
@@ -0,0 +1,76 @@
import { AlertTriangle } from 'lucide-react';
import type { MeshDataPlaneStatus } from '@/types/mesh';
type Reason = MeshDataPlaneStatus['reason'];
type ActionableReason = Exclude<Reason, 'ok' | 'not_started' | 'not_in_docker'>;
const HEADLINES: Record<ActionableReason, (status: MeshDataPlaneStatus) => string> = {
subnet_invalid: () => 'SENCHO_MESH_SUBNET is not a valid CIDR.',
subnet_overlap: (s) => `Mesh subnet ${s.subnet} overlaps an existing Docker network on this host.`,
subnet_mismatch: (s) => `sencho_mesh already exists with a different subnet than ${s.subnet}.`,
ip_in_use: (s) => `Another container is using Sencho's address on ${s.subnet}.`,
attach_failed: () => 'Sencho could not attach to its own mesh network.',
};
function isActionable(reason: Reason): reason is ActionableReason {
return reason !== 'ok' && reason !== 'not_started' && reason !== 'not_in_docker';
}
export type MeshDataPlaneBannerVariant = 'tab' | 'card';
/**
* Surfaces the local mesh data-plane failure with the operator-actionable
* recovery hint. Suppresses the dev-mode `not_in_docker` and the transient
* `not_started` states so the operator only sees a banner when there is
* something to fix.
*
* - `tab` variant: the full block shown at the top of the Routing tab.
* - `card` variant: a single-line strip designed to sit inside a dashboard
* card (e.g. Fleet Heartbeat).
*/
export function MeshDataPlaneBanner({
status,
variant = 'tab',
}: {
status: MeshDataPlaneStatus | null;
variant?: MeshDataPlaneBannerVariant;
}) {
if (!status || status.ok || !isActionable(status.reason)) return null;
const headline = HEADLINES[status.reason](status);
if (variant === 'card') {
return (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-2.5 py-1.5 mb-2 text-xs text-destructive flex items-start gap-2">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" strokeWidth={1.75} />
<div className="min-w-0 leading-relaxed">
<span className="font-medium">Mesh data plane down · </span>
<span className="font-mono text-[11px]">{status.reason}</span>
<span> · </span>
<span>{headline}</span>
<span> Set </span>
<code className="font-mono bg-destructive/15 px-1 rounded text-[10px]">SENCHO_MESH_SUBNET</code>
<span> to a free </span>
<code className="font-mono bg-destructive/15 px-1 rounded text-[10px]">/24</code>
<span> and restart Sencho.</span>
</div>
</div>
);
}
return (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive flex items-start gap-3 shadow-card-bevel">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" strokeWidth={1.75} />
<div className="min-w-0 space-y-1">
<div className="font-medium">Mesh data plane is down</div>
<div className="text-xs leading-relaxed text-destructive/90">
{headline}
{' '}
Set <code className="font-mono bg-destructive/15 px-1 py-0.5 rounded text-[11px]">SENCHO_MESH_SUBNET</code> to a free <code className="font-mono bg-destructive/15 px-1 py-0.5 rounded text-[11px]">/24</code> (for example <code className="font-mono bg-destructive/15 px-1 py-0.5 rounded text-[11px]">10.42.0.0/24</code>) and restart the Sencho container.
</div>
{status.message ? (
<div className="text-[11px] font-mono text-destructive/80 truncate">{status.message}</div>
) : null}
</div>
</div>
);
}
+4 -40
View File
@@ -3,7 +3,8 @@ import { apiFetch } from '@/lib/api';
import { visibilityInterval } from '@/lib/utils';
import { toast } from '@/components/ui/toast-store';
import { Button } from '@/components/ui/button';
import { AlertTriangle, ArrowLeftRight, Loader2, ScrollText, Table2, Network } from 'lucide-react';
import { ArrowLeftRight, Loader2, ScrollText, Table2, Network } from 'lucide-react';
import { MeshDataPlaneBanner } from './MeshDataPlaneBanner';
import { RoutingNodeCard } from './RoutingNodeCard';
import { MeshOptInSheet } from './MeshOptInSheet';
import { MeshRouteDetailSheet } from './MeshRouteDetailSheet';
@@ -158,7 +159,7 @@ export function RoutingTab() {
return (
<div className="space-y-4">
<RoutingMasthead meshedNodes={meshedNodes} reachableNodes={reachableNodes} totalAliases={totalAliases} onShowActivity={() => setActivityOpen(true)} />
<DataPlaneBanner status={localDataPlane} />
<MeshDataPlaneBanner status={localDataPlane} variant="tab" />
<div className="flex flex-col items-center justify-center py-12 rounded border border-dashed border-card-border bg-card/50">
<ArrowLeftRight className="w-12 h-12 text-stat-subtitle mb-4" />
<div className="text-lg font-display italic mb-2">Mesh containers across nodes</div>
@@ -199,7 +200,7 @@ export function RoutingTab() {
return (
<div className="space-y-4">
<RoutingMasthead meshedNodes={meshedNodes} reachableNodes={reachableNodes} totalAliases={totalAliases} onShowActivity={() => setActivityOpen(true)} />
<DataPlaneBanner status={localDataPlane} />
<MeshDataPlaneBanner status={localDataPlane} variant="tab" />
<div className="flex flex-wrap items-center gap-3">
<SegmentedControl<RoutingViewMode>
value={viewMode}
@@ -260,43 +261,6 @@ export function RoutingTab() {
);
}
/**
* Visible when the local Sencho's mesh data plane is down. Distinguishes the
* common reasons (subnet conflict, IP-in-use, invalid CIDR) so the operator
* does not have to dig through the activity log to know how to recover. The
* `not_in_docker` warn-level case is intentionally suppressed: it is the
* expected condition for dev-mode startup.
*/
function DataPlaneBanner({ status }: { status: MeshDataPlaneStatus | null }) {
if (!status || status.ok || status.reason === 'not_in_docker' || status.reason === 'not_started') {
return null;
}
const headlines: Record<Exclude<MeshDataPlaneStatus['reason'], 'ok' | 'not_started' | 'not_in_docker'>, string> = {
subnet_invalid: 'SENCHO_MESH_SUBNET is not a valid CIDR.',
subnet_overlap: `Mesh subnet ${status.subnet} overlaps another Docker network on this host.`,
subnet_mismatch: `sencho_mesh already exists with a different subnet than ${status.subnet}.`,
ip_in_use: `Another container is using Sencho's address on ${status.subnet}.`,
attach_failed: 'Sencho could not attach to its own mesh network.',
};
const reason = status.reason as keyof typeof headlines;
return (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive flex items-start gap-3 shadow-card-bevel">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" strokeWidth={1.75} />
<div className="min-w-0 space-y-1">
<div className="font-medium">Mesh data plane is down</div>
<div className="text-xs leading-relaxed text-destructive/90">
{headlines[reason] ?? 'Mesh setup did not complete.'}
{' '}
Set <code className="font-mono bg-destructive/15 px-1 py-0.5 rounded text-[11px]">SENCHO_MESH_SUBNET</code> to a free <code className="font-mono bg-destructive/15 px-1 py-0.5 rounded text-[11px]">/24</code> (for example <code className="font-mono bg-destructive/15 px-1 py-0.5 rounded text-[11px]">10.42.0.0/24</code>) and restart the Sencho container.
</div>
{status.message ? (
<div className="text-[11px] font-mono text-destructive/80 truncate">{status.message}</div>
) : null}
</div>
</div>
);
}
function RoutingMasthead({ meshedNodes, reachableNodes, totalAliases, onShowActivity }: {
meshedNodes: number; reachableNodes: number; totalAliases: number; onShowActivity: () => void;
}) {