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
@@ -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 };
}