diff --git a/src/psyc/_federation_cli.py b/src/psyc/_federation_cli.py
index b51223c..0c4dcbb 100644
--- a/src/psyc/_federation_cli.py
+++ b/src/psyc/_federation_cli.py
@@ -13,7 +13,7 @@ import httpx
import typer
from psyc import db, log
-from psyc.lines import discovery, federation, network_view, pulse, translog
+from psyc.lines import discovery, federation, network_view, pulse, topology_export, translog
from psyc.result import Err, Ok
@@ -326,6 +326,18 @@ def register(typer_app: typer.Typer) -> None:
for k, v in view.stats.items():
typer.echo(f" {k:<32} {v}")
+ @typer_app.command("fed-topology")
+ def fed_topology() -> None:
+ """Print the sanitized docker topology JSON published at /federation/topology.
+
+ Useful for auditing what gets exposed to peers — pipe through `jq` to
+ confirm no env vars / volume mounts / IPs leak. On a dev box where
+ the docker-socket-proxy isn't running the export will be empty.
+ """
+ db.init_db()
+ export = topology_export.build_export()
+ typer.echo(json.dumps(export.model_dump(mode="json"), indent=2))
+
@typer_app.command("fed-log-verify")
def fed_log_verify() -> None:
"""Re-walk the chain locally and report verification status."""
diff --git a/src/psyc/cockpit/federation_routes.py b/src/psyc/cockpit/federation_routes.py
index ae915a5..8d415bf 100644
--- a/src/psyc/cockpit/federation_routes.py
+++ b/src/psyc/cockpit/federation_routes.py
@@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Red
from fastapi.templating import Jinja2Templates
from psyc import db, log
-from psyc.lines import discovery, federation, network_view, pulse, translog
+from psyc.lines import discovery, federation, network_view, pulse, topology_export, translog
from psyc.result import Err
@@ -39,6 +39,11 @@ _PUBLIC_NETWORK_TTL = 60.0
_EXPLORE_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None, "domain": None}
_EXPLORE_TTL = 60.0
+# Sanitized docker topology cache. The build call hits the docker-socket-proxy
+# sidecar; polled peer admin pages mustn't re-trigger that on every poke.
+_TOPOLOGY_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None}
+_TOPOLOGY_TTL = 60.0
+
# Headers we slap on every public endpoint so other psyc nodes' explore
# pages can fetch them cross-origin from the browser.
@@ -77,6 +82,16 @@ def _cached_public_network() -> Dict[str, Any]:
return _PUBLIC_NETWORK_CACHE["payload"]
+def _cached_topology() -> Dict[str, Any]:
+ """Cached sanitized docker topology — same poll-load pattern as the feed."""
+ now = time.time()
+ if _TOPOLOGY_CACHE["payload"] is None or (now - _TOPOLOGY_CACHE["ts"]) > _TOPOLOGY_TTL:
+ export = topology_export.build_export()
+ _TOPOLOGY_CACHE["payload"] = export.model_dump(mode="json")
+ _TOPOLOGY_CACHE["ts"] = now
+ return _TOPOLOGY_CACHE["payload"]
+
+
def _cached_explore(domain: Optional[str]) -> Dict[str, Any]:
"""Cached explore payload. Re-uses the cache when the host domain matches.
@@ -257,6 +272,17 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
"""
return _public_json(_cached_public_network())
+ @app.get("/federation/topology")
+ def federation_topology_public() -> JSONResponse:
+ """Sanitized docker topology — public, for peer-side display.
+
+ Whitelist-only: container names + images + state + network names. No
+ env vars, no volume mounts, no IPs/MACs/gateways, no labels. CORS open
+ so a peer's `/admin/federation/network` page can fetch it from the
+ browser and render every node's containers alongside its own.
+ """
+ return _public_json(_cached_topology())
+
# ---------- public vouches + transparency log --------------------
@app.get("/federation/vouches")
diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css
index 13730e9..4d4dfa7 100644
--- a/src/psyc/cockpit/static/cockpit.css
+++ b/src/psyc/cockpit/static/cockpit.css
@@ -1824,3 +1824,68 @@ body.wide #federation-network-graph { height: 720px; }
.fn-remote-list code { font-size: 11px; color: var(--accent); }
.fn-remote-list .muted { color: var(--muted); margin-left: 6px; font-size: 11px; }
.fn-remote-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
+
+/* peer container topology — sanitized snapshot fetched from peer's
+ /federation/topology. Full-width row inside the detail card; networks
+ + containers in two columns, each row tagged with a small state dot. */
+.fn-topology-sec { grid-column: 1 / -1; }
+.fn-topology-meta {
+ display: flex; flex-wrap: wrap; gap: 6px 16px;
+ font-size: 11px; color: var(--muted); margin-bottom: 10px;
+ letter-spacing: 0.02em;
+}
+.fn-topology-meta code { font-size: 11px; color: var(--accent); }
+.fn-topology-cols {
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 14px; margin: 4px 0 6px;
+}
+.fn-topology-h {
+ font-size: 11px; color: var(--muted);
+ text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px;
+}
+.fn-topology-list { list-style: none; padding: 0; margin: 0; font-size: 12px; line-height: 1.55; }
+.fn-topology-list li {
+ padding: 4px 0; border-bottom: 1px dashed rgba(125,133,151,0.18);
+ display: block;
+}
+.fn-topology-list li:last-child { border-bottom: 0; }
+.fn-topology-list .muted { color: var(--muted); font-size: 11px; }
+
+/* small colored dot indicating container state */
+.fn-topo-state-dot {
+ display: inline-block; width: 6px; height: 6px; border-radius: 50%;
+ margin-right: 8px; vertical-align: middle;
+ background: rgba(125,133,151,0.7);
+}
+.fn-topo-state-running { background: rgba(74,222,128,1); box-shadow: 0 0 4px rgba(74,222,128,0.55); }
+.fn-topo-state-exited { background: rgba(125,133,151,0.7); }
+.fn-topo-state-paused { background: rgba(251,191,36,1); }
+.fn-topo-state-restarting,
+.fn-topo-state-dead,
+.fn-topo-state-unhealthy { background: rgba(248,113,113,1); }
+
+.fn-topo-cname { color: var(--text); font-family: ui-monospace, Menlo, Consolas, monospace; }
+.fn-topo-netname { color: var(--text); font-family: ui-monospace, Menlo, Consolas, monospace; }
+.fn-topo-image {
+ font-family: ui-monospace, Menlo, Consolas, monospace;
+ font-size: 11px; color: var(--muted);
+ margin-left: 14px; word-break: break-all;
+}
+.fn-topo-int {
+ display: inline-block; font-size: 10px;
+ padding: 1px 5px; margin-left: 6px;
+ border-radius: 3px; color: var(--amber);
+ background: rgba(251,191,36,0.12);
+ border: 1px solid rgba(251,191,36,0.25);
+ text-transform: uppercase; letter-spacing: 0.05em;
+}
+.fn-topo-health {
+ display: inline-block; font-size: 10px;
+ padding: 1px 5px; margin-left: 6px;
+ border-radius: 3px; text-transform: uppercase; letter-spacing: 0.05em;
+ background: rgba(125,133,151,0.15); color: var(--muted);
+ border: 1px solid rgba(125,133,151,0.25);
+}
+.fn-topo-h-healthy { color: var(--green); border-color: rgba(74,222,128,0.35); background: rgba(74,222,128,0.10); }
+.fn-topo-h-unhealthy { color: var(--red); border-color: rgba(248,113,113,0.35); background: rgba(248,113,113,0.10); }
+.fn-topo-h-starting { color: var(--amber); border-color: rgba(251,191,36,0.35); background: rgba(251,191,36,0.10); }
diff --git a/src/psyc/cockpit/static/federation_network.js b/src/psyc/cockpit/static/federation_network.js
index 2a7705a..8aded76 100644
--- a/src/psyc/cockpit/static/federation_network.js
+++ b/src/psyc/cockpit/static/federation_network.js
@@ -589,6 +589,20 @@
`
: "";
+ // Placeholder for the peer's container topology. We render this for
+ // self too — the local node's /federation/topology fetches via the same
+ // path, just same-origin. Distance=2 transitive peers usually lack a
+ // direct domain in the network feed; skip them since there's nothing
+ // to fetch.
+ const topoHost = n.is_self ? "" : (n.domain || "");
+ const topoTarget = n.is_self ? "" : (n.domain || "");
+ const topoSec = (n.is_self || n.domain)
+ ? `
+
containers fetching…
+
loading${topoHost ? ` from ${esc(topoHost)}` : " local topology"}…
+
`
+ : "";
+
const html = `
${esc(kindLabel)}
@@ -603,6 +617,7 @@
${quorum}
${translog}
${remote}
+ ${topoSec}
${actions}
`;
@@ -616,6 +631,14 @@
fetchPeerSelfView(n.domain, n.fp);
}
+ // Async-fetch the topology — sanitized container/network listing. For
+ // SELF we hit our own origin; for peers we hit https:///.
+ if (n.is_self) {
+ fetchPeerTopology("", n.fp);
+ } else if (n.domain) {
+ fetchPeerTopology(n.domain, n.fp);
+ }
+
const close = detailEl.querySelector(".td-close");
if (close) close.addEventListener("click", clearSelection);
@@ -737,6 +760,99 @@
`;
}
+ // ---------- peer container topology (cross-origin fetch) ------------
+ // Each psyc node exposes /federation/topology — a whitelist-only docker
+ // snapshot (container names + images + state + network names, nothing
+ // else). CORS=* so we can render every connected node's containers
+ // inside the federation network panel — operator gets a single pane of
+ // glass instead of having to SSH into each node.
+
+ function _topoStateClass(state) {
+ const s = String(state || "").toLowerCase();
+ if (s === "running") return "fn-topo-state-running";
+ if (s === "paused") return "fn-topo-state-paused";
+ if (s === "restarting" || s === "dead") return "fn-topo-state-restarting";
+ // exited / created / removing / unknown → muted
+ return "fn-topo-state-exited";
+ }
+
+ function _truncImage(img, max) {
+ const s = String(img || "");
+ if (s.length <= max) return s;
+ // Keep the tail — repo/image:tag is more meaningful than the registry
+ // prefix when truncating.
+ return "…" + s.slice(-(max - 1));
+ }
+
+ async function fetchPeerTopology(domain, expectedFp) {
+ const sec = detailEl.querySelector(`.fn-topology-sec[data-topo-fp="${expectedFp}"]`);
+ if (!sec) return;
+ const statusEl = sec.querySelector(".fn-topo-status");
+ const bodyEl = sec.querySelector(".fn-topology-body");
+ // Empty domain → same-origin fetch for SELF. Otherwise cross-origin.
+ const base = domain ? `https://${domain}` : "";
+ let data = null;
+ try {
+ const r = await fetch(`${base}/federation/topology`, { mode: "cors", cache: "no-store" });
+ if (r.ok) data = await r.json();
+ } catch (e) { /* fall through */ }
+ // Detail panel may have moved on; bail if our section is gone.
+ if (!detailEl.contains(sec)) return;
+ if (!data) {
+ if (statusEl) statusEl.textContent = "unreachable";
+ bodyEl.innerHTML = `couldn't reach ${esc(domain || "self")} — endpoint may be offline, blocking cross-origin, or this peer hasn't been upgraded yet.`;
+ return;
+ }
+ const containers = Array.isArray(data.containers) ? data.containers : [];
+ const networks = Array.isArray(data.networks) ? data.networks : [];
+ const host = data.host_name || "—";
+ const gen = String(data.generated_at || "").slice(0, 19).replace("T", " ");
+ const cCount = Number(data.container_count || containers.length || 0);
+ const nCount = Number(data.network_count || networks.length || 0);
+
+ // Empty-state — node has docker_view disabled or no containers visible.
+ if (!cCount && !nCount) {
+ if (statusEl) statusEl.textContent = "no data";
+ bodyEl.innerHTML = `host ${esc(host)} · ${cCount} containers · ${nCount} networks${gen ? ` · generated ${esc(gen)}` : ""}
no containers reported — docker-socket-proxy may be down on this node.`;
+ return;
+ }
+
+ const CONT_VISIBLE = 30, NET_VISIBLE = 10;
+ const contShown = containers.slice(0, CONT_VISIBLE);
+ const contHidden = Math.max(0, containers.length - CONT_VISIBLE);
+ const netShown = networks.slice(0, NET_VISIBLE);
+ const netHidden = Math.max(0, networks.length - NET_VISIBLE);
+
+ const netsList = netShown.length
+ ? `${netShown.map(net => {
+ const drv = esc(net.driver || "—");
+ const ct = Number(net.container_count || 0);
+ const internalChip = net.internal ? ` internal` : "";
+ return `- ${esc(net.name || "?")} · ${drv} · ${ct} container${ct === 1 ? "" : "s"}${internalChip}
`;
+ }).join("")}${netHidden ? `- + ${netHidden} more
` : ""}
`
+ : `no networks reported`;
+
+ const contList = contShown.length
+ ? `${contShown.map(c => {
+ const dotCls = _topoStateClass(c.state);
+ const stateTxt = esc(c.state || "?");
+ const img = _truncImage(c.image, 30);
+ const healthPill = (c.health && c.health !== "—")
+ ? ` ${esc(c.health)}` : "";
+ const svc = c.service ? ` [${esc(c.service)}]` : "";
+ return `- ${esc(c.name || "?")}${svc}${healthPill}
${esc(img)}
`;
+ }).join("")}${contHidden ? `- + ${contHidden} more
` : ""}
`
+ : `no containers reported`;
+
+ if (statusEl) statusEl.textContent = domain ? "peer topology" : "local topology";
+ bodyEl.innerHTML = `
+ host ${esc(host)} · ${cCount} container${cCount === 1 ? "" : "s"} · ${nCount} network${nCount === 1 ? "" : "s"}${gen ? ` · generated ${esc(gen)}` : ""}
+
+
networks (${networks.length})
${netsList}
+
containers (${containers.length})
${contList}
+
`;
+ }
+
// ---------- idle animation ------------------------------------------
let energyBudget = 40;
function loop() {
diff --git a/src/psyc/cockpit/static/sw.js b/src/psyc/cockpit/static/sw.js
index f203085..5e0dd8b 100644
--- a/src/psyc/cockpit/static/sw.js
+++ b/src/psyc/cockpit/static/sw.js
@@ -5,7 +5,7 @@
// This makes the cockpit installable as a PWA and survives flaky connections,
// without serving stale operational data behind the operator's back.
-const CACHE_VERSION = "psyc-v9";
+const CACHE_VERSION = "psyc-v10";
const STATIC_ASSETS = [
"/static/cockpit.css",
"/static/psyc-tokens.css",
diff --git a/src/psyc/lines/topology_export.py b/src/psyc/lines/topology_export.py
new file mode 100644
index 0000000..7de2aed
--- /dev/null
+++ b/src/psyc/lines/topology_export.py
@@ -0,0 +1,228 @@
+"""Topology export — sanitized public docker snapshot.
+
+The cockpit's `docker_view.topology()` returns a rich daemon view useful to
+the local operator: container env vars, volume mounts, internal IPs, labels,
+gateways. None of that may leave the node. This module wraps `docker_view`
+with a strict whitelist: only container names, images, states, network names
+and high-level driver/health metadata are exposed. Anything not listed in
+the Pydantic schemas below is dropped before serialization.
+
+Used by `/federation/topology` so peer admin pages can render every node's
+container topology side-by-side with their own.
+"""
+
+from __future__ import annotations
+
+import re
+from datetime import datetime, timezone
+from typing import Any, Dict, List, Optional
+
+from pydantic import BaseModel, Field
+
+from psyc import log
+from psyc.cockpit import docker_view
+from psyc.lines import federation
+
+
+_log = log.get(__name__)
+
+
+# Caps keep the response bounded — a runaway node with thousands of
+# containers shouldn't blow up the peer's panel.
+MAX_CONTAINERS = 200
+MAX_NETWORKS = 50
+
+
+# ---------- data model --------------------------------------------------
+
+class TopologyContainer(BaseModel):
+ """One container — sanitized.
+
+ Strict whitelist: name, short_id, image (tag-only), state, health,
+ network names, compose service label, started_at. No env vars, no
+ volumes, no IPs, no MACs, no port mappings, no full labels dict.
+ """
+ name: str
+ short_id: str
+ image: str
+ state: str
+ health: str
+ networks: List[str] = Field(default_factory=list)
+ service: Optional[str] = None
+ started_at: Optional[str] = None
+
+
+class TopologyNetwork(BaseModel):
+ """One docker network — sanitized.
+
+ Whitelist: name, driver, internal flag, container_count. No subnet,
+ no gateway, no labels, no attached-container details (those are
+ surfaced via the container.networks list).
+ """
+ name: str
+ driver: str
+ internal: bool
+ container_count: int
+
+
+class TopologyExport(BaseModel):
+ """Whole-node container snapshot, public-safe."""
+ node_fingerprint: str
+ generated_at: str
+ host_name: str
+ container_count: int
+ network_count: int
+ containers: List[TopologyContainer] = Field(default_factory=list)
+ networks: List[TopologyNetwork] = Field(default_factory=list)
+
+
+# ---------- sanitizers --------------------------------------------------
+
+_BASIC_AUTH_RE = re.compile(r"^[^/@]+@")
+
+
+def _filter_image_name(s: str) -> str:
+ """Strip credentials from an image reference and drop digests.
+
+ Docker accepts `user:pass@registry/image:tag` for registries with HTTP
+ basic auth — we strip everything up to and including the `@` so leaked
+ creds never reach a peer. We also cut content-addressable digests
+ (`...@sha256:...`) to a clean tag-only form.
+
+ Returns the cleaned `repo/image:tag` string. Empty input → "".
+ """
+ if not s:
+ return ""
+ raw = str(s).strip()
+ if not raw:
+ return ""
+ # Drop digest suffix, e.g. "nginx:1.25@sha256:abcd…" → "nginx:1.25".
+ if "@sha256:" in raw:
+ raw = raw.split("@sha256:", 1)[0]
+ # Strip basic-auth prefix on the registry component.
+ # "user:pass@host/repo:tag" → "host/repo:tag" (we never want creds out).
+ if _BASIC_AUTH_RE.match(raw):
+ raw = raw.split("@", 1)[1]
+ # Cap length defensively.
+ return raw[:160]
+
+
+def _short_id(raw: Any) -> str:
+ s = str(raw or "")
+ return s[:12]
+
+
+def _parse_health(status: str) -> str:
+ """Extract a healthcheck word from the docker "Status" line if present.
+
+ docker's container-list "Status" string includes "(healthy)" or
+ "(unhealthy)" when a healthcheck is configured. We surface just that
+ one-word state and fall back to "—" otherwise — no other free-form
+ text from the daemon leaks out.
+ """
+ if not status:
+ return "—"
+ low = status.lower()
+ if "(healthy)" in low:
+ return "healthy"
+ if "(unhealthy)" in low:
+ return "unhealthy"
+ if "(starting)" in low or "(health: starting)" in low:
+ return "starting"
+ return "—"
+
+
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _empty_export(node_fp: str) -> TopologyExport:
+ return TopologyExport(
+ node_fingerprint=node_fp,
+ generated_at=_now_iso(),
+ host_name="",
+ container_count=0,
+ network_count=0,
+ containers=[],
+ networks=[],
+ )
+
+
+# ---------- builder ------------------------------------------------------
+
+def build_export() -> TopologyExport:
+ """Sanitized snapshot of this node's docker topology.
+
+ Calls `docker_view.topology()` and re-projects every field through the
+ Pydantic whitelist above. If the proxy is unreachable (e.g. dev box
+ without docker-socket-proxy) we return an empty export rather than
+ raising — the public endpoint must never 500.
+ """
+ try:
+ node_fp = federation.node_fingerprint()
+ except Exception as exc: # noqa: BLE001 — keep endpoint defensive
+ _log.warning("topology_export.fp.error", error=str(exc))
+ node_fp = ""
+
+ try:
+ raw = docker_view.topology()
+ except Exception as exc: # noqa: BLE001 — docker proxy may be down
+ _log.warning("topology_export.docker.error", error=str(exc))
+ return _empty_export(node_fp)
+
+ # docker_view.topology() returns a dict with `containers`, `networks`,
+ # `host`, `error` fields. We treat any non-None error as "empty export"
+ # rather than partially leaking through whatever did succeed.
+ if raw.get("error"):
+ return _empty_export(node_fp)
+
+ raw_host = raw.get("host") or {}
+ host_name_raw = str(raw_host.get("name") or "")
+ # Truncate the docker host id — it can be the actual machine hostname.
+ # Keep it short, no domain. Defensive even though docker host names are
+ # generally low-sensitivity.
+ host_name = host_name_raw[:24]
+
+ raw_containers = raw.get("containers") or []
+ raw_networks = raw.get("networks") or []
+
+ containers: List[TopologyContainer] = []
+ for c in raw_containers[:MAX_CONTAINERS]:
+ nets_raw = c.get("networks") or []
+ net_names: List[str] = []
+ for nd in nets_raw:
+ nm = nd.get("name") if isinstance(nd, dict) else None
+ if nm:
+ net_names.append(str(nm)[:64])
+ containers.append(TopologyContainer(
+ name=str(c.get("name") or "?")[:64],
+ short_id=_short_id(c.get("id")),
+ image=_filter_image_name(c.get("image") or ""),
+ state=str(c.get("state") or "")[:24],
+ health=_parse_health(str(c.get("status") or "")),
+ networks=net_names[:12],
+ # docker_view doesn't currently surface the compose service label
+ # or started_at; leave them None until that lands.
+ service=None,
+ started_at=None,
+ ))
+
+ networks: List[TopologyNetwork] = []
+ for n in raw_networks[:MAX_NETWORKS]:
+ attached = n.get("containers") or []
+ networks.append(TopologyNetwork(
+ name=str(n.get("name") or "")[:64],
+ driver=str(n.get("driver") or "")[:24],
+ internal=bool(n.get("internal")),
+ container_count=len(attached),
+ ))
+
+ return TopologyExport(
+ node_fingerprint=node_fp,
+ generated_at=_now_iso(),
+ host_name=host_name,
+ container_count=len(containers),
+ network_count=len(networks),
+ containers=containers,
+ networks=networks,
+ )
diff --git a/tests/test_topology_export.py b/tests/test_topology_export.py
new file mode 100644
index 0000000..02b7c2a
--- /dev/null
+++ b/tests/test_topology_export.py
@@ -0,0 +1,313 @@
+"""Topology export — whitelist sanitization + endpoint contract.
+
+The big invariant: nothing from docker_view.topology() escapes that isn't
+in the Pydantic schema. We assert via model_fields introspection AND via a
+JSON-dump scan over a fixture that contains every dangerous field.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any, Dict, List
+from unittest.mock import patch
+
+import pytest
+from fastapi import FastAPI
+from fastapi.templating import Jinja2Templates
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine
+from starlette.middleware.sessions import SessionMiddleware
+
+from psyc import db
+from psyc.cockpit import docker_view, federation_routes
+from psyc.lines import federation, topology_export
+from psyc.lines.topology_export import (
+ TopologyContainer,
+ TopologyExport,
+ TopologyNetwork,
+ _filter_image_name,
+ build_export,
+)
+
+
+# ---------- fixtures ----------------------------------------------------
+
+@pytest.fixture
+def fresh_db(tmp_path, monkeypatch):
+ test_db = tmp_path / "test.db"
+ eng = create_engine(f"sqlite:///{test_db}", future=True)
+ db._metadata.create_all(eng, checkfirst=True)
+ monkeypatch.setattr(db, "_engine", eng)
+ monkeypatch.setattr(db, "DB_PATH", test_db)
+ yield test_db
+
+
+@pytest.fixture
+def fed_dir(tmp_path, monkeypatch):
+ d = tmp_path / "federation"
+ monkeypatch.setattr(federation, "FED_DIR", d)
+ monkeypatch.setattr(federation, "PRIVATE_KEY_PATH", d / "node.key")
+ monkeypatch.setattr(federation, "PUBLIC_KEY_PATH", d / "node.pub")
+ yield d
+
+
+@pytest.fixture(autouse=True)
+def reset_topology_cache():
+ if hasattr(federation_routes, "_TOPOLOGY_CACHE"):
+ federation_routes._TOPOLOGY_CACHE["payload"] = None
+ federation_routes._TOPOLOGY_CACHE["ts"] = 0.0
+ yield
+
+
+# ---------- fixture data: hostile docker_view output --------------------
+
+# This payload has every leaky field docker_view *could* surface, plus
+# nested env-style data — used to prove the export is whitelist-only.
+_LEAKY_TOPOLOGY: Dict[str, Any] = {
+ "containers": [
+ {
+ "id": "abcdef1234567890ffff",
+ "name": "psyc-cockpit-1",
+ "image": "registry.example/psyc:1.2",
+ "state": "running",
+ "status": "Up 5 minutes (healthy)",
+ "networks": [
+ {"name": "backend", "ip": "172.20.0.5", "gateway": "172.20.0.1", "mac": "02:42:ac:14:00:05"},
+ {"name": "frontend", "ip": "172.21.0.7", "gateway": "172.21.0.1", "mac": "02:42:ac:15:00:07"},
+ ],
+ "ports": ["0.0.0.0:8767->8767/tcp"],
+ "published_ports": ["8767/tcp"],
+ # These are NOT current docker_view fields but defend in depth —
+ # if a future docker_view change adds them, sanitizer drops them.
+ "env": ["SECRET_TOKEN=abc123", "DB_PASSWORD=hunter2"],
+ "mounts": ["/var/run/docker.sock", "/etc/secrets:/secrets"],
+ "labels": {"com.docker.compose.project": "psyc", "secret_label": "shh"},
+ },
+ {
+ "id": "fedcba0987654321",
+ "name": "some-stopped",
+ "image": "alpine",
+ "state": "exited",
+ "status": "Exited (0) 2 hours ago",
+ "networks": [],
+ "ports": [],
+ "published_ports": [],
+ },
+ ],
+ "networks": [
+ {
+ "id": "n1", "name": "backend", "driver": "bridge", "scope": "local",
+ "internal": False, "subnet": "172.20.0.0/16", "gateway": "172.20.0.1",
+ "containers": [
+ {"id": "abcdef123456", "name": "psyc-cockpit-1", "ip": "172.20.0.5", "mac": "02:42:ac:14:00:05"},
+ ],
+ },
+ {
+ "id": "n2", "name": "internal-only", "driver": "bridge", "scope": "local",
+ "internal": True, "subnet": "10.99.0.0/16", "gateway": "10.99.0.1",
+ "containers": [],
+ },
+ ],
+ "host": {"name": "docker-host-secret-internal.example.com", "os": "linux", "ncpu": 8},
+ "error": None,
+ "proxy": "http://docker-socket-proxy:2375",
+}
+
+
+# Sensitive strings that MUST NOT appear anywhere in the export JSON.
+_FORBIDDEN_STRINGS = (
+ "SECRET_TOKEN", "DB_PASSWORD", "hunter2", "abc123",
+ "/var/run/docker.sock", "/etc/secrets",
+ "secret_label", "shh",
+ "172.20.0.5", "172.21.0.7", # IPs
+ "02:42:ac", # MAC prefix
+ "172.20.0.1", # gateway
+ "172.20.0.0/16", "10.99.0.0/16", # subnets
+ "0.0.0.0:8767", # port mapping
+ "internal.example.com", # full host
+)
+
+
+# ---------- model field introspection -----------------------------------
+
+def test_container_model_has_no_dangerous_fields():
+ fields = set(TopologyContainer.model_fields.keys())
+ # whitelist — must match the design contract exactly
+ assert fields == {
+ "name", "short_id", "image", "state", "health",
+ "networks", "service", "started_at",
+ }
+ # explicit deny-list, double-belt
+ for forbidden in ("env", "environment", "mounts", "volumes",
+ "labels", "ip", "ip_address", "ipaddress",
+ "ports", "published_ports", "mac", "gateway"):
+ assert forbidden not in fields, f"{forbidden} must not be a field"
+
+
+def test_network_model_has_no_dangerous_fields():
+ fields = set(TopologyNetwork.model_fields.keys())
+ assert fields == {"name", "driver", "internal", "container_count"}
+ for forbidden in ("subnet", "gateway", "labels", "ipam",
+ "containers", "scope", "id"):
+ assert forbidden not in fields, f"{forbidden} must not be a field"
+
+
+def test_export_model_top_level_fields():
+ fields = set(TopologyExport.model_fields.keys())
+ assert fields == {
+ "node_fingerprint", "generated_at", "host_name",
+ "container_count", "network_count", "containers", "networks",
+ }
+
+
+# ---------- image-name filter -------------------------------------------
+
+def test_filter_image_strips_basic_auth_prefix():
+ # user:pass@host/repo:tag → host/repo:tag (creds gone)
+ assert _filter_image_name("user:pass@host/repo:tag") == "host/repo:tag"
+
+
+def test_filter_image_drops_digest_suffix():
+ assert _filter_image_name(
+ "nginx:1.25@sha256:abcdef0123"
+ ) == "nginx:1.25"
+
+
+def test_filter_image_passes_clean_refs_untouched():
+ assert _filter_image_name("psyc:latest") == "psyc:latest"
+ assert _filter_image_name(
+ "ghcr.io/example/psyc:v0.3.1"
+ ) == "ghcr.io/example/psyc:v0.3.1"
+
+
+def test_filter_image_handles_empty():
+ assert _filter_image_name("") == ""
+ assert _filter_image_name(None) == "" # type: ignore[arg-type]
+
+
+# ---------- build_export contract ---------------------------------------
+
+def test_build_export_returns_empty_when_docker_view_raises(fresh_db, fed_dir, monkeypatch):
+ def boom():
+ raise docker_view.DockerProxyError("connection refused")
+ monkeypatch.setattr(docker_view, "topology", boom)
+ out = build_export()
+ assert isinstance(out, TopologyExport)
+ assert out.container_count == 0
+ assert out.containers == []
+ assert out.networks == []
+ # fingerprint is still real (federation key was generated)
+ assert len(out.node_fingerprint) == 32
+
+
+def test_build_export_returns_empty_when_docker_view_reports_error(fresh_db, fed_dir, monkeypatch):
+ monkeypatch.setattr(docker_view, "topology", lambda: {
+ "containers": [], "networks": [], "host": {"name": "x"},
+ "error": "containers: refused", "proxy": "x",
+ })
+ out = build_export()
+ assert out.container_count == 0
+ assert out.containers == []
+
+
+def test_build_export_sanitizes_every_field(fresh_db, fed_dir, monkeypatch):
+ monkeypatch.setattr(docker_view, "topology", lambda: _LEAKY_TOPOLOGY)
+ out = build_export()
+ # Containers came through, but as TopologyContainer (no leaky attrs).
+ assert out.container_count == 2
+ by_name = {c.name: c for c in out.containers}
+ cp = by_name["psyc-cockpit-1"]
+ assert cp.short_id == "abcdef123456"
+ assert cp.image == "registry.example/psyc:1.2"
+ assert cp.state == "running"
+ assert cp.health == "healthy"
+ assert cp.networks == ["backend", "frontend"]
+ assert cp.service is None
+ # Networks came through, sanitized.
+ assert out.network_count == 2
+ by_net = {n.name: n for n in out.networks}
+ assert by_net["backend"].driver == "bridge"
+ assert by_net["backend"].internal is False
+ assert by_net["backend"].container_count == 1
+ assert by_net["internal-only"].internal is True
+
+
+def test_export_json_contains_no_dangerous_strings(fresh_db, fed_dir, monkeypatch):
+ """Strict no-leak: serialize and grep for everything sensitive."""
+ monkeypatch.setattr(docker_view, "topology", lambda: _LEAKY_TOPOLOGY)
+ out = build_export()
+ blob = json.dumps(out.model_dump(mode="json"))
+ for forbidden in _FORBIDDEN_STRINGS:
+ assert forbidden not in blob, f"leak: {forbidden!r} appeared in export JSON"
+
+
+def test_build_export_caps_at_max_containers(fresh_db, fed_dir, monkeypatch):
+ fake = {
+ "containers": [
+ {"id": f"id{i:04d}", "name": f"c{i}", "image": "x", "state": "running", "status": "Up", "networks": []}
+ for i in range(topology_export.MAX_CONTAINERS + 50)
+ ],
+ "networks": [], "host": {"name": "h"}, "error": None, "proxy": "",
+ }
+ monkeypatch.setattr(docker_view, "topology", lambda: fake)
+ out = build_export()
+ assert out.container_count == topology_export.MAX_CONTAINERS
+
+
+# ---------- HTTP endpoint -----------------------------------------------
+
+def _mk_app() -> FastAPI:
+ app = FastAPI()
+ app.add_middleware(SessionMiddleware, secret_key="test-secret")
+ from pathlib import Path as _Path
+ here = _Path(__file__).resolve().parent.parent / "src" / "psyc" / "cockpit" / "templates"
+ templates = Jinja2Templates(directory=str(here))
+ federation_routes.register(app, templates)
+ return app
+
+
+def test_federation_topology_endpoint_returns_json_with_cors(fresh_db, fed_dir, monkeypatch):
+ monkeypatch.setattr(docker_view, "topology", lambda: _LEAKY_TOPOLOGY)
+ client = TestClient(_mk_app())
+ r = client.get("/federation/topology")
+ assert r.status_code == 200
+ assert r.headers.get("access-control-allow-origin") == "*"
+ data = r.json()
+ # Schema check.
+ for key in ("node_fingerprint", "generated_at", "host_name",
+ "container_count", "network_count", "containers", "networks"):
+ assert key in data
+ assert data["container_count"] == 2
+ assert len(data["containers"]) == 2
+ # No leaks in the wire response either.
+ blob = r.text
+ for forbidden in _FORBIDDEN_STRINGS:
+ assert forbidden not in blob, f"leak via endpoint: {forbidden!r}"
+
+
+def test_federation_topology_endpoint_resilient_when_docker_unavailable(fresh_db, fed_dir, monkeypatch):
+ def boom():
+ raise docker_view.DockerProxyError("proxy down")
+ monkeypatch.setattr(docker_view, "topology", boom)
+ client = TestClient(_mk_app())
+ r = client.get("/federation/topology")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["container_count"] == 0
+ assert data["containers"] == []
+
+
+def test_federation_topology_cache_short_circuits_repeated_calls(fresh_db, fed_dir, monkeypatch):
+ """Within TTL, a second hit must not re-call docker_view."""
+ calls = {"n": 0}
+
+ def counted():
+ calls["n"] += 1
+ return _LEAKY_TOPOLOGY
+
+ monkeypatch.setattr(docker_view, "topology", counted)
+ client = TestClient(_mk_app())
+ r1 = client.get("/federation/topology")
+ r2 = client.get("/federation/topology")
+ assert r1.status_code == 200 and r2.status_code == 200
+ assert calls["n"] == 1, "cache should suppress the second docker_view call"