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 + ? `` + : `no networks reported`; + + const contList = contShown.length + ? `` + : `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"