feat: Compose Network Inspector and exposure intent guard (#1360)

* feat: add Compose Network Inspector facts engine

Render a stack's authored effective model and pair it with the live
Docker snapshot to derive per-stack networking facts: project networks
with external and internal flags, service-to-network membership and
aliases, published ports with host-binding scope, network_mode, and
extra_hosts, plus runtime drift (runtime-only attachments, foreign
networks, and declared-but-unused or missing networks).

Extend the effective-model parser with service network membership,
extra_hosts, and label keys (key names only, never values), and add a
key-space normalized network model with adapters from both the rendered
model and the raw declared compose so the Inspector and drift share one
comparison. Expose GET /api/stacks/:stackName/networking: advisory and
read-only, it renders the authored model only and never returns or logs
raw stderr, env values, or label values.

* feat: store and edit per-stack and per-service exposure intent

Add a stack_exposure_intent table (intent values constrained by a CHECK,
unique per node, stack, and service) with DAO methods to read, upsert,
clear one row, and clear all rows for a stack. The classification is
stored independently of the generated networking facts so a later
mismatch stays detectable; service rows are kept separately from the
stack-level row (service '').

Expose GET and PUT /api/stacks/:stackName/exposure: GET requires read
access, PUT requires edit access and validates the intent against the
allowed set. Sending intent null clears that row, returning the scope to
unset so a service inherits the stack intent again. Intent rows are
cleared when the stack is deleted and when the owning node is removed,
so a later same-named stack never picks up stale classification.

* feat: add exposure-aware Compose Doctor findings

Feed the Compose Doctor's effective-model context with the stored
exposure intent (resolved into a stack-level value plus per-service
overrides) and the dossier's documented access-URL ports, read fail-soft
so a metadata read error skips these checks rather than failing the
preflight. Add five deterministic findings on top of that context:

- a service classified internal or same-node that publishes a host port
  (same-node tolerates a loopback bind),
- a sensitive database or admin image published on all interfaces,
- a port-publishing stack with no exposure intent set,
- a published port not reflected in the documented access URLs,
- reverse-proxy labels with no documented URL or reverse-proxy intent.

The rules stay pure functions over the preflight context; the registry
completeness test pins the new rule set.

* feat: detect compose network drift in the drift ledger

Extend the spatial drift engine with two network-level findings: a
running container attached to a stack-owned or foreign network that
compose does not declare (one finding per service), and a declared
network that no running service uses or that is absent from the runtime
(one stack-level finding, every network named by its resolved runtime
name). The comparison reuses the same helper the Network Inspector uses,
so the two surfaces never disagree.

Network drift runs only when the stack has running containers and the
runtime is reachable, preserving the existing missing-runtime,
parse-error, and unreachable behavior. The findings persist through the
existing drift ledger and surface on the Drift tab, which now labels the
two new kinds.

* feat: link a Docker network back to its owning stack

Add a cross-component open-stack event and make the owning-stack badge on
a managed network in Resources a link: clicking it loads that stack on
its node and opens the editor, reusing the existing fleet navigation. A
latest-ref keeps the window listener current without re-subscribing each
render. Image and volume badges are unchanged; only a managed network
opts in via the new optional handler.

* feat: add the Networking tab to the stack detail panel

Add a capability-gated Networking tab that reads the per-stack networking
facts and exposure intent. It shows the project networks (with external,
internal, and created-by-stack flags), per-service network membership and
aliases, published ports with their host-binding scope, network_mode and
extra_hosts, and runtime drift, degrading to the declared model when the
runtime is unavailable. Users can classify the stack and each service
(internal, LAN, reverse proxy, public, and so on) or clear a row to
inherit; the controls are read-only when the user cannot edit, and a
broken exposure response never tears down the facts view.

A new compose-networking capability is added to both registries so older
nodes hide the tab, and the tab cross-links to the Doctor for the deploy
and security findings.

* docs: document the Compose Networking tab

Add a feature page covering the Networking tab: the network facts,
published ports and host bindings, the exposure-intent classification
and inheritance, the exposure-aware Doctor findings, runtime drift, and
a troubleshooting section. Register it in the docs navigation next to
Compose Doctor.

* feat: add a redacted network summary to the Stack Dossier export

Append a network exposure section to the dossier Markdown: the stack and
per-service exposure intents, the networks with their external and
internal flags, and each service's published ports with their binding
scope. It carries only names, intents, port numbers, and scope, never an
env value or a label value.

The summary is fetched only when the user exports (copy or download), so
opening the panel costs nothing, and it degrades to omitting the section
when the data is unavailable. The whole-fleet dossier export collects the
same summary per stack, rethrowing the unauthorized sentinel like the
sibling loaders.

* feat: add a Fleet networking filter for exposure and drift

Add a per-node networking summary that classifies a node's stacks as
exposed (a host port published beyond loopback), unknown-exposure
(publishes ports with no exposure intent set), or network-drift. It
reads each stack's compose with the light dependency parser and one
Docker snapshot, so it stays cheap across a node's full stack set, and
it skips drift when the runtime is unreachable rather than inventing it.

Serve it node-locally at GET /api/networking/summary, and aggregate it
fleet-wide at GET /api/fleet/networking-summary: the hub computes its own
summary in-process and reaches each remote through its node-local route,
degrading an unreachable or older node to a skip. Because the aggregate
lives under the proxy-exempt /api/fleet prefix it is never wrongly
proxied. The Fleet overview gains a networking filter chip backed by that
aggregate, fetched fail-soft and detached so it never gates the grid.

* fix: spin the Networking refresh button while it reloads

The refresh button silently refetched the same data, so a click gave no
feedback. Track a refreshing state and spin the icon while the load is in
flight, disabling the button, matching the Compose Doctor preflight
button.

* fix: apply effective per-service exposure intent to unclassified checks

The "unclassified exposure" decisions only consulted the stack-level intent
row, so a service classified directly (with no stack row) was still reported
as unclassified, and a service explicitly marked unknown over a classified
stack was missed.

Both the exposure-unclassified preflight rule and the networking summary's
unknown-exposure bucket now resolve the effective intent per publishing
service (service row overrides stack row), matching the precedence already
used by the exposure-internal-published rule.

* fix: resolve drift network names via the compose top-level name

When a compose file sets a top-level name:, Docker prefixes resource names
with that project name instead of the stack directory. The light dependency
parser dropped name:, so network-drift normalization compared runtime
networks against directory-prefixed names and reported false
network-undeclared / network-missing findings.

Carry the parsed project name through DeclaredCompose and use it when
normalizing declared networks for drift, while still filtering containers by
the stack directory.
This commit is contained in:
Anso
2026-06-12 02:15:11 -04:00
committed by GitHub
parent bef51a979f
commit 77f1611971
53 changed files with 2853 additions and 49 deletions
@@ -0,0 +1,318 @@
import { useEffect, useState, useCallback } from 'react';
import { Network, Globe, Lock, ShieldQuestion, RefreshCw, ArrowRight } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
// Mirrors the backend networking payload shapes (the frontend never imports
// backend). IntentEntry intentionally keeps only the fields this panel reads.
type ExposureIntent = 'internal' | 'same-node' | 'lan' | 'reverse-proxy' | 'public' | 'temporary' | 'unknown';
const INTENTS: readonly ExposureIntent[] = ['internal', 'same-node', 'lan', 'reverse-proxy', 'public', 'temporary', 'unknown'];
interface NetworkFactNetwork { key: string; name: string; external: boolean; internal: boolean; createdByStack: boolean }
interface NetworkFactPort { hostIp: string; startPort: number; endPort: number; protocol: string; allInterfaces: boolean; loopbackOnly: boolean }
interface NetworkFactService {
name: string;
networks: { key: string; aliases: string[] }[];
publishedPorts: NetworkFactPort[];
networkMode?: string;
extraHosts: string[];
}
interface NetworkDriftFacts {
runtimeOnlyAttachments: { container: string; service: string | null; network: string }[];
declaredButUnused: string[];
missingFromRuntime: string[];
foreignNetworkAttachments: { container: string; network: string }[];
}
interface StackNetworkFacts {
stack: string;
renderable: boolean;
renderError: string | null;
runtime: 'available' | 'unavailable';
networks: NetworkFactNetwork[];
services: NetworkFactService[];
drift: NetworkDriftFacts;
}
interface IntentEntry { service: string; intent: ExposureIntent }
const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle';
const ACTION_CLASS = 'inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors disabled:opacity-40';
const CARD_CLASS = 'rounded-lg border px-3 py-2.5';
function portLabel(p: NetworkFactPort): string {
const range = p.startPort === p.endPort ? `${p.startPort}` : `${p.startPort}-${p.endPort}`;
return `${range}/${p.protocol}`;
}
/** Defensively read the intents array from an exposure response body. */
function asIntents(body: unknown): IntentEntry[] {
const list = (body as { intents?: unknown })?.intents;
return Array.isArray(list) ? (list as IntentEntry[]) : [];
}
/** A small chip that states the binding scope of a published port. */
function BindingBadge({ port }: { port: NetworkFactPort }) {
if (port.allInterfaces) {
return <span className="rounded border border-warning/40 bg-warning/[0.08] px-1 py-0.5 font-mono text-[10px] text-warning">all interfaces</span>;
}
if (port.loopbackOnly) {
return <span className="rounded border border-success/30 bg-success/[0.06] px-1 py-0.5 font-mono text-[10px] text-success">loopback</span>;
}
return <span className="rounded border border-muted bg-card/40 px-1 py-0.5 font-mono text-[10px] text-stat-subtitle">{port.hostIp}</span>;
}
/**
* Exposure-intent picker: a row of pills plus a clear option. `value` null means
* the scope is cleared. The clear option reads "unset" on the stack row and
* "inherit" on a per-service row, where the service then falls back to the stack
* intent. Disabled and read-only when the user cannot edit the stack.
*/
function IntentControl({ value, inherited, canEdit, onChange }: {
value: ExposureIntent | null;
inherited?: ExposureIntent | null;
canEdit: boolean;
onChange: (intent: ExposureIntent | null) => void;
}) {
const pill = (active: boolean) => cn(
'rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide border transition-colors',
active ? 'border-brand/50 bg-brand/15 text-brand' : 'border-muted bg-card/40 text-stat-subtitle',
canEdit ? 'hover:border-brand/40' : 'cursor-default opacity-90',
);
const clearLabel = inherited !== undefined ? 'inherit' : 'unset';
const cleared = value === null;
return (
<div className="flex flex-wrap items-center gap-1">
{INTENTS.map(opt => (
<button key={opt} type="button" disabled={!canEdit} className={pill(value === opt)} onClick={() => canEdit && onChange(opt)}>
{opt}
</button>
))}
<button type="button" disabled={!canEdit} className={pill(cleared)} onClick={() => canEdit && onChange(null)}>
{clearLabel}
</button>
{cleared && inherited && (
<span className="font-mono text-[10px] text-stat-subtitle"> {inherited}</span>
)}
</div>
);
}
export default function StackNetworkingPanel({ stackName, canEdit, doctorEnabled }: {
stackName: string;
canEdit: boolean;
doctorEnabled: boolean;
}) {
const { activeNode } = useNodes();
const nodeId = activeNode?.id;
const [facts, setFacts] = useState<StackNetworkFacts | null>(null);
const [intents, setIntents] = useState<IntentEntry[]>([]);
const [loadError, setLoadError] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
const run = async () => {
setLoadError(false);
setRefreshing(true);
try {
const [factsRes, exposureRes] = await Promise.all([
apiFetch(`/stacks/${stackName}/networking`),
apiFetch(`/stacks/${stackName}/exposure`),
]);
if (cancelled) return;
if (!factsRes.ok) {
setLoadError(true);
toast.error('Failed to load the networking view.');
return;
}
setFacts((await factsRes.json()) as StackNetworkFacts);
// The exposure overlay is secondary: a bad exposure body must not tear
// down a working facts view, so its parse is tolerated on its own.
if (exposureRes.ok) {
try { setIntents(asIntents(await exposureRes.json())); } catch { /* keep intents unset */ }
}
} catch {
if (!cancelled) {
setLoadError(true);
toast.error('Failed to load the networking view.');
}
} finally {
if (!cancelled) setRefreshing(false);
}
};
void run();
return () => { cancelled = true; };
}, [stackName, nodeId, reloadKey]);
const stackIntent = intents.find(i => i.service === '')?.intent ?? null;
const intentFor = (service: string): ExposureIntent | null => intents.find(i => i.service === service)?.intent ?? null;
const saveIntent = useCallback(async (service: string, intent: ExposureIntent | null) => {
try {
const res = await apiFetch(`/stacks/${stackName}/exposure`, {
method: 'PUT',
body: JSON.stringify({ service, intent }),
});
if (!res.ok) {
toast.error('Failed to save the exposure intent.');
return;
}
setIntents(asIntents(await res.json()));
} catch {
toast.error('Failed to save the exposure intent.');
}
}, [stackName]);
if (loadError) {
return (
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3">
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/40 bg-destructive/[0.06] px-3 py-3">
<span className="font-mono text-[11px] text-destructive">Could not load the networking view.</span>
<button type="button" onClick={() => setReloadKey(k => k + 1)} className="font-mono text-[10px] uppercase tracking-wide text-destructive hover:underline">retry</button>
</div>
</div>
);
}
if (!facts) {
return <div className="flex-1 min-h-0 px-3 py-3 font-mono text-[11px] text-stat-subtitle">Loading networking</div>;
}
if (!facts.renderable) {
return (
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3">
<div className={cn(CARD_CLASS, 'border-destructive/40 bg-destructive/[0.06]')}>
<div className="flex items-center gap-2 text-destructive"><Network className="h-4 w-4" strokeWidth={1.5} /><span className="font-mono text-[11px] uppercase tracking-wide">cannot render</span></div>
<p className="mt-1 text-[12px] leading-relaxed text-foreground/80">{facts.renderError ?? 'Sencho could not render the effective Compose model.'}</p>
</div>
</div>
);
}
const drift = facts.drift;
const hasDrift = drift.runtimeOnlyAttachments.length > 0 || drift.foreignNetworkAttachments.length > 0
|| drift.declaredButUnused.length > 0 || drift.missingFromRuntime.length > 0;
return (
<div data-testid="networking-panel" className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<span className={LABEL_CLASS}>networking</span>
<button type="button" onClick={() => setReloadKey(k => k + 1)} disabled={refreshing} className={ACTION_CLASS}>
<RefreshCw className={cn('h-3 w-3', refreshing && 'animate-spin')} strokeWidth={1.5} /> refresh
</button>
</div>
{/* Exposure intent */}
<section className="flex flex-col gap-2">
<div className={LABEL_CLASS}>exposure intent</div>
<div className={cn(CARD_CLASS, 'border-muted bg-card/40 flex flex-col gap-2')}>
<div className="flex flex-col gap-1">
<span className="font-mono text-[11px] text-foreground/80">stack</span>
<IntentControl value={stackIntent} canEdit={canEdit} onChange={intent => saveIntent('', intent)} />
</div>
{facts.services.map(svc => (
<div key={svc.name} className="flex flex-col gap-1 border-t border-muted pt-2">
<span className="font-mono text-[11px] text-foreground/80">{svc.name}</span>
<IntentControl value={intentFor(svc.name)} inherited={stackIntent} canEdit={canEdit} onChange={intent => saveIntent(svc.name, intent)} />
</div>
))}
</div>
</section>
{/* Networks */}
<section className="flex flex-col gap-2">
<div className={LABEL_CLASS}>networks</div>
<div className="rounded-lg border border-muted bg-card/40">
{facts.networks.length === 0 && <div className="px-3 py-2 font-mono text-[11px] text-stat-subtitle">default network only</div>}
{facts.networks.map(net => (
<div key={net.key} className="flex flex-wrap items-center gap-2 border-t border-muted px-3 py-2 first:border-t-0">
<span className="font-mono text-[12px] text-foreground/90">{net.name}</span>
{net.key !== net.name && <span className="font-mono text-[10px] text-stat-subtitle">({net.key})</span>}
{net.external && <span className="rounded border border-info/40 bg-info/[0.06] px-1 py-0.5 font-mono text-[10px] text-info">external</span>}
{net.internal && <span className="rounded border border-muted px-1 py-0.5 font-mono text-[10px] text-stat-subtitle"><Lock className="inline h-2.5 w-2.5" /> internal</span>}
{net.createdByStack && <span className="rounded border border-muted px-1 py-0.5 font-mono text-[10px] text-stat-subtitle">created by stack</span>}
</div>
))}
</div>
</section>
{/* Services */}
<section className="flex flex-col gap-2">
<div className={LABEL_CLASS}>services</div>
<div className="flex flex-col gap-2">
{facts.services.map(svc => (
<div key={svc.name} className={cn(CARD_CLASS, 'border-muted bg-card/40 flex flex-col gap-1.5')}>
<div className="flex items-center gap-2">
<span className="font-mono text-[12px] text-foreground/90">{svc.name}</span>
{svc.networkMode && <span className="rounded border border-warning/40 bg-warning/[0.08] px-1 py-0.5 font-mono text-[10px] text-warning">network_mode: {svc.networkMode}</span>}
</div>
{svc.networks.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{svc.networks.map(n => (
<span key={n.key} className="font-mono text-[11px] text-foreground/80">
{n.key}{n.aliases.length > 0 && <span className="text-stat-subtitle"> ({n.aliases.join(', ')})</span>}
</span>
))}
</div>
)}
{svc.publishedPorts.length > 0 && (
<div className="flex flex-col gap-1">
{svc.publishedPorts.map((p, i) => (
<div key={i} className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[11px] text-foreground/80">{portLabel(p)}</span>
<BindingBadge port={p} />
</div>
))}
</div>
)}
{svc.extraHosts.length > 0 && (
<div className="font-mono text-[10px] text-stat-subtitle">extra_hosts: {svc.extraHosts.join(', ')}</div>
)}
</div>
))}
</div>
</section>
{/* Runtime drift */}
<section className="flex flex-col gap-2">
<div className={LABEL_CLASS}>runtime drift</div>
{facts.runtime === 'unavailable' ? (
<div className={cn(CARD_CLASS, 'border-muted bg-card/40 flex items-center gap-2 text-stat-subtitle')}>
<ShieldQuestion className="h-4 w-4" strokeWidth={1.5} />
<span className="font-mono text-[11px]">runtime unavailable, showing the declared model only</span>
</div>
) : !hasDrift ? (
<div className={cn(CARD_CLASS, 'border-success/30 bg-success/[0.06] flex items-center gap-2 text-success')}>
<Globe className="h-4 w-4" strokeWidth={1.5} />
<span className="font-mono text-[11px] uppercase tracking-wide">runtime matches compose</span>
</div>
) : (
<div className="rounded-lg border border-muted bg-card/40 flex flex-col">
{drift.runtimeOnlyAttachments.map((d, i) => (
<div key={`ro-${i}`} className="border-t border-muted px-3 py-2 first:border-t-0 text-[12px] text-foreground/80">
<span className="rounded bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{d.service ?? d.container}</span> attached to undeclared network <span className="font-mono">{d.network}</span>
</div>
))}
{drift.foreignNetworkAttachments.map((d, i) => (
<div key={`fn-${i}`} className="border-t border-muted px-3 py-2 first:border-t-0 text-[12px] text-foreground/80">
<span className="font-mono">{d.container}</span> attached to a network owned by another stack: <span className="font-mono">{d.network}</span>
</div>
))}
{drift.declaredButUnused.length > 0 && (
<div className="border-t border-muted px-3 py-2 first:border-t-0 text-[12px] text-foreground/80">declared but unused by any running service: <span className="font-mono">{drift.declaredButUnused.join(', ')}</span></div>
)}
{drift.missingFromRuntime.length > 0 && (
<div className="border-t border-muted px-3 py-2 first:border-t-0 text-[12px] text-foreground/80">declared but missing from the runtime: <span className="font-mono">{drift.missingFromRuntime.join(', ')}</span></div>
)}
</div>
)}
</section>
{doctorEnabled && (
<div className="flex items-center gap-1 font-mono text-[10px] text-stat-subtitle">
<ArrowRight className="h-3 w-3" strokeWidth={1.5} /> deploy and security findings are in the Doctor tab
</div>
)}
</div>
);
}