merge topology: per-peer container view in federation network detail panel

This commit is contained in:
m17hr1l
2026-06-07 01:59:23 +02:00
7 changed files with 763 additions and 3 deletions

View File

@@ -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."""

View File

@@ -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")

View File

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

View File

@@ -589,6 +589,20 @@
</div>`
: "";
// 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)
? `<div class="fn-detail-sec fn-topology-sec" data-topo-fp="${esc(n.fp)}">
<h4>containers <span class="fn-remote-status fn-topo-status">fetching…</span></h4>
<div class="fn-topology-body">loading${topoHost ? ` from <code>${esc(topoHost)}</code>` : " local topology"}…</div>
</div>`
: "";
const html = `
<div class="td-head">
<span class="td-kind ${kindCls}">${esc(kindLabel)}</span>
@@ -603,6 +617,7 @@
${quorum}
${translog}
${remote}
${topoSec}
${actions}
</div>
`;
@@ -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://<domain>/.
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 = `<span class="muted">couldn't reach ${esc(domain || "self")} — endpoint may be offline, blocking cross-origin, or this peer hasn't been upgraded yet.</span>`;
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 = `<div class="fn-topology-meta">host <code>${esc(host)}</code> · ${cCount} containers · ${nCount} networks${gen ? ` · generated <code>${esc(gen)}</code>` : ""}</div><span class="muted">no containers reported — docker-socket-proxy may be down on this node.</span>`;
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
? `<ul class="fn-topology-list">${netShown.map(net => {
const drv = esc(net.driver || "—");
const ct = Number(net.container_count || 0);
const internalChip = net.internal ? ` <span class="fn-topo-int">internal</span>` : "";
return `<li><span class="fn-topo-netname">${esc(net.name || "?")}</span> <span class="muted">· ${drv} · ${ct} container${ct === 1 ? "" : "s"}</span>${internalChip}</li>`;
}).join("")}${netHidden ? `<li class="muted">+ ${netHidden} more</li>` : ""}</ul>`
: `<span class="muted">no networks reported</span>`;
const contList = contShown.length
? `<ul class="fn-topology-list">${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 !== "—")
? ` <span class="fn-topo-health fn-topo-h-${esc(c.health)}">${esc(c.health)}</span>` : "";
const svc = c.service ? ` <span class="muted">[${esc(c.service)}]</span>` : "";
return `<li><span class="fn-topo-state-dot ${dotCls}" title="${stateTxt}"></span><span class="fn-topo-cname">${esc(c.name || "?")}</span>${svc}${healthPill}<div class="fn-topo-image">${esc(img)}</div></li>`;
}).join("")}${contHidden ? `<li class="muted">+ ${contHidden} more</li>` : ""}</ul>`
: `<span class="muted">no containers reported</span>`;
if (statusEl) statusEl.textContent = domain ? "peer topology" : "local topology";
bodyEl.innerHTML = `
<div class="fn-topology-meta">host <code>${esc(host)}</code> · ${cCount} container${cCount === 1 ? "" : "s"} · ${nCount} network${nCount === 1 ? "" : "s"}${gen ? ` · generated <code>${esc(gen)}</code>` : ""}</div>
<div class="fn-topology-cols">
<div><div class="fn-topology-h">networks <span class="muted">(${networks.length})</span></div>${netsList}</div>
<div><div class="fn-topology-h">containers <span class="muted">(${containers.length})</span></div>${contList}</div>
</div>`;
}
// ---------- idle animation ------------------------------------------
let energyBudget = 40;
function loop() {

View File

@@ -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",

View File

@@ -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,
)

View File

@@ -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"