Compare commits
8 Commits
8587e079bb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f12e344a8 | ||
|
|
00cd8ca252 | ||
|
|
77e4cb6ab9 | ||
|
|
9ba4cd2189 | ||
|
|
155d6eaaf9 | ||
|
|
d998be276b | ||
|
|
367f17a013 | ||
|
|
a8216d00ef |
@@ -45,7 +45,7 @@ while IFS= read -r line; do
|
||||
if PSYC_PROD_HOST="$SSH_TARGET" \
|
||||
PSYC_PROD_PATH="$REMOTE_PATH" \
|
||||
PSYC_PROD_URL="$PUBLIC_URL" \
|
||||
bash "$(dirname "$0")/deploy.sh"; then
|
||||
bash "$(dirname "$0")/deploy.sh" < /dev/null; then
|
||||
OK+=("$LABEL")
|
||||
else
|
||||
FAIL+=("$LABEL")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -35,6 +35,19 @@ app = FastAPI(title="psyc Operations Cockpit", version="0.1.0")
|
||||
app.add_middleware(SessionMiddleware, secret_key=adminauth.session_secret(), max_age=3600)
|
||||
app.mount("/static", StaticFiles(directory=str(HERE / "static")), name="static")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def _security_headers(request: Request, call_next):
|
||||
"""Defense-in-depth headers. CSP is intentionally NOT set yet — the
|
||||
cockpit currently uses inline scripts in base.html / journey.html /
|
||||
federation_explore.html which would need nonces or extraction first."""
|
||||
resp = await call_next(request)
|
||||
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
resp.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||
resp.headers.setdefault("X-Frame-Options", "DENY")
|
||||
return resp
|
||||
|
||||
|
||||
pulse_routes.register(app, TEMPLATES)
|
||||
federation_routes.register(app, TEMPLATES)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -650,11 +673,21 @@
|
||||
// them from any cockpit page. The fetch is best-effort — if the peer is
|
||||
// unreachable, blocked by CSP, or older, we render whatever we got.
|
||||
|
||||
// Reject anything that isn't a bare hostname (+ optional port). Stops a
|
||||
// hostile peer-supplied `domain` value from steering a click to an
|
||||
// attacker-controlled URL via path/query injection or a different scheme.
|
||||
const DOMAIN_RE = /^[A-Za-z0-9._\-]+(:\d{1,5})?$/;
|
||||
|
||||
async function fetchPeerSelfView(domain, expectedFp) {
|
||||
const sec = detailEl.querySelector(`.fn-remote-sec[data-remote-fp="${expectedFp}"]`);
|
||||
if (!sec) return;
|
||||
const statusEl = sec.querySelector(".fn-remote-status");
|
||||
const bodyEl = sec.querySelector(".fn-remote-body");
|
||||
if (!DOMAIN_RE.test(domain || "")) {
|
||||
statusEl.textContent = "blocked";
|
||||
bodyEl.innerHTML = `<span class="muted">peer domain failed hostname validation — not fetching.</span>`;
|
||||
return;
|
||||
}
|
||||
const base = `https://${domain}`;
|
||||
let data = null;
|
||||
let kind = "";
|
||||
@@ -737,6 +770,105 @@
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------- 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 —
|
||||
// guard against a poisoned peer-supplied domain steering elsewhere.
|
||||
if (domain && !DOMAIN_RE.test(domain)) {
|
||||
statusEl.textContent = "blocked";
|
||||
bodyEl.innerHTML = `<span class="muted">peer domain failed hostname validation — not fetching topology.</span>`;
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
|
||||
@@ -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-v11";
|
||||
const STATIC_ASSETS = [
|
||||
"/static/cockpit.css",
|
||||
"/static/psyc-tokens.css",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<td class="lg-ts">{{ ((m.last_used or '')[:16] | replace('T', ' ')) or '—' }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/members/{{ m.id }}/revoke" class="queue-action"
|
||||
onsubmit="return confirm('Revoke {{ m.label }}? Their codes stop working immediately.');">
|
||||
data-confirm-revoke="member" data-confirm-name="{{ m.label }}">
|
||||
<button type="submit" class="btn btn-reject">revoke</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<button type="submit" class="btn btn-reject" {% if p.status == 'blocked' %}disabled{% endif %}>block</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/federation/peers/{{ p.domain }}/remove" class="queue-action"
|
||||
onsubmit="return confirm('Remove {{ p.domain }}? Their signals will no longer count toward quorum.');">
|
||||
data-confirm-revoke="peer" data-confirm-name="{{ p.domain }}">
|
||||
<button type="submit" class="btn">remove</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
@@ -39,6 +39,21 @@
|
||||
btn.setAttribute("aria-expanded", "false");
|
||||
}));
|
||||
});
|
||||
// data-driven confirms (used by /admin and /admin/federation revoke/remove
|
||||
// buttons; replaces inline onsubmit which was XSS-vulnerable when the
|
||||
// confirm prompt interpolated a member label or peer domain).
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll("form[data-confirm-revoke]").forEach(form => {
|
||||
form.addEventListener("submit", ev => {
|
||||
const kind = form.getAttribute("data-confirm-revoke") || "item";
|
||||
const name = form.getAttribute("data-confirm-name") || "";
|
||||
const msg = kind === "peer"
|
||||
? `Remove ${name}? Their signals will no longer count toward quorum.`
|
||||
: `Revoke ${name}? Their codes stop working immediately.`;
|
||||
if (!confirm(msg)) ev.preventDefault();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<h2>Source</h2>
|
||||
<dl>
|
||||
<dt>Type</dt><dd>{{ case.source_type }}</dd>
|
||||
<dt>Reference</dt><dd>{% if case.source_ref %}<a href="{{ case.source_ref }}" target="_blank" rel="noopener">{{ case.source_ref }}</a>{% else %}—{% endif %}</dd>
|
||||
<dt>Reference</dt><dd>{% if case.source_ref %}{% if case.source_ref.startswith(('http://', 'https://')) %}<a href="{{ case.source_ref }}" target="_blank" rel="noopener">{{ case.source_ref }}</a>{% else %}<code>{{ case.source_ref }}</code>{% endif %}{% else %}—{% endif %}</dd>
|
||||
<dt>Observed</dt><dd class="muted">{{ case.observed_at.strftime('%Y-%m-%d %H:%M UTC') }}</dd>
|
||||
<dt>Ingested</dt><dd class="muted">{{ case.ingested_at.strftime('%Y-%m-%d %H:%M UTC') }}</dd>
|
||||
</dl>
|
||||
|
||||
@@ -134,10 +134,13 @@
|
||||
<script>
|
||||
// The walk-to-peer param ("?peer=domain") tells the JS to focus that peer
|
||||
// in the graph as soon as the data lands.
|
||||
// Use tojson so values are honest JS literals — does not depend on the
|
||||
// subtle HTML-entity-vs-script-context parser rules. Empty values become
|
||||
// null/"" cleanly. Reach the values via PSYC_EXPLORE.* in the script.
|
||||
window.PSYC_EXPLORE = {
|
||||
selfFingerprint: "{{ fingerprint }}",
|
||||
selfDomain: "{{ domain }}",
|
||||
focusPeer: "{{ peer }}"
|
||||
selfFingerprint: {{ fingerprint | tojson }},
|
||||
selfDomain: {{ (domain or "") | tojson }},
|
||||
focusPeer: {{ (peer or "") | tojson }}
|
||||
};
|
||||
</script>
|
||||
<script src="/static/federation_explore.js" defer></script>
|
||||
|
||||
@@ -17,11 +17,13 @@ from sqlalchemy import (
|
||||
Table,
|
||||
Text,
|
||||
create_engine,
|
||||
event,
|
||||
func,
|
||||
insert,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from psyc import DATA_DIR, log
|
||||
from psyc.models import Case
|
||||
@@ -209,10 +211,31 @@ _engine: Optional[Engine] = None
|
||||
|
||||
|
||||
def engine(db_path: Path = DB_PATH) -> Engine:
|
||||
"""Lazy-init the SQLite engine.
|
||||
|
||||
Uses NullPool — SQLite doesn't benefit from connection pooling (it's a
|
||||
file, opens are cheap) and the default QueuePool starved the classify +
|
||||
federation + cockpit-request workers under real load. WAL journal mode
|
||||
+ a 30s busy timeout let readers and a writer share the file safely.
|
||||
"""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_engine = create_engine(f"sqlite:///{db_path}", future=True)
|
||||
_engine = create_engine(
|
||||
f"sqlite:///{db_path}",
|
||||
future=True,
|
||||
poolclass=NullPool,
|
||||
connect_args={"check_same_thread": False, "timeout": 30},
|
||||
)
|
||||
|
||||
@event.listens_for(_engine, "connect")
|
||||
def _sqlite_pragmas(dbapi_conn, _connection_record): # noqa: D401
|
||||
cur = dbapi_conn.cursor()
|
||||
cur.execute("PRAGMA journal_mode=WAL")
|
||||
cur.execute("PRAGMA synchronous=NORMAL")
|
||||
cur.execute("PRAGMA busy_timeout=30000")
|
||||
cur.close()
|
||||
|
||||
return _engine
|
||||
|
||||
|
||||
|
||||
@@ -16,9 +16,15 @@ import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# Hostname-with-optional-port pattern for peer domains. Reject anything else at
|
||||
# registration so a hostile domain string can't reach a render context where
|
||||
# it could break out of an HTML attr or JS string.
|
||||
_DOMAIN_RE = re.compile(r"^[A-Za-z0-9._\-]+(:\d{1,5})?$")
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -420,7 +426,17 @@ def _row_to_peer(row: Dict[str, Any]) -> Peer:
|
||||
|
||||
|
||||
def register_peer(domain: str, fingerprint: str, pubkey_pem: str, status: str = "unknown") -> None:
|
||||
"""Insert or update a peer in the registry. Idempotent on `domain`."""
|
||||
"""Insert or update a peer in the registry. Idempotent on `domain`.
|
||||
|
||||
Rejects malformed domain strings — only hostname chars + optional :port.
|
||||
Closes a stored-XSS hole where a hostile `domain` would have been rendered
|
||||
into the admin federation page's confirm() prompt.
|
||||
"""
|
||||
domain = (domain or "").strip()
|
||||
if not _DOMAIN_RE.match(domain):
|
||||
raise ValueError(f"invalid domain: {domain!r}")
|
||||
if fingerprint and not re.fullmatch(r"[0-9a-fA-F]{32}", fingerprint):
|
||||
raise ValueError(f"invalid fingerprint: {fingerprint!r}")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
existing = db.get_peer(domain)
|
||||
discovered_at = existing["discovered_at"] if existing else now
|
||||
|
||||
228
src/psyc/lines/topology_export.py
Normal file
228
src/psyc/lines/topology_export.py
Normal 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,
|
||||
)
|
||||
@@ -230,3 +230,33 @@ def test_peer_registry_crud(fresh_db, fed_dir):
|
||||
|
||||
federation.remove_peer("peer.example")
|
||||
assert federation.list_peers() == []
|
||||
|
||||
|
||||
def test_register_peer_rejects_malformed_domain(fresh_db, fed_dir):
|
||||
"""XSS guard: domain must look like a hostname (+ optional :port)."""
|
||||
import pytest
|
||||
bad = [
|
||||
"evil.com'); alert(1); //",
|
||||
"evil.com<script>",
|
||||
"evil.com onclick=alert(1)",
|
||||
"",
|
||||
"evil com", # space
|
||||
"/etc/passwd",
|
||||
"evil.com/?phish=1",
|
||||
]
|
||||
for d in bad:
|
||||
with pytest.raises(ValueError):
|
||||
federation.register_peer(d, "ff" * 16, "PEM")
|
||||
# And good ones still pass:
|
||||
for d in ["peer.example.com", "peer.example.com:8443", "peer-1.example", "127.0.0.1:8767"]:
|
||||
federation.register_peer(d, "ff" * 16, "PEM")
|
||||
federation.remove_peer(d)
|
||||
|
||||
|
||||
def test_register_peer_rejects_malformed_fingerprint(fresh_db, fed_dir):
|
||||
"""Defense-in-depth: fingerprint must be 32 hex chars."""
|
||||
import pytest
|
||||
with pytest.raises(ValueError):
|
||||
federation.register_peer("peer.example", "not-hex", "PEM")
|
||||
with pytest.raises(ValueError):
|
||||
federation.register_peer("peer.example", "ff" * 8, "PEM") # too short
|
||||
|
||||
313
tests/test_topology_export.py
Normal file
313
tests/test_topology_export.py
Normal 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"
|
||||
Reference in New Issue
Block a user