Compare commits

...

31 Commits

Author SHA1 Message Date
m17hr1l
4f12e344a8 xss fixes from audit:
- F1 case_detail.html: scheme-check source_ref href (block javascript: URLs)
- F2 admin.html / F3 admin_federation.html: replace inline onsubmit confirm()
  with data-attr + global handler in base.html (no more label/domain
  interpolation into onsubmit attribute string)
- federation.register_peer: validate hostname + fingerprint regex at ingest
- federation_explore.html: window.PSYC_EXPLORE via | tojson
- federation_network.js: DOMAIN_RE guard on peer-supplied domain before
  building cross-origin fetch URL (also closes open-redirect via 'open
  their explorer' button)
- app.py: nosniff + Referrer-Policy: no-referrer + X-Frame-Options: DENY
- sw.js: psyc-v11 cache bump

CSP deferred — needs inline scripts moved to external files first.
Tests: +2 cases, 245/245 green.
2026-06-07 14:23:55 +02:00
m17hr1l
00cd8ca252 db: NullPool + WAL + busy_timeout — fixes QueuePool exhaustion under federation+classify load 2026-06-07 11:57:07 +02:00
m17hr1l
77e4cb6ab9 deploy-all: redirect deploy.sh stdin from /dev/null so loop doesn't drop hosts 2026-06-07 02:06:45 +02:00
m17hr1l
9ba4cd2189 merge topology: per-peer container view in federation network detail panel 2026-06-07 01:59:23 +02:00
m17hr1l
155d6eaaf9 stage-topo-d topology-export: CLI fed-topology + SW v10 2026-06-07 01:58:27 +02:00
m17hr1l
d998be276b stage-topo-c topology-export: federation network panel renders peer containers 2026-06-07 01:57:29 +02:00
m17hr1l
367f17a013 stage-topo-b topology-export: /federation/topology endpoint + CORS cache 2026-06-07 01:56:09 +02:00
m17hr1l
a8216d00ef stage-topo-a topology-export: sanitized public docker snapshot module + tests 2026-06-07 01:55:49 +02:00
m17hr1l
8587e079bb federation/network: fetch peer's own /federation/explore/data on click + render their self-view inline (their peers, vouches in/out, transitive, translog head) 2026-06-07 01:21:58 +02:00
m17hr1l
cef3bcb1ed merge explore: public transparent federation explorer with cross-jump 2026-06-07 01:19:56 +02:00
m17hr1l
9ab3271bc8 stage-exp-f explore: tests 2026-06-07 01:17:11 +02:00
m17hr1l
c2bd68e246 stage-exp-e explore: link from home + info endpoint 2026-06-07 01:16:32 +02:00
m17hr1l
587fd07d38 stage-exp-d explore: JS — cross-jump navigation + verify button 2026-06-07 01:16:02 +02:00
m17hr1l
ca6ba83950 stage-exp-c explore: HTML template + landing layout 2026-06-07 01:13:51 +02:00
m17hr1l
a10203d8f1 stage-exp-b explore: public routes + CORS on existing public endpoints 2026-06-07 01:12:25 +02:00
m17hr1l
56466c334d stage-exp-a explore: public payload builder + tests 2026-06-07 01:11:17 +02:00
m17hr1l
351e16c3ce inference: openai-compatible mode + bearer auth (for api.neuronetz.ai etc.) 2026-06-07 01:09:19 +02:00
m17hr1l
2c7f71eff8 deploy: scripts/deploy-all.sh + hosts.example for multi-node federation rollouts 2026-06-07 01:03:31 +02:00
m17hr1l
925bf76a0b merge network-detail: rich detail panel, corroboration edges, 24h timeline, search 2026-06-07 01:01:42 +02:00
m17hr1l
0d9baef4c8 stage-netd-f network detail: tests for admin enrichment (stats/corroboration/timeline) 2026-06-07 01:00:39 +02:00
m17hr1l
980cf74b76 stage-netd-d cockpit SW: bump CACHE_VERSION to psyc-v7 for network detail CSS+JS 2026-06-07 00:57:53 +02:00
m17hr1l
70b6af6a35 stage-netd-c network detail: rich detail panel + hover tooltips + search/intensity + timeline JS 2026-06-07 00:57:49 +02:00
m17hr1l
15749e050e stage-netd-b network detail: corroboration edges + timeline strip (CSS + template) 2026-06-07 00:57:44 +02:00
m17hr1l
c6c5d3b2ea stage-netd-a network detail: enrich peer stats (signals/severity/vouches/quorum) 2026-06-07 00:52:41 +02:00
m17hr1l
e33c5b41f5 merge network-view: federation graph per node with vouches + signal flow 2026-06-07 00:43:13 +02:00
m17hr1l
865be2e239 stage-net-f network view: tests 2026-06-07 00:42:11 +02:00
m17hr1l
ff44e9e450 stage-net-e network view: CLI fed-network command 2026-06-07 00:40:43 +02:00
m17hr1l
5950d34deb stage-net-d network view: cockpit page + JS force-directed graph 2026-06-07 00:40:13 +02:00
m17hr1l
5ff6d80333 stage-net-c network view: transitive fetcher + admin data endpoint 2026-06-07 00:37:32 +02:00
m17hr1l
6dcaae39c3 stage-net-b network view: public endpoint + signed payload 2026-06-07 00:37:12 +02:00
m17hr1l
fbad78a611 stage-net-a network view: data model + local view builder 2026-06-07 00:36:29 +02:00
27 changed files with 6125 additions and 28 deletions

3
.gitignore vendored
View File

@@ -14,6 +14,9 @@ data/
.env
.env.local
# per-operator federation host list (SSH targets are sensitive)
scripts/hosts
# editors
.vscode/
.idea/

65
scripts/deploy-all.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Deploy the current main commit to every federation host listed in
# scripts/hosts (one node per line: LABEL SSH_TARGET REMOTE_PATH PUBLIC_URL).
# Loops scripts/deploy.sh against each. Bails on first failure unless --keep-going.
set -euo pipefail
HOSTS_FILE="${PSYC_HOSTS_FILE:-$(dirname "$0")/hosts}"
KEEP_GOING=0
for arg in "$@"; do
case "$arg" in
--keep-going) KEEP_GOING=1 ;;
-h|--help)
echo "usage: $0 [--keep-going]"
echo " reads $HOSTS_FILE (override with PSYC_HOSTS_FILE=...)"
exit 0
;;
esac
done
if [[ ! -f "$HOSTS_FILE" ]]; then
echo "no hosts file at $HOSTS_FILE — copy scripts/hosts.example to scripts/hosts and edit" >&2
exit 2
fi
declare -a OK=() FAIL=()
while IFS= read -r line; do
# skip blanks + comments
[[ -z "${line// /}" || "${line# }" == \#* ]] && continue
# shellcheck disable=SC2206
parts=($line)
if [[ ${#parts[@]} -lt 4 ]]; then
echo "[deploy-all] skipping malformed line: $line" >&2
continue
fi
LABEL="${parts[0]}"
SSH_TARGET="${parts[1]}"
REMOTE_PATH="${parts[2]}"
PUBLIC_URL="${parts[3]}"
echo
echo "════════════════════════════════════════════════════════════════"
echo " deploying → $LABEL ($SSH_TARGET:$REMOTE_PATH$PUBLIC_URL)"
echo "════════════════════════════════════════════════════════════════"
if PSYC_PROD_HOST="$SSH_TARGET" \
PSYC_PROD_PATH="$REMOTE_PATH" \
PSYC_PROD_URL="$PUBLIC_URL" \
bash "$(dirname "$0")/deploy.sh" < /dev/null; then
OK+=("$LABEL")
else
FAIL+=("$LABEL")
if [[ $KEEP_GOING -ne 1 ]]; then
echo "[deploy-all] $LABEL failed — stopping. pass --keep-going to continue past failures." >&2
break
fi
fi
done < "$HOSTS_FILE"
echo
echo "════════════════════════════════════════════════════════════════"
echo " summary"
echo "════════════════════════════════════════════════════════════════"
echo " ok: ${OK[*]:-(none)}"
echo " failed: ${FAIL[*]:-(none)}"
[[ ${#FAIL[@]} -eq 0 ]]

6
scripts/hosts.example Normal file
View File

@@ -0,0 +1,6 @@
# Federation hosts — one node per line.
# Format: LABEL SSH_TARGET REMOTE_PATH PUBLIC_URL
# Lines starting with # are ignored. Copy to scripts/hosts and edit.
prod neuronetz@cloud.neuronetz.ai /home/neuronetz/docker-public/neuro-psyc https://psyc.neuronetz.ai
sto user@sto-host.example /path/to/neuro-psyc https://psyc.maschinen-stockert.de
bittomine user@bittomine-host.example /path/to/neuro-psyc https://psyc.bittomine.com

View File

@@ -13,7 +13,7 @@ import httpx
import typer
from psyc import db, log
from psyc.lines import discovery, federation, pulse, translog
from psyc.lines import discovery, federation, network_view, pulse, topology_export, translog
from psyc.result import Err, Ok
@@ -274,6 +274,70 @@ def register(typer_app: typer.Typer) -> None:
f" id={e.id:5d} {e.entry_type:6s} {e.timestamp[:19]} hash={e.entry_hash[:16]}"
)
# ---------- network view ------------------------------------------
@typer_app.command("fed-network")
def fed_network() -> None:
"""Print the local federation network view — nodes, vouches, stats."""
db.init_db()
view = network_view.build_local_view()
# Nodes table.
typer.echo("NODES")
typer.echo(f" {'fingerprint':<34} {'label':<32} {'status':<9} dist")
for n in view.nodes:
fp = f"{n.fingerprint[:8]}{n.fingerprint[-8:]}" if len(n.fingerprint) >= 16 else n.fingerprint
label = (n.label or "")[:30]
typer.echo(f" {fp:<34} {label:<32} {n.status:<9} {n.distance}")
# Vouches breakdown.
our_fp = view.nodes[0].fingerprint
vouch_out = [e for e in view.edges if e.kind == "vouch" and e.source_fingerprint == our_fp]
vouch_in = [e for e in view.edges if e.kind == "vouch" and e.target_fingerprint == our_fp]
bidir = [e for e in vouch_out if e.bidirectional]
typer.echo("")
typer.echo("VOUCHES")
if not vouch_out and not vouch_in and not bidir:
typer.echo(" (no vouches)")
else:
for e in vouch_out:
arrow = "" if e.bidirectional else ""
fp = f"{e.target_fingerprint[:8]}{e.target_fingerprint[-8:]}"
typer.echo(f" us {arrow} {fp}")
for e in vouch_in:
fp = f"{e.source_fingerprint[:8]}{e.source_fingerprint[-8:]}"
typer.echo(f" {fp} → us")
# Signal edges.
sig_edges = [e for e in view.edges if e.kind == "signal"]
typer.echo("")
typer.echo("SIGNALS (24h)")
if not sig_edges:
typer.echo(" (no signals)")
else:
for e in sig_edges:
fp = f"{e.source_fingerprint[:8]}{e.source_fingerprint[-8:]}"
typer.echo(f" from {fp}: {int(e.weight)}")
# Stats footer.
typer.echo("")
typer.echo("STATS")
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

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

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, pulse, translog
from psyc.lines import discovery, federation, network_view, pulse, topology_export, translog
from psyc.result import Err
@@ -30,6 +30,29 @@ _FEED_TTL = 60.0
_PUBLIC_PEERS_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None}
_PUBLIC_PEERS_TTL = 60.0
# And again for the public federation-network payload (signed JSON view).
_PUBLIC_NETWORK_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None}
_PUBLIC_NETWORK_TTL = 60.0
# Explore-view cache. The builder fans out to trusted peers' explore feeds
# for the distance-2 snapshot, so a polled hit must NEVER trigger that walk.
_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.
_CORS_HEADERS: Dict[str, str] = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
}
def _admin_ok(request: Request) -> bool:
return bool(request.session.get("admin_ok"))
@@ -51,6 +74,48 @@ def _cached_public_peers() -> Any:
return _PUBLIC_PEERS_CACHE["payload"]
def _cached_public_network() -> Dict[str, Any]:
now = time.time()
if _PUBLIC_NETWORK_CACHE["payload"] is None or (now - _PUBLIC_NETWORK_CACHE["ts"]) > _PUBLIC_NETWORK_TTL:
_PUBLIC_NETWORK_CACHE["payload"] = network_view.build_public_view()
_PUBLIC_NETWORK_CACHE["ts"] = now
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.
Domain is recorded into the payload's `node.domain` field, so a fresh
cache slot per host avoids serving the wrong reflected name.
"""
now = time.time()
cached_domain = _EXPLORE_CACHE.get("domain")
if (
_EXPLORE_CACHE["payload"] is None
or (now - _EXPLORE_CACHE["ts"]) > _EXPLORE_TTL
or cached_domain != domain
):
_EXPLORE_CACHE["payload"] = network_view.build_explore_view(node_domain=domain)
_EXPLORE_CACHE["ts"] = now
_EXPLORE_CACHE["domain"] = domain
return _EXPLORE_CACHE["payload"]
def _public_json(payload: Any) -> JSONResponse:
"""JSONResponse with the public-CORS header set."""
return JSONResponse(payload, headers=_CORS_HEADERS)
def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
"""Mount all federation routes onto `app`."""
@@ -168,20 +233,25 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
@app.get("/federation/info")
def federation_info() -> JSONResponse:
return JSONResponse({
return _public_json({
"fingerprint": federation.node_fingerprint(),
"version": federation.FEED_VERSION,
"feed": federation.FEED_PATH,
"key": "/federation/key",
"explore": "/federation/explore",
})
@app.get("/federation/key", response_class=PlainTextResponse)
def federation_key() -> PlainTextResponse:
return PlainTextResponse(federation.public_key_pem(), media_type="text/plain")
return PlainTextResponse(
federation.public_key_pem(),
media_type="text/plain",
headers=_CORS_HEADERS,
)
@app.get("/federation/feed")
def federation_feed() -> JSONResponse:
return JSONResponse(_cached_feed())
return _public_json(_cached_feed())
@app.get("/federation/peers/public")
def federation_peers_public() -> JSONResponse:
@@ -190,14 +260,35 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
Only trusted peers leak; unknown + blocked are internal state and must
never appear here.
"""
return JSONResponse(_cached_public_peers())
return _public_json(_cached_public_peers())
@app.get("/federation/network")
def federation_network_public() -> JSONResponse:
"""Signed federation-network attestation — for transitive-view fetchers.
Mirrors /federation/peers/public in spirit but adds our outbound vouches
so a fetcher can reconstruct the local web-of-trust shape. Trusted peers
only — never unknown or blocked. Signal hashes are deliberately omitted.
"""
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")
def federation_vouches() -> JSONResponse:
"""Vouches WE have issued. Peers fetch this to learn who we trust."""
return JSONResponse({
return _public_json({
"fingerprint": federation.node_fingerprint(),
"vouches": [v.model_dump(mode="json") for v in federation.our_vouches()],
})
@@ -206,7 +297,7 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
def federation_log() -> JSONResponse:
"""Last 100 transparency-log entries, newest first."""
entries = translog.recent(limit=100)
return JSONResponse({
return _public_json({
"count": len(entries),
"entries": [e.model_dump(mode="json") for e in entries],
})
@@ -218,8 +309,41 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
head = translog.head()
head_hash = head.entry_hash if head else None
if isinstance(result, Err):
return JSONResponse({"error": result.reason, "head_hash": head_hash}, status_code=409)
return JSONResponse({"verified": result.value, "head_hash": head_hash})
return JSONResponse(
{"error": result.reason, "head_hash": head_hash},
status_code=409,
headers=_CORS_HEADERS,
)
return _public_json({"verified": result.value, "head_hash": head_hash})
# ---------- public explore page + data ---------------------------
@app.get("/federation/explore", response_class=HTMLResponse)
def federation_explore_page(request: Request) -> HTMLResponse:
"""Public transparency view — anyone can verify this network."""
host = request.url.hostname or ""
peer_param = request.query_params.get("peer", "").strip()
return TEMPLATES.TemplateResponse(
request,
"federation_explore.html",
{
"fingerprint": federation.node_fingerprint(),
"domain": host,
"peer": peer_param,
},
)
@app.get("/federation/explore/data")
def federation_explore_data(request: Request) -> JSONResponse:
"""Signed public explorer payload — peer counts, vouches, transitives.
Public, no auth, CORS-enabled. Cached so a polled hit never triggers
the distance-2 fan-out. See `network_view.build_explore_view` for the
no-leak contract.
"""
host = request.url.hostname or None
payload = _cached_explore(host)
return _public_json(payload)
# ---------- admin: vouches page ---------------------------------
@@ -300,6 +424,38 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
},
)
# ---------- admin: federation network view ----------------------
@app.get("/admin/federation/network", response_class=HTMLResponse)
def admin_federation_network(request: Request) -> HTMLResponse:
"""Cockpit page — force-directed federation map. Data lives at /data."""
if not _admin_ok(request):
return RedirectResponse("/admin", status_code=303)
# Build local stats up front so the header card renders even if the
# JS data-endpoint fetch fails (defensive — never give the operator a
# blank page).
view = network_view.build_local_view()
return TEMPLATES.TemplateResponse(
request,
"admin_federation_network.html",
{
"fingerprint": federation.node_fingerprint(),
"stats": view.stats,
},
)
@app.get("/admin/federation/network/data")
def admin_federation_network_data(request: Request) -> JSONResponse:
"""Full admin view — includes unknown/blocked peers + transitive peers.
Public /federation/network filters those out; this surface does not,
because it sits behind the admin gate and the operator needs to see
the real shape of the federation including the parts being ignored.
"""
if not _admin_ok(request):
raise HTTPException(status_code=403, detail="admin session required")
return JSONResponse(network_view.build_admin_view(include_transitive=True))
# ---------- admin: quorum config + per-peer/per-hash view -------
@app.get("/admin/federation/quorum", response_class=HTMLResponse)

View File

@@ -3,13 +3,19 @@
The cockpit venv has no torch; the fine-tuned model only runs inside the CUDA
container behind serve_model.py. This client reaches it over HTTP and degrades
gracefully — if the server is down, callers get None and fall back to rules.
Two backends are supported via PSYC_INFERENCE_MODE:
- "psyc" (default) — native serve_model.py, POST /infer
- "openai" — OpenAI-compatible / Ollama, POST /v1/chat/completions
A bearer token can be set via PSYC_INFERENCE_TOKEN; it is sent on every request
when present (psyc-native ignores it; api.neuronetz.ai requires it).
"""
from __future__ import annotations
import json
import os
from typing import Optional
from typing import Dict, Optional
import httpx
@@ -18,15 +24,31 @@ from psyc.lines.train import SEVERITY_INSTRUCTION, severity_features
from psyc.models import Case
INFERENCE_URL = os.environ.get("PSYC_INFERENCE_URL", "http://127.0.0.1:8771")
INFERENCE_URL = os.environ.get("PSYC_INFERENCE_URL", "http://127.0.0.1:8771")
INFERENCE_TOKEN = os.environ.get("PSYC_INFERENCE_TOKEN", "")
INFERENCE_MODE = os.environ.get("PSYC_INFERENCE_MODE", "psyc").lower()
INFERENCE_MODEL = os.environ.get("PSYC_INFERENCE_MODEL", "psyc-v5")
_log = log.get(__name__)
def _auth_headers() -> Dict[str, str]:
"""Bearer header when a token is set, empty dict otherwise."""
return {"Authorization": f"Bearer {INFERENCE_TOKEN}"} if INFERENCE_TOKEN else {}
def server_adapter(timeout: float = 2.0) -> Optional[str]:
"""Return the adapter the server is running, or None if it is unreachable."""
try:
with httpx.Client(timeout=timeout) as client:
if INFERENCE_MODE == "openai":
# OpenAI/Ollama exposes GET /v1/models — first available id wins.
resp = client.get(f"{INFERENCE_URL}/v1/models", headers=_auth_headers())
resp.raise_for_status()
data = resp.json().get("data") or []
if data:
return str(data[0].get("id") or INFERENCE_MODEL)
return INFERENCE_MODEL
resp = client.get(f"{INFERENCE_URL}/healthz")
resp.raise_for_status()
return resp.json().get("adapter")
@@ -45,18 +67,44 @@ def adapter_name(timeout: float = 2.0) -> Optional[str]:
def model_severity(case: Case, timeout: float = 15.0) -> Optional[str]:
"""Ask the live model to classify case severity. None if the server is down."""
payload = {
"instruction": SEVERITY_INSTRUCTION,
"input": json.dumps(severity_features(case), ensure_ascii=False),
"max_new_tokens": 16,
}
features_json = json.dumps(severity_features(case), ensure_ascii=False)
try:
with httpx.Client(timeout=timeout) as client:
resp = client.post(f"{INFERENCE_URL}/infer", json=payload)
resp.raise_for_status()
output = str(resp.json().get("output", "")).strip().lower()
if INFERENCE_MODE == "openai":
payload = {
"model": INFERENCE_MODEL,
"messages": [
{"role": "system", "content": SEVERITY_INSTRUCTION},
{"role": "user", "content": features_json},
],
"max_tokens": 16,
"temperature": 0.0,
}
resp = client.post(
f"{INFERENCE_URL}/v1/chat/completions",
json=payload,
headers=_auth_headers(),
)
resp.raise_for_status()
choices = resp.json().get("choices") or []
if not choices:
return None
output = str(choices[0].get("message", {}).get("content", "")).strip().lower()
else:
payload = {
"instruction": SEVERITY_INSTRUCTION,
"input": features_json,
"max_new_tokens": 16,
}
resp = client.post(
f"{INFERENCE_URL}/infer",
json=payload,
headers=_auth_headers(),
)
resp.raise_for_status()
output = str(resp.json().get("output", "")).strip().lower()
except httpx.HTTPError as exc:
_log.info("inference.unavailable", error=str(exc))
return None
_log.info("inference.severity", case_id=case.case_id, model_answer=output)
_log.info("inference.severity", case_id=case.case_id, model_answer=output, mode=INFERENCE_MODE)
return output

View File

@@ -748,6 +748,18 @@ body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr
text-shadow: 0 0 22px var(--accent-glow);
}
.hero-sub { margin: 6px 0 0; color: var(--muted); font-size: 13px; }
.hero-meta { margin: 10px 0 0; font-size: 12px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.hero-explore {
color: var(--accent);
font-family: ui-monospace, Menlo, Consolas, monospace;
text-decoration: none;
border: 1px solid var(--border);
padding: 3px 9px;
border-radius: 999px;
letter-spacing: 0.04em;
}
.hero-explore:hover { border-color: var(--accent); box-shadow: 0 0 10px var(--accent-glow); }
.hero-explore-sub { color: var(--muted); font-size: 11px; font-family: ui-monospace, Menlo, Consolas, monospace; }
.hero-cta {
display: inline-flex; align-items: center; gap: 6px;
padding: 9px 16px; border: 1px solid var(--accent); border-radius: 8px;
@@ -1166,3 +1178,714 @@ body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr
.stat-country { border-color: rgba(251,191,36,0.4); color: var(--amber); }
.stat-confidence { border-color: rgba(167,139,250,0.4); color: #c4b5fd; }
.stat-family { border-color: rgba(248,113,113,0.4); color: var(--red); }
/* ── federation network graph ──────────────────────────────── */
.fn-stats { display: flex; flex-wrap: wrap; gap: 10px; margin: 8px 0 18px; }
.fn-stat {
flex: 1; min-width: 120px;
background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px;
padding: 10px 14px;
}
.fn-stat-label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; }
.fn-stat-value { font-family: var(--font-display); font-size: 22px; font-weight: 700; color: var(--accent); margin-top: 4px; text-shadow: 0 0 12px var(--accent-glow); }
#federation-network-graph { display: block; width: 100%; height: 620px; cursor: grab; }
#federation-network-graph:active { cursor: grabbing; }
body.wide #federation-network-graph { height: 720px; }
.fn-edge { stroke: rgba(125,133,151,0.45); stroke-width: 1.2; fill: none; }
.fn-edge-label { fill: var(--muted); font-size: 9px; text-anchor: middle; font-family: ui-monospace, Menlo, monospace; opacity: 0.7; pointer-events: none; }
.fn-kind-vouch .fn-edge { stroke: rgba(74,222,128,0.7); stroke-width: 1.8; }
.fn-kind-vouch .fn-edge-label { fill: rgba(160,240,190,0.85); font-weight: 600; }
.fn-kind-signal .fn-edge { stroke: rgba(30,200,255,0.65); stroke-dasharray: 5 4; }
.fn-kind-signal .fn-edge-label { fill: rgba(170, 220, 255, 0.85); }
.fn-kind-knows .fn-edge { stroke: rgba(125,133,151,0.32); stroke-dasharray: 2 4; }
.fn-kind-knows .fn-edge-label { display: none; }
.fn-edge.alive { animation: fn-flow 1.6s linear infinite; }
.fn-edge.dim { opacity: 0.55; }
@keyframes fn-flow { to { stroke-dashoffset: -54; } }
#federation-network-graph.flow-off .fn-edge.alive { animation: none; }
@media (prefers-reduced-motion: reduce) { .fn-edge.alive { animation: none; } }
.fn-node { cursor: grab; }
.fn-node.dragging { cursor: grabbing; }
.fn-node circle, .fn-node rect { transition: filter 0.15s; }
.fn-node:hover circle, .fn-node:hover rect { filter: drop-shadow(0 0 10px var(--accent)); }
/* Self — accent-glowing rounded square. */
.fn-self rect {
fill: rgba(30,200,255,0.18); stroke: var(--accent); stroke-width: 2;
filter: drop-shadow(0 0 14px var(--accent-glow));
}
.fn-self .fn-label { fill: var(--accent); font-weight: 700; letter-spacing: 0.10em; font-size: 13px; }
/* Direct peers (distance=1). Status drives color. */
.fn-status-trusted circle { fill: rgba(74,222,128,0.12); stroke: var(--green); stroke-width: 2; }
.fn-status-vouched circle { fill: rgba(167,139,250,0.12); stroke: #a78bfa; stroke-width: 1.8; stroke-dasharray: 4 3; }
.fn-status-unknown circle { fill: rgba(125,133,151,0.10); stroke: var(--muted); stroke-width: 1.6; }
.fn-status-blocked circle { fill: rgba(248,113,113,0.10); stroke: var(--red); stroke-width: 1.6; }
/* Transitive (distance=2) — fade and shrink the stroke. */
.fn-distance-2 circle { opacity: 0.78; stroke-width: 1.2; }
.fn-distance-2 .fn-label { fill: var(--muted); font-size: 9.5px; }
.fn-distance-2 .fn-sublabel { display: none; }
.fn-label, .fn-sublabel { text-anchor: middle; pointer-events: none; font-family: var(--font-display); }
.fn-label { fill: var(--text); font-size: 11px; }
.fn-sublabel { fill: var(--muted); font-size: 9px; font-family: ui-monospace, Menlo, monospace; letter-spacing: 0.04em; }
.fn-node.selected circle, .fn-node.selected rect {
filter: drop-shadow(0 0 14px var(--accent));
}
.fn-node.selected .fn-label { fill: #eaf6ff; font-weight: 700; }
/* Legend swatches. */
.lg-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 50%; border: 1.5px solid; vertical-align: -1px; }
.fn-lg-self { border-color: var(--accent); background: rgba(30,200,255,0.18); }
.fn-lg-trusted { border-color: var(--green); background: rgba(74,222,128,0.18); }
.fn-lg-vouched { border-color: #a78bfa; background: rgba(167,139,250,0.18); }
.fn-lg-unknown { border-color: var(--muted); background: rgba(125,133,151,0.18); }
.fn-lg-blocked { border-color: var(--red); background: rgba(248,113,113,0.18); }
/* Detail status badge tinting. */
.fn-status-badge-self { color: var(--accent); border-color: var(--accent); background: rgba(30,200,255,0.10); }
.fn-status-badge-trusted { color: var(--green); border-color: var(--green); background: rgba(74,222,128,0.10); }
.fn-status-badge-vouched { color: #c4b5fd; border-color: #a78bfa; background: rgba(167,139,250,0.10); }
.fn-status-badge-unknown { color: var(--muted); border-color: var(--muted); }
.fn-status-badge-blocked { color: var(--red); border-color: var(--red); background: rgba(248,113,113,0.10); }
/* ---------- federation network — enriched detail layer ---------------- */
/* Per-node stat badge: small monospace pill sitting just below the
sublabel ("8 sig · 2 vch · 1 quo"). SVG <text> styled, not a real
HTML pill — we keep it inline with the node group for layout. */
.fn-stat-badge {
fill: var(--accent);
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 11px;
text-anchor: middle;
pointer-events: none;
opacity: 0.85;
letter-spacing: 0.02em;
}
.fn-distance-2 .fn-stat-badge { display: none; }
/* Corroboration edges — dotted faint accent, lower z visually. */
.fn-kind-corroborate .fn-edge {
stroke: var(--accent);
stroke-width: 1.1;
stroke-dasharray: 1 5;
stroke-linecap: round;
opacity: 0.28;
animation: fn-corr-pulse 3.2s ease-in-out infinite;
}
.fn-kind-corroborate .fn-edge-label {
fill: rgba(170, 220, 255, 0.55);
font-size: 8.5px;
display: none; /* surfaced via tooltip; chart stays calm */
}
.fn-kind-corroborate .fn-edge-grp { pointer-events: none; }
@keyframes fn-corr-pulse {
0%, 100% { stroke-opacity: 0.22; }
50% { stroke-opacity: 0.45; }
}
@media (prefers-reduced-motion: reduce) {
.fn-kind-corroborate .fn-edge { animation: none; }
}
#federation-network-graph.flow-off .fn-kind-corroborate .fn-edge { animation: none; }
/* Hover tooltip — absolutely positioned, accent-bordered HUD pill. */
.fn-tooltip {
position: absolute;
z-index: 50;
background: rgba(15, 17, 21, 0.96);
border: 1px solid var(--accent);
border-radius: 6px;
box-shadow: 0 0 18px var(--accent-glow), 0 6px 22px rgba(0,0,0,0.55);
padding: 8px 10px;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 11px;
color: var(--text);
line-height: 1.45;
pointer-events: none;
max-width: 320px;
white-space: nowrap;
display: none;
}
.fn-tooltip.is-visible { display: block; }
.fn-tooltip-title {
color: var(--accent);
font-family: var(--font-display);
font-size: 12px;
margin-bottom: 4px;
letter-spacing: 0.05em;
}
.fn-tooltip-row { display: flex; gap: 10px; }
.fn-tooltip-row .k { color: var(--muted); min-width: 70px; }
.fn-tooltip-row .v { color: var(--text); }
/* Search/filter bar above the graph. */
.fn-search-bar {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 10px;
}
.fn-search-bar label {
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.fn-search-input {
flex: 1;
max-width: 460px;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 10px;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 12px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.fn-search-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.fn-search-count { color: var(--muted); font-size: 11px; font-family: ui-monospace, Menlo, Consolas, monospace; }
/* Search dim/highlight states. */
.fn-node.dimmed { opacity: 0.15; }
.fn-node.match circle, .fn-node.match rect { stroke: var(--amber); stroke-width: 2.4; filter: drop-shadow(0 0 8px rgba(251,191,36,0.55)); }
.fn-edge-grp.dimmed { opacity: 0.08; }
/* Rich detail card — sits in the existing .topo-detail container. */
.fn-detail-card {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
margin-top: 10px;
}
.fn-detail-sec {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
min-width: 0; /* allow children to wrap */
}
.fn-detail-sec h4 {
margin: 0 0 8px;
font-size: 11px;
font-family: var(--font-display);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.10em;
font-weight: 600;
}
.fn-detail-sec .row { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; padding: 2px 0; }
.fn-detail-sec .row .k { color: var(--muted); }
.fn-detail-sec .row .v { color: var(--text); font-family: ui-monospace, Menlo, Consolas, monospace; word-break: break-all; }
.fn-detail-sec code {
font-size: 11px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 3px;
padding: 2px 5px;
word-break: break-all;
display: inline-block;
}
.fn-detail-sec .full-fp { font-size: 11px; line-height: 1.55; }
.fn-copy-btn {
display: inline-block;
background: transparent;
color: var(--accent);
border: 1px solid var(--border);
border-radius: 3px;
font-size: 10px;
padding: 1px 6px;
margin-left: 6px;
font-family: ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
letter-spacing: 0.04em;
}
.fn-copy-btn:hover { border-color: var(--accent); box-shadow: 0 0 8px var(--accent-glow); }
/* Severity chips inside the Signals section. */
.fn-sev-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.fn-sev-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-family: ui-monospace, Menlo, Consolas, monospace;
border: 1px solid var(--border);
background: var(--panel);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.fn-sev-chip .n { font-weight: 700; }
.fn-sev-critical { color: var(--red); border-color: var(--red); background: rgba(248,113,113,0.10); }
.fn-sev-high { color: var(--amber); border-color: var(--amber); background: rgba(251,191,36,0.10); }
.fn-sev-medium { color: #fde68a; border-color: rgba(253,224,71,0.55); background: rgba(253,224,71,0.06); }
.fn-sev-low { color: var(--muted); border-color: var(--border); }
/* IOC-type chips reuse the chip shell with muted accents. */
.fn-ioc-chip {
display: inline-flex; gap: 4px; padding: 2px 8px;
border-radius: 10px; font-size: 10px;
font-family: ui-monospace, Menlo, Consolas, monospace;
border: 1px solid var(--border); background: var(--panel);
color: var(--text);
}
.fn-ioc-chip .k { color: var(--accent); }
.fn-ioc-chip .n { color: var(--text); font-weight: 700; }
/* Quorum progress bar. */
.fn-quorum-bar {
position: relative;
height: 8px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
margin-top: 6px;
}
.fn-quorum-fill {
position: absolute; inset: 0 auto 0 0;
background: linear-gradient(90deg, var(--accent), var(--green));
box-shadow: 0 0 8px var(--accent-glow);
}
/* Translog list inside the detail card. */
.fn-trans-list {
list-style: none; margin: 0; padding: 0;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 11px;
max-height: 180px;
overflow-y: auto;
}
.fn-trans-list li {
display: flex; gap: 8px; padding: 3px 0;
border-bottom: 1px dashed var(--border);
}
.fn-trans-list .id { color: var(--muted); min-width: 38px; }
.fn-trans-list .type { color: var(--accent); min-width: 50px; }
.fn-trans-list .ts { color: var(--muted); }
.fn-trans-list .hash { color: var(--text); }
/* Clickable fingerprint chip — jumps to that peer in the graph. */
.fn-fp-jump {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 11px;
color: var(--accent);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1px 5px;
cursor: pointer;
margin: 2px 4px 2px 0;
display: inline-block;
}
.fn-fp-jump:hover { border-color: var(--accent); text-shadow: 0 0 8px var(--accent-glow); }
/* Action buttons inside the detail card. */
.fn-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.fn-action-btn {
display: inline-block;
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--accent);
background: var(--panel);
font-size: 12px;
font-family: ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
text-decoration: none;
}
.fn-action-btn:hover { border-color: var(--accent); box-shadow: 0 0 10px var(--accent-glow); text-decoration: none; }
/* 24h timeline strip. */
.fn-timeline-wrap {
margin-top: 18px;
padding: 12px 14px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
}
.fn-timeline-head {
display: flex; justify-content: space-between; align-items: baseline;
margin-bottom: 8px;
}
.fn-timeline-head h3 {
margin: 0; font-size: 12px; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.10em; font-weight: 600;
font-family: var(--font-display);
}
.fn-timeline-head .meta { color: var(--muted); font-size: 11px; font-family: ui-monospace, Menlo, Consolas, monospace; }
.fn-timeline {
display: flex;
align-items: flex-end;
gap: 2px;
height: 90px;
border-bottom: 1px solid var(--border);
padding-bottom: 2px;
}
.fn-timeline-bar {
flex: 1;
display: flex;
flex-direction: column-reverse; /* segments stack from bottom up */
align-items: stretch;
min-width: 6px;
height: 100%;
position: relative;
background: rgba(125,133,151,0.04);
border-bottom: 1px solid transparent;
cursor: default;
}
.fn-timeline-bar:hover { background: rgba(30,200,255,0.08); }
.fn-timeline-bar-seg {
width: 100%;
min-height: 1px;
transition: filter 0.15s;
}
.fn-timeline-bar:hover .fn-timeline-bar-seg { filter: brightness(1.25); }
.fn-timeline-axis {
display: flex; gap: 2px; margin-top: 4px;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 9px; color: var(--muted);
}
.fn-timeline-axis span { flex: 1; text-align: center; min-width: 6px; }
.fn-timeline-empty {
color: var(--muted); font-size: 12px; font-style: italic;
text-align: center; padding: 22px 0;
}
/* ===================================================================
* federation explorer — public transparency page
* Public-facing variant of the admin federation network UI. Reuses the
* fn-* graph classes; fe-* is just the chrome around it.
* =================================================================== */
.fe-page { background: var(--bg); }
.fe-topbar { gap: 18px; }
.fe-topbar .nav-toggle, .fe-topbar .nav { display: none; }
.fe-badge {
display: inline-flex; align-items: center; gap: 8px;
padding: 4px 12px;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 10.5px;
color: var(--accent);
background: rgba(30,200,255,0.08);
border: 1px solid var(--accent);
border-radius: 999px;
letter-spacing: 0.10em;
text-transform: uppercase;
box-shadow: 0 0 12px var(--accent-glow);
margin-left: auto;
}
.fe-badge-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 8px var(--accent);
animation: fe-pulse 1.8s ease-in-out infinite;
}
@keyframes fe-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(1.4); }
}
.fe-hero { padding: 28px 32px; }
.fe-hero-head { margin-bottom: 14px; }
.fe-title {
margin: 0;
font-family: var(--font-display);
font-size: 32px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--text);
}
.fe-title::before {
content: "⌖ ";
color: var(--accent);
text-shadow: 0 0 12px var(--accent-glow);
}
.fe-sub {
margin: 6px 0 0;
color: var(--accent);
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 13px;
word-break: break-all;
text-shadow: 0 0 8px var(--accent-glow);
}
.fe-intro {
margin: 14px 0 0;
max-width: 920px;
color: var(--text);
line-height: 1.55;
font-size: 14px;
}
.fe-intro strong { color: var(--accent); font-weight: 600; }
.fe-intro-sub { color: var(--muted); font-size: 12px; margin-top: 10px; }
.fe-fp {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 11px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 3px;
padding: 2px 6px;
color: var(--accent);
word-break: break-all;
}
.fe-kpi-panel { padding: 18px 22px; }
.fe-kpis { gap: 14px; }
.fe-kpis .fn-stat { min-width: 130px; }
.fe-kpi-verify .fn-stat-value { font-size: 18px; }
.fe-kpi-verify .fn-stat-value.fe-verify-ok { color: var(--green); text-shadow: 0 0 10px rgba(74,222,128,0.45); }
.fe-kpi-verify .fn-stat-value.fe-verify-bad { color: var(--red); text-shadow: 0 0 10px rgba(248,113,113,0.45); }
.fe-verify-row {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
margin-top: 14px;
padding-top: 14px;
border-top: 1px dashed var(--border);
}
.fe-verify-btn {
font-family: var(--font-display);
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 11px;
font-weight: 600;
}
.fe-verify-result {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 12px;
color: var(--muted);
}
.fe-verify-result.fe-verify-ok { color: var(--green); }
.fe-verify-result.fe-verify-bad { color: var(--red); }
.fe-verify-link {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 11px;
color: var(--muted);
text-decoration: none;
border-bottom: 1px dotted var(--border);
}
.fe-verify-link:hover { color: var(--accent); border-color: var(--accent); }
.fe-stage { margin-top: 8px; }
.fe-walk {
margin-top: 16px;
padding: 14px 18px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
min-height: 56px;
}
.fe-walk-empty {
margin: 0; color: var(--muted);
font-style: italic; font-size: 13px;
}
.fe-walk-card {
display: grid;
grid-template-columns: 1fr auto;
gap: 16px;
align-items: center;
}
.fe-walk-card-body { min-width: 0; }
.fe-walk-card-title {
margin: 0 0 4px;
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
color: var(--text);
word-break: break-all;
}
.fe-walk-card-fp {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 11px;
color: var(--muted);
word-break: break-all;
}
.fe-walk-card-stats {
display: flex; flex-wrap: wrap; gap: 8px;
margin-top: 10px;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 11px;
}
.fe-walk-card-stats .k { color: var(--muted); }
.fe-walk-card-stats .v { color: var(--accent); }
.fe-walk-card-stats > span {
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--panel);
}
.fe-walk-cta {
display: inline-flex; align-items: center; gap: 8px;
padding: 10px 18px;
font-family: var(--font-display);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--bg);
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 5px;
text-decoration: none;
box-shadow: 0 0 14px var(--accent-glow);
white-space: nowrap;
}
.fe-walk-cta:hover {
background: #66daff;
text-decoration: none;
}
.fe-walk-cta-disabled {
color: var(--muted);
background: transparent;
border-color: var(--border);
box-shadow: none;
cursor: not-allowed;
}
.fe-vouches-panel .fe-vouches-in-list {
list-style: none;
margin: 0; padding: 0;
}
.fe-vouches-in-list li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 8px 10px;
border-bottom: 1px dashed var(--border);
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 12px;
}
.fe-vouches-in-list li:last-child { border-bottom: 0; }
.fe-vouches-in-list .fp { color: var(--accent); word-break: break-all; }
.fe-vouches-in-list .ts { color: var(--muted); font-size: 11px; }
.fe-vouches-in-empty {
color: var(--muted); font-style: italic;
display: block !important;
text-align: center;
padding: 18px 0;
}
.fe-footer {
margin-top: 36px;
text-align: center;
color: var(--muted);
font-size: 11px;
font-family: ui-monospace, Menlo, Consolas, monospace;
letter-spacing: 0.05em;
}
@media (max-width: 720px) {
.fe-hero { padding: 18px 16px; }
.fe-title { font-size: 24px; }
.fe-walk-card {
grid-template-columns: 1fr;
}
.fe-walk-cta { width: 100%; justify-content: center; }
}
/* peer self-view section inside the detail panel — fetched cross-origin */
.fn-remote-sec { grid-column: 1 / -1; }
.fn-remote-status {
font-size: 11px; color: var(--muted); margin-left: 8px; font-weight: 400;
text-transform: lowercase; letter-spacing: 0.02em;
}
.fn-remote-meta {
display: flex; flex-wrap: wrap; gap: 12px 18px;
font-size: 12px; color: var(--muted); margin-bottom: 12px;
}
.fn-remote-meta b { color: var(--text); font-weight: 600; }
.fn-remote-meta code { font-size: 11px; color: var(--accent); }
.fn-remote-ok { color: rgba(74,222,128,0.95); }
.fn-remote-warn { color: rgba(251,191,36,0.95); }
.fn-remote-cols {
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px; margin: 8px 0 12px;
}
.fn-remote-h { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; }
.fn-remote-list { list-style: none; padding: 0; margin: 0; font-size: 12px; line-height: 1.55; }
.fn-remote-list li { padding: 2px 0; border-bottom: 1px dashed rgba(125,133,151,0.18); }
.fn-remote-list li:last-child { border-bottom: 0; }
.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

@@ -0,0 +1,780 @@
/* psyc — federation explorer (public, transparency view).
*
* Forked from federation_network.js, adapted for the public surface:
* • data source is /federation/explore/data (signed, CORS-enabled)
* • clicking a peer opens a walk-to-peer card with a primary CTA
* that full-page-navigates to that peer's own /federation/explore
* • the transparency log can be re-verified live from the page
* • inbound vouches (who vouches for THIS node) get their own section
* • severity/IOC-type breakdowns are intentionally NOT surfaced —
* those stay admin-only to avoid sector-leaking via the public page
*
* Pure vanilla JS, no deps. Mirrors topology.js's proven force-sim loop.
*/
(function () {
"use strict";
const svg = document.getElementById("federation-network-graph");
const loadingEl = document.getElementById("fn-loading");
const errorEl = document.getElementById("fn-error");
const tooltipEl = document.getElementById("fn-tooltip");
const walkEl = document.getElementById("fe-walk");
const directCountEl = document.getElementById("fe-direct-count");
const transitiveCountEl = document.getElementById("fe-transitive-count");
const kpiPeers = document.getElementById("fe-kpi-peers");
const kpiVouchesOut = document.getElementById("fe-kpi-vouches-out");
const kpiVouchesIn = document.getElementById("fe-kpi-vouches-in");
const kpiSignals = document.getElementById("fe-kpi-signals");
const kpiCorroboration = document.getElementById("fe-kpi-corroboration");
const kpiTranslog = document.getElementById("fe-kpi-translog");
const kpiVerify = document.getElementById("fe-kpi-verify");
const verifyBtn = document.getElementById("fe-verify-btn");
const verifyResult = document.getElementById("fe-verify-result");
const vouchesInList = document.getElementById("fe-vouches-in-list");
const vouchesInCountEl = document.getElementById("fe-vouches-in-count");
const settings = window.PSYC_EXPLORE || {};
if (!svg) return;
// ---------- shared escape -----------------------------------------------
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function shortFp(fp) {
if (!fp) return "—";
if (fp.length >= 16) return fp.slice(0, 8) + "…" + fp.slice(-8);
return fp;
}
function fmtAge(iso) {
if (!iso) return "—";
const ts = new Date(iso);
if (isNaN(ts.getTime())) return "—";
const secs = Math.floor((Date.now() - ts.getTime()) / 1000);
if (secs < 0) return "just now";
if (secs < 60) return secs + "s ago";
if (secs < 3600) return Math.floor(secs / 60) + "m ago";
if (secs < 86400) return Math.floor(secs / 3600) + "h ago";
return Math.floor(secs / 86400) + "d ago";
}
fetch("/federation/explore/data", { credentials: "omit" })
.then(r => {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(data => {
if (loadingEl) loadingEl.style.display = "none";
render(data);
})
.catch(err => {
if (loadingEl) loadingEl.style.display = "none";
if (errorEl) {
errorEl.style.display = "block";
errorEl.textContent = "✗ failed to load explore payload: " + err.message;
}
});
// ---------- verify button — fetch /federation/log/verify ---------------
if (verifyBtn) {
verifyBtn.addEventListener("click", () => {
verifyBtn.disabled = true;
verifyResult.textContent = "verifying…";
verifyResult.classList.remove("fe-verify-ok", "fe-verify-bad");
fetch("/federation/log/verify", { credentials: "omit" })
.then(r => r.json().then(b => ({ status: r.status, body: b })))
.then(({ status, body }) => {
if (status === 200 && body.verified != null) {
verifyResult.textContent = "✓ verified " + body.verified + " entries · head " + (body.head_hash || "").slice(0, 12) + "…";
verifyResult.classList.add("fe-verify-ok");
if (kpiVerify) {
kpiVerify.textContent = "✓ ok";
kpiVerify.classList.add("fe-verify-ok");
kpiVerify.classList.remove("fe-verify-bad");
}
} else {
verifyResult.textContent = "✗ " + (body.error || "chain invalid");
verifyResult.classList.add("fe-verify-bad");
if (kpiVerify) {
kpiVerify.textContent = "✗ broken";
kpiVerify.classList.add("fe-verify-bad");
kpiVerify.classList.remove("fe-verify-ok");
}
}
})
.catch(err => {
verifyResult.textContent = "✗ fetch failed: " + err.message;
verifyResult.classList.add("fe-verify-bad");
})
.finally(() => { verifyBtn.disabled = false; });
});
}
function render(data) {
const node = data.node || {};
const selfFp = data.fingerprint || node.fingerprint || "";
const peersData = data.peers || [];
const transitiveData = data.transitive_peers || [];
const vouchesIn = data.vouches_in || [];
const vouchesOut = data.vouches_out || data.vouches || [];
// ---------- KPI strip ------------------------------------------------
if (kpiPeers) kpiPeers.textContent = String(node.peer_count != null ? node.peer_count : peersData.length);
if (kpiVouchesOut) kpiVouchesOut.textContent = String(node.vouches_out_count != null ? node.vouches_out_count : vouchesOut.length);
if (kpiVouchesIn) kpiVouchesIn.textContent = String(node.vouches_in_count != null ? node.vouches_in_count : vouchesIn.length);
if (kpiSignals) kpiSignals.textContent = String(node.signals_count_24h || 0);
if (kpiCorroboration) kpiCorroboration.textContent = String(node.corroboration_count_24h || 0);
if (kpiTranslog) kpiTranslog.textContent = String(node.translog_entry_count || 0);
if (kpiVerify) kpiVerify.textContent = "unverified";
if (directCountEl) directCountEl.textContent = String(peersData.length);
if (transitiveCountEl) transitiveCountEl.textContent = String(transitiveData.length);
// ---------- node + edge model ----------------------------------------
// The explore payload doesn't ship edges directly; we derive them from
// the vouches + per-peer signal counts so the graph reads the same way
// the admin view does.
const peerByFp = Object.create(null);
const nodes = [];
// Self at the center.
const selfNode = {
id: selfFp, fp: selfFp,
domain: settings.selfDomain || node.domain || "",
label: settings.selfDomain || (node.domain || "self"),
status: "self",
is_self: true,
distance: 0,
stats: null,
r: 38,
intensity: 1,
x: 0, y: 0, vx: 0, vy: 0, fixed: false,
};
nodes.push(selfNode);
peerByFp[selfFp] = selfNode;
// Max signal count for log-intensity normalization.
let maxSig = 0;
for (const p of peersData) {
if ((p.signal_count_24h || 0) > maxSig) maxSig = p.signal_count_24h || 0;
}
for (const p of peersData) {
const fp = p.fingerprint;
if (!fp || fp === selfFp) continue;
const sig = p.signal_count_24h || 0;
let intensity = 1;
if (maxSig > 0) {
const num = Math.log2(sig + 1);
const den = Math.log2(maxSig + 1) || 1;
intensity = 0.20 + 0.80 * (num / den);
}
const n = {
id: fp, fp,
domain: p.domain || "",
label: p.domain || shortFp(fp),
status: "trusted",
is_self: false,
distance: 1,
stats: p,
r: 16,
intensity,
x: 0, y: 0, vx: 0, vy: 0, fixed: false,
};
nodes.push(n);
peerByFp[fp] = n;
}
for (const t of transitiveData) {
const fp = t.fingerprint;
if (!fp || peerByFp[fp]) continue;
const n = {
id: fp, fp,
domain: t.domain || "",
label: t.domain || shortFp(fp),
status: "unknown",
is_self: false,
distance: 2,
stats: null,
via: t.via_peer_fingerprint || "",
r: 9,
intensity: 0.7,
x: 0, y: 0, vx: 0, vy: 0, fixed: false,
};
nodes.push(n);
peerByFp[fp] = n;
}
// Edges. Per-peer signal counts → signal edges; outbound vouches →
// vouch edges; vouches_in → bidirectional vouch edges; transitive
// "via" → knows edges.
const edges = [];
for (const p of peersData) {
const fp = p.fingerprint;
if (!fp || fp === selfFp) continue;
if ((p.signal_count_24h || 0) > 0) {
edges.push({
source: fp, target: selfFp, kind: "signal",
weight: p.signal_count_24h, label: p.signal_count_24h + " signals/24h",
bidirectional: false,
});
}
}
// Outbound vouches.
const outbound = new Set();
for (const v of vouchesOut) {
const tgt = v.target_fingerprint;
if (!tgt || !peerByFp[tgt]) continue;
outbound.add(tgt);
edges.push({
source: selfFp, target: tgt, kind: "vouch",
weight: 1, label: "vouched", bidirectional: false,
});
}
// Inbound vouches — collapse onto existing outbound where possible.
for (const v of vouchesIn) {
const src = v.voucher_fingerprint;
if (!src || !peerByFp[src]) continue;
if (outbound.has(src)) {
const existing = edges.find(e => e.kind === "vouch"
&& e.source === selfFp && e.target === src);
if (existing) {
existing.bidirectional = true;
existing.label = "vouched ↔";
continue;
}
}
edges.push({
source: src, target: selfFp, kind: "vouch",
weight: 1, label: "vouches us", bidirectional: false,
});
}
// Transitive "knows" edges.
for (const t of transitiveData) {
const parent = t.via_peer_fingerprint;
if (!parent || !peerByFp[parent] || !peerByFp[t.fingerprint]) continue;
edges.push({
source: parent, target: t.fingerprint, kind: "knows",
weight: 0.5, label: "knows", bidirectional: false,
});
}
// ---------- viewport + seeding ---------------------------------------
function viewport() {
const W = svg.clientWidth || 900;
const H = parseInt(getComputedStyle(svg).height, 10) || 560;
return { W, H };
}
let { W, H } = viewport();
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
(function seed() {
const cx = W / 2, cy = H / 2;
nodes.forEach((n, i) => {
if (n.is_self) { n.x = cx; n.y = cy; return; }
const ring = n.distance >= 2 ? Math.min(W, H) * 0.40 : Math.min(W, H) * 0.22;
const ang = (i / Math.max(1, nodes.length)) * Math.PI * 2;
n.x = cx + ring * Math.cos(ang) + (Math.random() - 0.5) * 20;
n.y = cy + ring * Math.sin(ang) + (Math.random() - 0.5) * 20;
});
})();
const REPULSION = 1500;
const SPRING_K = 0.035;
const SPRING_REST_BASE = 110;
const DAMP = 0.82;
const CENTER_PULL = 0.005;
function tick() {
for (let i = 0; i < nodes.length; i++) {
const a = nodes[i];
for (let j = i + 1; j < nodes.length; j++) {
const b = nodes[j];
const dx = b.x - a.x, dy = b.y - a.y;
const d2 = dx * dx + dy * dy + 0.1;
const d = Math.sqrt(d2);
const f = REPULSION / d2;
const fx = (dx / d) * f, fy = (dy / d) * f;
if (!a.fixed) { a.vx -= fx; a.vy -= fy; }
if (!b.fixed) { b.vx += fx; b.vy += fy; }
}
}
for (const e of edges) {
const a = peerByFp[e.source], b = peerByFp[e.target];
if (!a || !b) continue;
const rest = e.kind === "knows" ? SPRING_REST_BASE + 60 : SPRING_REST_BASE;
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.sqrt(dx * dx + dy * dy) + 0.1;
const f = (d - rest) * SPRING_K;
const fx = (dx / d) * f, fy = (dy / d) * f;
if (!a.fixed) { a.vx += fx; a.vy += fy; }
if (!b.fixed) { b.vx -= fx; b.vy -= fy; }
}
for (const n of nodes) {
if (n.fixed) { n.vx = 0; n.vy = 0; continue; }
n.vx += (W / 2 - n.x) * CENTER_PULL;
n.vy += (H / 2 - n.y) * CENTER_PULL;
n.vx *= DAMP; n.vy *= DAMP;
n.x += n.vx; n.y += n.vy;
n.x = Math.max(n.r, Math.min(W - n.r, n.x));
n.y = Math.max(n.r, Math.min(H - n.r, n.y));
}
}
for (let i = 0; i < 280; i++) tick();
// ---------- render SVG groups ----------------------------------------
const ns = "http://www.w3.org/2000/svg";
const edgesG = document.createElementNS(ns, "g");
const nodesG = document.createElementNS(ns, "g");
edgesG.setAttribute("class", "fn-edges");
nodesG.setAttribute("class", "fn-nodes");
svg.appendChild(edgesG);
svg.appendChild(nodesG);
const edgeEls = edges.map(e => {
const grp = document.createElementNS(ns, "g");
grp.setAttribute("class", "fn-edge-grp fn-kind-" + e.kind);
grp.dataset.source = e.source;
grp.dataset.target = e.target;
const ln = document.createElementNS(ns, "line");
ln.setAttribute("class", "fn-edge");
if (e.kind === "signal") {
const w = Math.min(5, 1 + Math.log2(e.weight + 1) * 0.8);
ln.setAttribute("stroke-width", w.toFixed(2));
}
grp.appendChild(ln);
if (e.label) {
const lbl = document.createElementNS(ns, "text");
lbl.setAttribute("class", "fn-edge-label");
lbl.textContent = e.label;
grp.appendChild(lbl);
}
edgesG.appendChild(grp);
return { line: ln, label: grp.querySelector("text"), grp };
});
function _classFor(n) {
if (n.is_self) return "fn-node fn-self";
const dist = n.distance >= 2 ? " fn-distance-2" : " fn-distance-1";
return "fn-node fn-status-" + n.status + dist;
}
const nodeEls = nodes.map(n => {
const g = document.createElementNS(ns, "g");
g.setAttribute("class", _classFor(n));
g.dataset.fp = n.fp;
let shape;
if (n.is_self) {
const sz = n.r;
shape = document.createElementNS(ns, "rect");
shape.setAttribute("x", -sz); shape.setAttribute("y", -sz);
shape.setAttribute("width", sz * 2); shape.setAttribute("height", sz * 2);
shape.setAttribute("rx", 10); shape.setAttribute("ry", 10);
g.appendChild(shape);
} else {
shape = document.createElementNS(ns, "circle");
shape.setAttribute("r", n.r);
shape.setAttribute("fill-opacity", n.intensity.toFixed(2));
g.appendChild(shape);
}
const text = document.createElementNS(ns, "text");
text.setAttribute("class", "fn-label");
text.setAttribute("dy", n.r + 13);
text.textContent = n.label;
g.appendChild(text);
if (!n.is_self) {
const sub = document.createElementNS(ns, "text");
sub.setAttribute("class", "fn-sublabel");
sub.setAttribute("dy", n.r + 24);
sub.textContent = n.fp.slice(0, 8) + "…";
g.appendChild(sub);
if (n.stats) {
const badge = document.createElementNS(ns, "text");
badge.setAttribute("class", "fn-stat-badge");
badge.setAttribute("dy", n.r + 36);
badge.textContent =
"↓ " + (n.stats.signal_count_24h || 0) +
" · ⚡ " + (n.stats.quorum_contribution_24h || 0);
g.appendChild(badge);
}
}
const title = document.createElementNS(ns, "title");
title.textContent = (n.is_self ? "self · " : "") + (n.domain || n.label || n.fp);
g.appendChild(title);
nodesG.appendChild(g);
return g;
});
function paint() {
for (let i = 0; i < edges.length; i++) {
const e = edges[i];
const a = peerByFp[e.source], b = peerByFp[e.target];
if (!a || !b) continue;
const els = edgeEls[i];
els.line.setAttribute("x1", a.x); els.line.setAttribute("y1", a.y);
els.line.setAttribute("x2", b.x); els.line.setAttribute("y2", b.y);
if (els.label) {
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
els.label.setAttribute("x", mx); els.label.setAttribute("y", my - 3);
}
}
for (let i = 0; i < nodes.length; i++) {
nodeEls[i].setAttribute("transform", `translate(${nodes[i].x},${nodes[i].y})`);
}
}
paint();
// ---------- tooltip --------------------------------------------------
function showTooltip(n, clientX, clientY) {
if (!tooltipEl) return;
const rows = [];
rows.push(`<div class="fn-tooltip-title">${esc(n.domain || n.label || n.fp.slice(0,12))}</div>`);
if (n.is_self) {
rows.push(`<div class="fn-tooltip-row"><span class="k">role</span><span class="v">self</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">peers</span><span class="v">${node.peer_count || 0}</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">signals 24h</span><span class="v">${node.signals_count_24h || 0}</span></div>`);
} else if (n.distance >= 2) {
rows.push(`<div class="fn-tooltip-row"><span class="k">distance</span><span class="v">2 hops (transitive)</span></div>`);
if (n.via) {
const parent = peerByFp[n.via];
const via = parent ? (parent.domain || shortFp(parent.fp)) : shortFp(n.via);
rows.push(`<div class="fn-tooltip-row"><span class="k">via</span><span class="v">${esc(via)}</span></div>`);
}
rows.push(`<div class="fn-tooltip-row"><span class="k">tip</span><span class="v">click to walk →</span></div>`);
} else {
const s = n.stats || {};
rows.push(`<div class="fn-tooltip-row"><span class="k">status</span><span class="v">trusted</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">signals 24h</span><span class="v">${s.signal_count_24h || 0}</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">quorum hits</span><span class="v">${s.quorum_contribution_24h || 0}</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">last seen</span><span class="v">${esc(fmtAge(s.last_seen))}</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">tip</span><span class="v">click to walk →</span></div>`);
}
tooltipEl.innerHTML = rows.join("");
tooltipEl.classList.add("is-visible");
positionTooltip(clientX, clientY);
}
function positionTooltip(clientX, clientY) {
if (!tooltipEl) return;
const parent = svg.parentElement;
if (!parent) return;
const rect = parent.getBoundingClientRect();
let x = clientX - rect.left + 14;
let y = clientY - rect.top + 14;
const tw = tooltipEl.offsetWidth || 240;
const th = tooltipEl.offsetHeight || 100;
if (x + tw > parent.clientWidth - 4) x = clientX - rect.left - tw - 14;
if (y + th > parent.clientHeight - 4) y = clientY - rect.top - th - 14;
tooltipEl.style.left = x + "px";
tooltipEl.style.top = y + "px";
}
function hideTooltip() {
if (tooltipEl) tooltipEl.classList.remove("is-visible");
}
// ---------- drag + click + hover ------------------------------------
let dragging = null, dragOffset = { x: 0, y: 0 };
let pressedNode = null, pressedAt = null, moved = false;
let energyBudget = 40;
function svgPoint(clientX, clientY) {
const pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY;
return pt.matrixTransform(svg.getScreenCTM().inverse());
}
nodeEls.forEach((g, i) => {
const n = nodes[i];
g.addEventListener("mousedown", ev => {
ev.preventDefault();
pressedNode = n;
pressedAt = { x: ev.clientX, y: ev.clientY };
moved = false;
dragging = n;
const p = svgPoint(ev.clientX, ev.clientY);
dragOffset.x = p.x - dragging.x; dragOffset.y = p.y - dragging.y;
if (currentLayout === "force") dragging.fixed = true;
g.classList.add("dragging");
});
g.addEventListener("mouseenter", ev => showTooltip(n, ev.clientX, ev.clientY));
g.addEventListener("mousemove", ev => positionTooltip(ev.clientX, ev.clientY));
g.addEventListener("mouseleave", hideTooltip);
});
document.addEventListener("mousemove", ev => {
if (pressedAt) {
const dx = ev.clientX - pressedAt.x, dy = ev.clientY - pressedAt.y;
if (dx * dx + dy * dy > 16) moved = true;
}
if (!dragging) return;
const p = svgPoint(ev.clientX, ev.clientY);
dragging.x = p.x - dragOffset.x; dragging.y = p.y - dragOffset.y;
dragging.vx = 0; dragging.vy = 0;
energyBudget = 80;
});
document.addEventListener("mouseup", () => {
if (dragging) {
const g = nodesG.querySelector(`[data-fp="${CSS.escape(dragging.fp)}"]`);
if (g) g.classList.remove("dragging");
if (currentLayout === "force") dragging.fixed = false;
dragging = null;
}
if (pressedNode && !moved) selectNode(pressedNode);
pressedNode = null; pressedAt = null;
});
// ---------- walk-to-peer card ---------------------------------------
function selectNode(n) {
nodeEls.forEach(el => el.classList.remove("selected"));
const me = nodesG.querySelector(`[data-fp="${CSS.escape(n.fp)}"]`);
if (me) me.classList.add("selected");
renderWalk(n);
}
function jumpToFp(fp) {
const t = peerByFp[fp];
if (!t) return;
selectNode(t);
// Scroll the graph stage into view so the user sees the highlight.
svg.scrollIntoView({ behavior: "smooth", block: "center" });
}
function vouchersFor(fp) {
// Inbound vouches naming `fp`. Right now we only have inbound vouches
// for SELF in the public payload; for any other peer we don't see
// who-vouches-for-them from this page.
if (fp === selfFp) return vouchesIn.map(v => v.voucher_fingerprint);
return [];
}
function renderWalk(n) {
if (!walkEl) return;
const isSelf = n.is_self;
const isTransitive = n.distance >= 2;
const stats = n.stats || {};
const targetDomain = n.domain || (isSelf ? settings.selfDomain : "");
const peerHref = targetDomain
? `https://${targetDomain}/federation/explore`
: "";
const statsHtml = [];
if (isSelf) {
statsHtml.push(`<span><span class="k">peers</span> <span class="v">${node.peer_count || 0}</span></span>`);
statsHtml.push(`<span><span class="k">signals 24h</span> <span class="v">${node.signals_count_24h || 0}</span></span>`);
statsHtml.push(`<span><span class="k">corroborations</span> <span class="v">${node.corroboration_count_24h || 0}</span></span>`);
statsHtml.push(`<span><span class="k">translog</span> <span class="v">${node.translog_entry_count || 0} entries</span></span>`);
} else if (isTransitive) {
const parent = peerByFp[n.via];
const via = parent ? (parent.domain || shortFp(parent.fp)) : shortFp(n.via);
statsHtml.push(`<span><span class="k">distance</span> <span class="v">2 hops</span></span>`);
statsHtml.push(`<span><span class="k">learned via</span> <span class="v">${esc(via)}</span></span>`);
statsHtml.push(`<span><span class="k">stats</span> <span class="v">— (peer-side only)</span></span>`);
} else {
statsHtml.push(`<span><span class="k">status</span> <span class="v">trusted</span></span>`);
statsHtml.push(`<span><span class="k">signals 24h</span> <span class="v">${stats.signal_count_24h || 0}</span></span>`);
statsHtml.push(`<span><span class="k">cases / iocs 24h</span> <span class="v">${stats.cases_24h || 0} / ${stats.iocs_24h || 0}</span></span>`);
statsHtml.push(`<span><span class="k">quorum hits</span> <span class="v">${stats.quorum_contribution_24h || 0}</span></span>`);
statsHtml.push(`<span><span class="k">last seen</span> <span class="v">${esc(fmtAge(stats.last_seen))}</span></span>`);
}
const cta = peerHref
? `<a class="fe-walk-cta" href="${esc(peerHref)}">View this peer's federation <span aria-hidden="true">→</span></a>`
: `<span class="fe-walk-cta fe-walk-cta-disabled" title="no public domain on file for this peer">no public address known</span>`;
walkEl.innerHTML = `
<div class="fe-walk-card">
<div class="fe-walk-card-body">
<h3 class="fe-walk-card-title">${esc(n.domain || n.label || shortFp(n.fp))}</h3>
<div class="fe-walk-card-fp">${esc(n.fp)}</div>
<div class="fe-walk-card-stats">${statsHtml.join("")}</div>
</div>
${cta}
</div>`;
}
// ---------- inbound vouches list ------------------------------------
function renderVouchesIn() {
if (!vouchesInList) return;
if (vouchesInCountEl) vouchesInCountEl.textContent = String(vouchesIn.length);
if (!vouchesIn.length) {
vouchesInList.innerHTML = `<li class="fe-vouches-in-empty">no inbound vouches yet</li>`;
return;
}
const items = vouchesIn.map(v => {
const fp = v.voucher_fingerprint || "";
const peer = peerByFp[fp];
const label = peer ? (peer.domain || shortFp(fp)) : shortFp(fp);
const clickable = peer ? `data-jump="${esc(fp)}"` : `data-jump=""`;
return `<li>
<span class="fp">
<button type="button" class="fn-fp-jump" ${clickable}>${esc(label)}</button>
<code style="margin-left:8px;color:var(--muted);font-size:11px;">${esc(fp)}</code>
</span>
<span class="ts">${esc(v.issued_at || "")}</span>
</li>`;
}).join("");
vouchesInList.innerHTML = items;
vouchesInList.querySelectorAll(".fn-fp-jump").forEach(btn => {
btn.addEventListener("click", () => {
const fp = btn.getAttribute("data-jump") || "";
if (fp) jumpToFp(fp);
});
});
}
renderVouchesIn();
// ---------- copy buttons on the static page -------------------------
document.querySelectorAll(".fn-copy-btn").forEach(btn => {
btn.addEventListener("click", () => {
const v = btn.getAttribute("data-copy") || "";
if (!v) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(v).catch(() => {});
}
const t = btn.textContent;
btn.textContent = "copied";
setTimeout(() => { btn.textContent = t; }, 1100);
});
});
// ---------- idle animation ------------------------------------------
function loop() {
let moving = false;
for (const n of nodes) {
if (Math.abs(n.vx) > 0.04 || Math.abs(n.vy) > 0.04) { moving = true; break; }
}
if (moving || energyBudget > 0 || dragging) {
tick(); paint();
if (energyBudget > 0) energyBudget--;
}
requestAnimationFrame(loop);
}
loop();
// ---------- edge liveness + flow toggle -----------------------------
edges.forEach((e, i) => {
const ln = edgeEls[i].line;
if (e.kind === "signal") ln.classList.add("alive");
if (e.kind === "knows") ln.classList.add("dim");
});
const flowToggle = document.getElementById("fn-flow");
function applyFlowToggle() {
svg.classList.toggle("flow-off", !(flowToggle && flowToggle.checked));
}
applyFlowToggle();
if (flowToggle) flowToggle.addEventListener("change", applyFlowToggle);
// ---------- layout modes --------------------------------------------
function unfix() { for (const n of nodes) n.fixed = false; }
function clearVel() { for (const n of nodes) { n.vx = 0; n.vy = 0; } }
function applyForce() {
unfix();
for (const n of nodes) {
if (n.is_self) { n.x = W / 2; n.y = H / 2; n.fixed = true; continue; }
n.vx = (Math.random() - 0.5) * 5;
n.vy = (Math.random() - 0.5) * 5;
}
energyBudget = 300;
}
function applyHierarchical() {
const self = nodes.find(n => n.is_self);
const direct = nodes.filter(n => !n.is_self && n.distance === 1);
const transitive = nodes.filter(n => n.distance >= 2);
if (self) { self.x = W / 2; self.y = 70; self.fixed = true; }
direct.forEach((n, i) => {
n.x = W * (i + 1) / (direct.length + 1);
n.y = H * 0.42;
n.fixed = true;
});
const tCount = transitive.length || 1;
transitive.forEach((n, i) => {
n.x = W * (i + 1) / (tCount + 1);
n.y = H * 0.78;
n.fixed = true;
});
clearVel(); paint();
}
function applyRadial() {
const self = nodes.find(n => n.is_self);
const direct = nodes.filter(n => !n.is_self && n.distance === 1);
const transitive = nodes.filter(n => n.distance >= 2);
const R1 = Math.min(W, H) * 0.22;
const R2 = Math.min(W, H) * 0.40;
if (self) { self.x = W / 2; self.y = H / 2; self.fixed = true; }
const dCount = direct.length || 1;
direct.forEach((n, i) => {
const a = (i / dCount) * Math.PI * 2 - Math.PI / 2;
n.x = W / 2 + R1 * Math.cos(a);
n.y = H / 2 + R1 * Math.sin(a);
n.fixed = true;
});
const tCount = transitive.length || 1;
transitive.forEach((n, i) => {
const a = (i / tCount) * Math.PI * 2 - Math.PI / 2;
n.x = W / 2 + R2 * Math.cos(a);
n.y = H / 2 + R2 * Math.sin(a);
n.fixed = true;
});
clearVel(); paint();
}
const LAYOUTS = { force: applyForce, hier: applyHierarchical, radial: applyRadial };
let currentLayout = "force";
const selfNodeRef = nodes.find(n => n.is_self);
if (selfNodeRef) { selfNodeRef.x = W / 2; selfNodeRef.y = H / 2; selfNodeRef.fixed = true; }
document.querySelectorAll(".topo-layout").forEach(btn => {
btn.addEventListener("click", () => {
const mode = btn.dataset.layout;
if (!LAYOUTS[mode] || mode === currentLayout) return;
document.querySelectorAll(".topo-layout").forEach(b => b.classList.toggle("is-active", b === btn));
currentLayout = mode;
LAYOUTS[mode]();
});
});
const resetBtn = document.getElementById("fn-reset");
if (resetBtn) {
resetBtn.addEventListener("click", () => {
if (currentLayout === "force") {
for (const n of nodes) {
if (n.is_self) continue;
n.vx = (Math.random() - 0.5) * 6;
n.vy = (Math.random() - 0.5) * 6;
}
energyBudget = 200;
} else {
LAYOUTS[currentLayout]();
}
});
}
// ---------- wheel zoom + resize -------------------------------------
let zoom = 1, panX = 0, panY = 0;
svg.addEventListener("wheel", ev => {
ev.preventDefault();
const delta = ev.deltaY > 0 ? 1.1 : 0.9;
zoom = Math.max(0.3, Math.min(2.5, zoom * delta));
const vw = W / zoom, vh = H / zoom;
svg.setAttribute("viewBox", `${(W - vw) / 2 + panX} ${(H - vh) / 2 + panY} ${vw} ${vh}`);
}, { passive: false });
window.addEventListener("resize", () => {
const v = viewport();
W = v.W; H = v.H;
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
energyBudget = 60;
});
// ---------- focus-peer query param ----------------------------------
// ?peer=<domain> auto-selects that peer in the graph so deep links work.
if (settings.focusPeer) {
const target = nodes.find(n => n.domain && n.domain === settings.focusPeer);
if (target) selectNode(target);
}
}
})();

File diff suppressed because it is too large Load Diff

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-v5";
const CACHE_VERSION = "psyc-v11";
const STATIC_ASSETS = [
"/static/cockpit.css",
"/static/psyc-tokens.css",

View File

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

View File

@@ -8,7 +8,7 @@
<span class="count">{{ peers|length }} peer{{ '' if peers|length == 1 else 's' }}</span>
</div>
<p class="page-intro">This node's Ed25519 identity. The fingerprint goes into a DNS TXT record so other psyc nodes can discover this one. The public key lets them verify any feed we publish — the private key never leaves this box.</p>
<p class="back"><a href="/admin">← back to admin</a> &nbsp;·&nbsp; <a href="/admin/federation/discovery">discovery</a> &nbsp;·&nbsp; <a href="/admin/federation/vouches">vouches</a> &nbsp;·&nbsp; <a href="/admin/federation/quorum">quorum config</a> &nbsp;·&nbsp; <a href="/admin/federation/log">transparency log</a></p>
<p class="back"><a href="/admin">← back to admin</a> &nbsp;·&nbsp; <a href="/admin/federation/discovery">discovery</a> &nbsp;·&nbsp; <a href="/admin/federation/vouches">vouches</a> &nbsp;·&nbsp; <a href="/admin/federation/quorum">quorum config</a> &nbsp;·&nbsp; <a href="/admin/federation/log">transparency log</a> &nbsp;·&nbsp; <a href="/admin/federation/network">network</a></p>
<div class="card" style="margin-bottom:14px;">
<div class="lg-sub">node fingerprint</div>
@@ -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>

View File

@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}Federation network — psyc admin{% endblock %}
{% block body_class %}wide{% endblock %}
{% block content %}
<section class="panel">
<div class="panel-head">
<h1>Federation Network</h1>
<span class="count">{{ stats.total_peers }} direct · <span id="fn-transitive-count"></span> transitive</span>
</div>
<p class="page-intro">Force-directed map of the federation this node sits inside. Self at the center, directly-registered peers at distance 1, peers-of-peers (fetched from each trusted peer's <code>/federation/network</code>) at distance 2. Edges: <em>vouches</em> (solid), <em>signals</em> (dashed, thickness ∝ 24h volume), <em>knows</em> (dotted grey).</p>
<p class="back"><a href="/admin/federation">← federation hub</a> &nbsp;·&nbsp; <a href="/admin/federation/discovery">discovery</a> &nbsp;·&nbsp; <a href="/admin/federation/vouches">vouches</a> &nbsp;·&nbsp; <a href="/admin/federation/quorum">quorum</a> &nbsp;·&nbsp; <a href="/admin/federation/log">log</a></p>
<div class="fn-stats">
<div class="fn-stat"><div class="fn-stat-label">direct peers</div><div class="fn-stat-value">{{ stats.total_peers }}</div></div>
<div class="fn-stat"><div class="fn-stat-label">vouched / trusted</div><div class="fn-stat-value">{{ stats.vouched_peers }}</div></div>
<div class="fn-stat"><div class="fn-stat-label">vouches issued</div><div class="fn-stat-value">{{ stats.vouches_issued }}</div></div>
<div class="fn-stat"><div class="fn-stat-label">signals (24h)</div><div class="fn-stat-value">{{ stats.signals_buffered_24h }}</div></div>
<div class="fn-stat"><div class="fn-stat-label">distinct hashes</div><div class="fn-stat-value">{{ stats.distinct_signal_hashes_24h }}</div></div>
<div class="fn-stat"><div class="fn-stat-label">quorum-met</div><div class="fn-stat-value">{{ stats.quorum_met_count }}</div></div>
</div>
<div class="fn-search-bar">
<label for="fn-search">filter</label>
<input type="search" id="fn-search" class="fn-search-input" placeholder="domain or fingerprint substring…" autocomplete="off" spellcheck="false">
<span id="fn-search-count" class="fn-search-count"></span>
</div>
<div class="topo-stage">
<div class="topo-toolbar">
<div class="topo-layouts" role="tablist">
<button type="button" class="topo-layout is-active" data-layout="force" title="organic force-directed">Force</button>
<button type="button" class="topo-layout" data-layout="hier" title="self on top, direct peers in a row, transitives below">Hierarchical</button>
<button type="button" class="topo-layout" data-layout="radial" title="self at center, peers on a ring, transitives outside">Radial</button>
</div>
<label class="topo-toggle"><input type="checkbox" id="fn-flow" checked> signal flow</label>
<span class="topo-legend"><span class="lg-swatch fn-lg-self"></span>self</span>
<span class="topo-legend"><span class="lg-swatch fn-lg-trusted"></span>trusted</span>
<span class="topo-legend"><span class="lg-swatch fn-lg-vouched"></span>vouched</span>
<span class="topo-legend"><span class="lg-swatch fn-lg-unknown"></span>unknown</span>
<span class="topo-legend"><span class="lg-swatch fn-lg-blocked"></span>blocked</span>
<button type="button" id="fn-reset" class="btn">re-settle</button>
<span class="topo-hint">drag · scroll to zoom · hover for tooltip</span>
</div>
<svg id="federation-network-graph" preserveAspectRatio="xMidYMid meet"></svg>
<div id="fn-tooltip" class="fn-tooltip"></div>
<div id="fn-loading" class="page-intro" style="text-align:center; margin-top:10px;">loading federation network…</div>
<div id="fn-error" class="gate-error" style="display:none;"></div>
</div>
<div id="fn-detail" class="topo-detail">
<p class="topo-detail-empty">Click any node in the graph above to inspect it.</p>
</div>
<div class="fn-timeline-wrap">
<div class="fn-timeline-head">
<h3>signals · last 24h</h3>
<span class="meta" id="fn-timeline-meta"></span>
</div>
<div id="fn-timeline" class="fn-timeline" aria-label="signals received per hour for the last 24 hours"></div>
<div id="fn-timeline-axis" class="fn-timeline-axis"></div>
</div>
<p class="page-intro" style="margin-top:18px;">Self fingerprint: <code style="color:var(--accent);">{{ fingerprint }}</code></p>
</section>
<script src="/static/federation_network.js" defer></script>
{% endblock %}

View File

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

View File

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

View File

@@ -0,0 +1,148 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0f1115">
<meta name="apple-mobile-web-app-title" content="psyc · federation explorer">
<title>Federation Explorer — psyc</title>
<link rel="icon" type="image/png" href="/static/psyc-logo.png">
<link rel="apple-touch-icon" href="/static/psyc-logo.png">
<link rel="manifest" href="/static/manifest.json">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/cockpit.css">
<link rel="stylesheet" href="/static/psyc-tokens.css">
<script defer src="https://analytics.neuronetz.ai/script.js" data-website-id="34c354e5-780e-4c42-a5ce-49b13ff3f088"></script>
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(() => {});
});
}
</script>
</head>
<body class="wide fe-page">
<header class="topbar fe-topbar">
<a class="brand" href="/federation/explore">
<img class="brand-icon" src="/static/psyc-logo.png" alt="psyc">
<span class="brand-sub">operations cockpit</span>
</a>
<span class="fe-badge" title="Public, no auth — this view is auditable by anyone">
<span class="fe-badge-dot"></span>PUBLIC · TRANSPARENT
</span>
<div class="family">
<img class="family-icon" src="/static/nn-sc-icon.png" alt="NN-sc — Security/Control" title="NN-sc · Security">
</div>
</header>
<main class="content">
<section class="panel fe-hero">
<div class="fe-hero-head">
<h1 class="fe-title">Federation Explorer</h1>
<p class="fe-sub" id="fe-node-domain">{{ domain or fingerprint }}</p>
</div>
<p class="fe-intro">
This is a <strong>public transparency view</strong> of the psyc federation this node sits inside.
Anyone on the internet can verify the trust network — who has vouched for whom,
which signals are corroborated, and that the transparency log hasn't been rewritten.
<strong>Click any peer in the graph to inspect it, then jump to that peer's own explorer.</strong>
</p>
<p class="fe-intro fe-intro-sub">
Self fingerprint:
<code class="fe-fp">{{ fingerprint }}</code>
<button type="button" class="fn-copy-btn" data-copy="{{ fingerprint }}">copy</button>
</p>
</section>
<section class="panel fe-kpi-panel">
<div class="fn-stats fe-kpis">
<div class="fn-stat"><div class="fn-stat-label">direct peers</div><div class="fn-stat-value" id="fe-kpi-peers"></div></div>
<div class="fn-stat"><div class="fn-stat-label">vouches out</div><div class="fn-stat-value" id="fe-kpi-vouches-out"></div></div>
<div class="fn-stat"><div class="fn-stat-label">vouches in</div><div class="fn-stat-value" id="fe-kpi-vouches-in"></div></div>
<div class="fn-stat"><div class="fn-stat-label">signals (24h)</div><div class="fn-stat-value" id="fe-kpi-signals"></div></div>
<div class="fn-stat"><div class="fn-stat-label">corroborations (24h)</div><div class="fn-stat-value" id="fe-kpi-corroboration"></div></div>
<div class="fn-stat"><div class="fn-stat-label">translog entries</div><div class="fn-stat-value" id="fe-kpi-translog"></div></div>
<div class="fn-stat fe-kpi-verify"><div class="fn-stat-label">log integrity</div><div class="fn-stat-value" id="fe-kpi-verify"></div></div>
</div>
<div class="fe-verify-row">
<button type="button" id="fe-verify-btn" class="btn fe-verify-btn">Verify this log →</button>
<span id="fe-verify-result" class="fe-verify-result"></span>
<a class="fe-verify-link" href="/federation/log" target="_blank" rel="noopener">raw log JSON</a>
<a class="fe-verify-link" href="/federation/explore/data" target="_blank" rel="noopener">signed payload</a>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Trust network</h2>
<span class="count"><span id="fe-direct-count"></span> direct · <span id="fe-transitive-count"></span> transitive</span>
</div>
<p class="page-intro">
Self at the center, directly-trusted peers around it,
peers-of-peers (learned from each trusted peer's signed feed) on the outer ring.
Edges: <em>vouches</em> (solid), <em>signals</em> (dashed, thickness ∝ 24h volume),
<em>knows</em> (dotted grey), <em>corroborate</em> (faint pulse — two peers reporting the same signal).
</p>
<div class="topo-stage fe-stage">
<div class="topo-toolbar">
<div class="topo-layouts" role="tablist">
<button type="button" class="topo-layout is-active" data-layout="force" title="organic force-directed">Force</button>
<button type="button" class="topo-layout" data-layout="hier" title="self on top, direct peers in a row, transitives below">Hierarchical</button>
<button type="button" class="topo-layout" data-layout="radial" title="self at center, peers on a ring, transitives outside">Radial</button>
</div>
<label class="topo-toggle"><input type="checkbox" id="fn-flow" checked> signal flow</label>
<span class="topo-legend"><span class="lg-swatch fn-lg-self"></span>self</span>
<span class="topo-legend"><span class="lg-swatch fn-lg-trusted"></span>trusted</span>
<span class="topo-legend"><span class="lg-swatch fn-lg-unknown"></span>transitive</span>
<button type="button" id="fn-reset" class="btn">re-settle</button>
<span class="topo-hint">drag · scroll to zoom · click a peer to walk to its explorer</span>
</div>
<svg id="federation-network-graph" preserveAspectRatio="xMidYMid meet"></svg>
<div id="fn-tooltip" class="fn-tooltip"></div>
<div id="fn-loading" class="page-intro" style="text-align:center; margin-top:10px;">loading federation network…</div>
<div id="fn-error" class="gate-error" style="display:none;"></div>
</div>
<div id="fe-walk" class="fe-walk">
<p class="fe-walk-empty">Click any peer in the graph above to inspect it and walk to its federation view.</p>
</div>
</section>
<section class="panel fe-vouches-panel">
<div class="panel-head">
<h2>Who vouches for this node</h2>
<span class="count"><span id="fe-vouches-in-count"></span> inbound</span>
</div>
<p class="page-intro">
Each entry is a signed vouch naming this node as target, issued by a peer we currently trust.
Click a fingerprint to highlight that peer in the graph above.
</p>
<ul id="fe-vouches-in-list" class="fe-vouches-in-list">
<li class="fe-vouches-in-empty">no inbound vouches yet</li>
</ul>
</section>
</main>
<footer class="footer fe-footer">
psyc · defensive CTI · transparent by design ·
powered by <a href="https://neuronetz.ai" target="_blank" rel="noopener">neuronetz.ai</a>
</footer>
<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 | tojson }},
selfDomain: {{ (domain or "") | tojson }},
focusPeer: {{ (peer or "") | tojson }}
};
</script>
<script src="/static/federation_explore.js" defer></script>
</body>
</html>

View File

@@ -6,6 +6,7 @@
<div class="hero-text">
<h1 class="hero-title">Defensive CTI in motion</h1>
<p class="hero-sub">What psyc has seen and done — at a glance.</p>
<p class="hero-meta"><a class="hero-explore" href="/federation/explore">Federation Explorer →</a> <span class="hero-explore-sub">public · auditable trust network</span></p>
</div>
<a class="hero-cta" href="/cases">All cases →</a>
</section>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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

427
tests/test_explore_view.py Normal file
View File

@@ -0,0 +1,427 @@
"""Federation explore view — public transparency payload tests.
Sibling to `test_network_view.py`; focused on the explore-only shape:
shape contract, signature round-trip, no-leak invariants, transitive walk,
inbound vouches filter, and the corroboration counter.
"""
from __future__ import annotations
import base64
import json
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
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 federation_routes
from psyc.lines import federation, network_view, translog
from psyc.lines.network_view import build_explore_view
# ---------- 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_explore_caches(monkeypatch):
"""Prevent route-level cache bleed between tests."""
monkeypatch.setattr(network_view, "_TRANSITIVE_CACHE", {"ts": 0.0, "view": None})
federation_routes._FEED_CACHE["payload"] = None
federation_routes._PUBLIC_PEERS_CACHE["payload"] = None
federation_routes._PUBLIC_NETWORK_CACHE["payload"] = None
if hasattr(federation_routes, "_EXPLORE_CACHE"):
federation_routes._EXPLORE_CACHE["payload"] = None
yield
def _make_peer_pubkey() -> tuple[str, str]:
"""Return (fingerprint, pubkey_pem) for a synthetic peer keypair."""
import hashlib
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
priv = ed25519.Ed25519PrivateKey.generate()
pub = priv.public_key()
pem = pub.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("ascii")
raw = pub.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
fp = hashlib.sha256(raw).digest()[:16].hex()
return fp, pem
def _silence_explore_fetch():
return patch.object(network_view, "_fetch_peer_explore", return_value=None)
def _silence_network_fetch():
return patch.object(network_view, "_fetch_peer_network", return_value=None)
# ---------- schema ------------------------------------------------------
def test_explore_view_top_level_shape(fresh_db, fed_dir):
with _silence_explore_fetch(), _silence_network_fetch():
payload = build_explore_view(node_domain="me.example")
for key in (
"version", "fingerprint", "generated_at",
"node", "peers", "vouches", "vouches_out", "vouches_in",
"transitive_peers", "corroboration_count_24h", "signature",
):
assert key in payload, f"missing {key}"
node = payload["node"]
for key in (
"fingerprint", "domain", "generated_at",
"transparency_log_head_hash", "translog_entry_count",
"peer_count", "vouches_out_count", "vouches_in_count",
"corroboration_count_24h", "signals_count_24h",
):
assert key in node, f"missing node.{key}"
assert node["domain"] == "me.example"
assert node["fingerprint"] == federation.node_fingerprint()
def test_explore_peer_carries_public_safe_stats(fresh_db, fed_dir):
fp, pem = _make_peer_pubkey()
federation.register_peer("peer.example", fp, pem, status="trusted")
now_iso = datetime.now(timezone.utc).isoformat()
for i in range(3):
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id=f"1.2.3.{i}",
signal_hash=f"hash-{i}",
received_at=now_iso,
raw_json=json.dumps({"type": "ip", "value": f"1.2.3.{i}"}),
))
with _silence_explore_fetch(), _silence_network_fetch():
payload = build_explore_view()
assert len(payload["peers"]) == 1
p = payload["peers"][0]
# Public-safe stats present.
for key in (
"signal_count_24h", "signal_count_total",
"cases_24h", "iocs_24h",
"quorum_contribution_24h", "last_seen",
):
assert key in p
assert p["signal_count_24h"] == 3
assert p["iocs_24h"] == 3
assert p["cases_24h"] == 0
# Sensitive fields are not surfaced per-peer.
assert "severity_breakdown" not in p
assert "ioc_type_breakdown" not in p
assert "recent_translog" not in p
# ---------- signature round-trip ---------------------------------------
def test_explore_view_signature_round_trip(fresh_db, fed_dir):
fp, pem = _make_peer_pubkey()
federation.register_peer("trusted.example", fp, pem, status="trusted")
federation.issue_vouch(fp, ttl_days=30)
with _silence_explore_fetch(), _silence_network_fetch():
payload = build_explore_view()
assert "signature" in payload
sig = base64.b64decode(payload["signature"])
unsigned = {k: v for k, v in payload.items() if k != "signature"}
assert federation.verify_payload(
federation.canonical_json(unsigned),
sig,
federation.public_key_pem(),
) is True
# ---------- no-leak invariants -----------------------------------------
def test_explore_view_has_no_ioc_values_or_case_ids_or_raw_json(fresh_db, fed_dir):
"""Public payload must not expose IOC values, case_ids in raw form, or raw_json.
This is the core transparency-vs-leakage contract: anyone can see who's
talking to whom and how much, but never what they're saying.
"""
fp, pem = _make_peer_pubkey()
federation.register_peer("trusted.example", fp, pem, status="trusted")
now_iso = datetime.now(timezone.utc).isoformat()
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id="evil-domain-do-not-leak.com",
signal_hash="ioc-hash-leak",
received_at=now_iso,
raw_json=json.dumps({"type": "domain", "value": "evil-domain-do-not-leak.com"}),
))
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="case",
signal_id="CASE-SECRET-42",
signal_hash="case-hash-leak",
received_at=now_iso,
raw_json=json.dumps({"severity": "critical", "case_id": "CASE-SECRET-42"}),
))
with _silence_explore_fetch(), _silence_network_fetch():
payload = build_explore_view()
flat = json.dumps(payload, default=str)
# IOC values.
assert "evil-domain-do-not-leak.com" not in flat
# Case ids (raw).
assert "CASE-SECRET-42" not in flat
# raw_json shape never serialized.
assert "raw_json" not in flat
# Sector-leaking breakdowns.
assert "severity_breakdown" not in flat
assert "ioc_type_breakdown" not in flat
# ---------- transitive peers --------------------------------------------
def test_explore_transitive_peers_populated_from_peer_responses(fresh_db, fed_dir):
direct_fp, direct_pem = _make_peer_pubkey()
federation.register_peer("direct.example", direct_fp, direct_pem, status="trusted")
far_a, _ = _make_peer_pubkey()
far_b, _ = _make_peer_pubkey()
fake_payload: Dict[str, Any] = {
"fingerprint": direct_fp,
"peers": [
{"fingerprint": far_a, "domain": "far-a.example"},
{"fingerprint": far_b, "domain": "far-b.example"},
],
"vouches": [],
}
with patch.object(network_view, "_fetch_peer_explore", return_value=fake_payload):
payload = build_explore_view()
tps = payload["transitive_peers"]
fps = {t["fingerprint"] for t in tps}
assert far_a in fps
assert far_b in fps
via_fps = {t["via_peer_fingerprint"] for t in tps}
assert via_fps == {direct_fp}
def test_explore_transitive_peers_falls_back_to_network_endpoint(fresh_db, fed_dir):
"""If a peer doesn't have /federation/explore/data (older node), fall back
to /federation/network — the public-view shape is the same {fingerprint, peers}."""
direct_fp, direct_pem = _make_peer_pubkey()
federation.register_peer("direct.example", direct_fp, direct_pem, status="trusted")
far_fp, _ = _make_peer_pubkey()
fallback_payload: Dict[str, Any] = {
"fingerprint": direct_fp,
"peers": [{"fingerprint": far_fp, "domain": "far.example"}],
"vouches": [],
}
with patch.object(network_view, "_fetch_peer_explore", return_value=None), \
patch.object(network_view, "_fetch_peer_network", return_value=fallback_payload):
payload = build_explore_view()
assert any(t["fingerprint"] == far_fp for t in payload["transitive_peers"])
def test_explore_transitive_peers_dedupe_against_direct(fresh_db, fed_dir):
"""If a transitive fp is already a direct peer, don't duplicate it."""
direct_fp, direct_pem = _make_peer_pubkey()
federation.register_peer("direct.example", direct_fp, direct_pem, status="trusted")
fake_payload = {
"fingerprint": direct_fp,
# Direct peer's own fp echoed back — must be deduped.
"peers": [{"fingerprint": direct_fp, "domain": "direct.example"}],
"vouches": [],
}
with patch.object(network_view, "_fetch_peer_explore", return_value=fake_payload):
payload = build_explore_view()
assert payload["transitive_peers"] == []
# ---------- vouches_in --------------------------------------------------
def test_explore_vouches_in_filters_to_target_self_and_trusted_vouchers(fresh_db, fed_dir):
"""vouches_in includes ONLY entries naming us as target whose voucher we trust."""
our_fp = federation.node_fingerprint()
fp_trusted, pem_t = _make_peer_pubkey()
fp_unknown, pem_u = _make_peer_pubkey()
federation.register_peer("trusted.example", fp_trusted, pem_t, status="trusted")
federation.register_peer("unknown.example", fp_unknown, pem_u, status="unknown")
now = datetime.now(timezone.utc).isoformat()
# Trusted peer vouches for us.
db.upsert_vouch(dict(
voucher_fingerprint=fp_trusted,
target_fingerprint=our_fp,
issued_at=now,
expires_at=None,
signature="trusted-sig",
))
# Unknown peer also "vouches" for us — must NOT leak.
db.upsert_vouch(dict(
voucher_fingerprint=fp_unknown,
target_fingerprint=our_fp,
issued_at=now,
expires_at=None,
signature="rogue-sig",
))
# Vouch naming someone else — must NOT appear in vouches_in.
other_fp, _ = _make_peer_pubkey()
db.upsert_vouch(dict(
voucher_fingerprint=fp_trusted,
target_fingerprint=other_fp,
issued_at=now,
expires_at=None,
signature="other-sig",
))
with _silence_explore_fetch(), _silence_network_fetch():
payload = build_explore_view()
vouchers = {v["voucher_fingerprint"] for v in payload["vouches_in"]}
assert vouchers == {fp_trusted}
# And the rogue signature is not anywhere in the payload.
assert "rogue-sig" not in json.dumps(payload, default=str)
# ---------- corroboration counter --------------------------------------
def test_explore_corroboration_count_matches_distinct_shared_hashes(fresh_db, fed_dir):
fp_a, pem_a = _make_peer_pubkey()
fp_b, pem_b = _make_peer_pubkey()
federation.register_peer("a.example", fp_a, pem_a, status="trusted")
federation.register_peer("b.example", fp_b, pem_b, status="trusted")
now_iso = datetime.now(timezone.utc).isoformat()
# Two shared hashes between A and B.
for h in ("shared-1", "shared-2"):
for fp in (fp_a, fp_b):
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id="x",
signal_hash=h,
received_at=now_iso,
raw_json="{}",
))
# One solo hash — must NOT count.
db.record_signal(dict(
peer_fingerprint=fp_a,
signal_type="ioc",
signal_id="solo",
signal_hash="solo-hash",
received_at=now_iso,
raw_json="{}",
))
with _silence_explore_fetch(), _silence_network_fetch():
payload = build_explore_view()
assert payload["corroboration_count_24h"] == 2
assert payload["node"]["corroboration_count_24h"] == 2
# ---------- transparency log headline ----------------------------------
def test_explore_node_translog_headline_reflects_chain(fresh_db, fed_dir):
translog.append("vouch", {"foo": "bar"})
translog.append("signal", {"x": 1})
with _silence_explore_fetch(), _silence_network_fetch():
payload = build_explore_view()
node = payload["node"]
assert node["translog_entry_count"] == 2
assert isinstance(node["transparency_log_head_hash"], str)
assert len(node["transparency_log_head_hash"]) == 64 # hex sha256
# ---------- HTTP routes -------------------------------------------------
def _mk_app() -> FastAPI:
app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key="test-secret")
import tempfile
from pathlib import Path as _Path
# We need real templates for /federation/explore HTML response.
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_explore_endpoint_returns_html(fresh_db, fed_dir):
with _silence_explore_fetch(), _silence_network_fetch():
client = TestClient(_mk_app())
r = client.get("/federation/explore")
assert r.status_code == 200
assert "text/html" in r.headers.get("content-type", "")
# Banner + page title are present.
body = r.text
assert "Federation Explorer" in body
def test_federation_explore_data_returns_signed_json(fresh_db, fed_dir):
fp, pem = _make_peer_pubkey()
federation.register_peer("trusted.example", fp, pem, status="trusted")
with _silence_explore_fetch(), _silence_network_fetch():
client = TestClient(_mk_app())
r = client.get("/federation/explore/data")
assert r.status_code == 200
data = r.json()
assert "signature" in data
assert "node" in data
sig = base64.b64decode(data["signature"])
unsigned = {k: v for k, v in data.items() if k != "signature"}
assert federation.verify_payload(
federation.canonical_json(unsigned),
sig,
federation.public_key_pem(),
) is True
def test_federation_explore_data_has_cors_header(fresh_db, fed_dir):
"""Other psyc nodes' explore pages need to fetch this from the browser."""
with _silence_explore_fetch(), _silence_network_fetch():
client = TestClient(_mk_app())
r = client.get("/federation/explore/data")
assert r.status_code == 200
assert r.headers.get("access-control-allow-origin") == "*"
def test_federation_info_has_explore_and_cors(fresh_db, fed_dir):
client = TestClient(_mk_app())
r = client.get("/federation/info")
assert r.status_code == 200
data = r.json()
assert data.get("explore") == "/federation/explore"
assert r.headers.get("access-control-allow-origin") == "*"
def test_existing_public_endpoints_have_cors_header(fresh_db, fed_dir):
"""All public endpoints must be cross-origin fetchable for the explorer."""
client = TestClient(_mk_app())
for path in (
"/federation/key",
"/federation/feed",
"/federation/vouches",
"/federation/log",
"/federation/log/verify",
"/federation/peers/public",
"/federation/network",
):
r = client.get(path)
assert r.status_code in (200, 409), f"{path} status {r.status_code}"
assert r.headers.get("access-control-allow-origin") == "*", f"{path} missing CORS"

View File

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

151
tests/test_inference.py Normal file
View File

@@ -0,0 +1,151 @@
"""Tests for the inference client — both psyc-native and openai-compatible modes."""
from __future__ import annotations
import importlib
import json
from typing import Any, Dict
import httpx
import pytest
from psyc.cockpit import inference
from psyc.models import Case, Classification, Confidence, Severity, TLP, Observables, Evidence, Victim
def _reload_with_env(monkeypatch, **env: str) -> Any:
for k, v in env.items():
monkeypatch.setenv(k, v)
return importlib.reload(inference)
def _case() -> Case:
return Case(
case_id="C-T-1",
summary="test",
source_type="test",
source_ref="",
observed_at="2026-01-01T00:00:00+00:00",
ingested_at="2026-01-01T00:00:00+00:00",
classification=Classification(tlp=TLP.GREEN, severity=Severity.HIGH),
confidence=Confidence(level="medium", source_reliability="B", information_credibility="2"),
observables=Observables(),
evidence=Evidence(),
source_metadata={},
victim=Victim(),
)
def test_no_auth_header_when_token_unset(monkeypatch):
mod = _reload_with_env(monkeypatch, PSYC_INFERENCE_TOKEN="")
assert mod._auth_headers() == {}
def test_bearer_header_when_token_set(monkeypatch):
mod = _reload_with_env(monkeypatch, PSYC_INFERENCE_TOKEN="abc123")
assert mod._auth_headers() == {"Authorization": "Bearer abc123"}
def test_psyc_mode_server_adapter(monkeypatch):
mod = _reload_with_env(monkeypatch, PSYC_INFERENCE_MODE="psyc", PSYC_INFERENCE_URL="http://x")
seen: Dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["url"] = str(request.url)
seen["method"] = request.method
return httpx.Response(200, json={"adapter": "/data/adapters/psyc-v5/final"})
transport = httpx.MockTransport(handler)
real_client = httpx.Client
monkeypatch.setattr(httpx, "Client", lambda **kw: real_client(transport=transport, **{k: v for k, v in kw.items() if k != "transport"}))
assert mod.server_adapter() == "/data/adapters/psyc-v5/final"
assert seen["url"].endswith("/healthz")
def test_openai_mode_server_adapter(monkeypatch):
mod = _reload_with_env(
monkeypatch,
PSYC_INFERENCE_MODE="openai",
PSYC_INFERENCE_URL="https://api.example",
PSYC_INFERENCE_TOKEN="t0k",
PSYC_INFERENCE_MODEL="psyc-v5",
)
seen: Dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["url"] = str(request.url)
seen["auth"] = request.headers.get("authorization")
return httpx.Response(200, json={"data": [{"id": "llama3"}, {"id": "mistral"}]})
transport = httpx.MockTransport(handler)
real_client = httpx.Client
monkeypatch.setattr(httpx, "Client", lambda **kw: real_client(transport=transport, **{k: v for k, v in kw.items() if k != "transport"}))
assert mod.server_adapter() == "llama3"
assert seen["url"].endswith("/v1/models")
assert seen["auth"] == "Bearer t0k"
def test_openai_mode_severity_request_shape(monkeypatch):
mod = _reload_with_env(
monkeypatch,
PSYC_INFERENCE_MODE="openai",
PSYC_INFERENCE_URL="https://api.example",
PSYC_INFERENCE_TOKEN="t0k",
PSYC_INFERENCE_MODEL="psyc-v5",
)
sent: Dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
sent["url"] = str(request.url)
sent["auth"] = request.headers.get("authorization")
sent["body"] = json.loads(request.content.decode())
return httpx.Response(200, json={"choices": [{"message": {"content": "HIGH"}}]})
transport = httpx.MockTransport(handler)
real_client = httpx.Client
monkeypatch.setattr(httpx, "Client", lambda **kw: real_client(transport=transport, **{k: v for k, v in kw.items() if k != "transport"}))
result = mod.model_severity(_case())
assert result == "high"
assert sent["url"].endswith("/v1/chat/completions")
assert sent["auth"] == "Bearer t0k"
assert sent["body"]["model"] == "psyc-v5"
assert sent["body"]["messages"][0]["role"] == "system"
assert sent["body"]["messages"][1]["role"] == "user"
assert sent["body"]["max_tokens"] == 16
def test_psyc_mode_severity_unchanged(monkeypatch):
mod = _reload_with_env(monkeypatch, PSYC_INFERENCE_MODE="psyc", PSYC_INFERENCE_URL="http://x", PSYC_INFERENCE_TOKEN="")
sent: Dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
sent["url"] = str(request.url)
sent["auth"] = request.headers.get("authorization")
sent["body"] = json.loads(request.content.decode())
return httpx.Response(200, json={"output": "MEDIUM"})
transport = httpx.MockTransport(handler)
real_client = httpx.Client
monkeypatch.setattr(httpx, "Client", lambda **kw: real_client(transport=transport, **{k: v for k, v in kw.items() if k != "transport"}))
assert mod.model_severity(_case()) == "medium"
assert sent["url"].endswith("/infer")
assert sent["auth"] is None
assert "instruction" in sent["body"]
assert "max_new_tokens" in sent["body"]
def test_server_adapter_returns_none_on_http_error(monkeypatch):
mod = _reload_with_env(monkeypatch, PSYC_INFERENCE_MODE="openai", PSYC_INFERENCE_URL="https://api.example")
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={"error": "unauthorized"})
transport = httpx.MockTransport(handler)
real_client = httpx.Client
monkeypatch.setattr(httpx, "Client", lambda **kw: real_client(transport=transport, **{k: v for k, v in kw.items() if k != "transport"}))
assert mod.server_adapter() is None

702
tests/test_network_view.py Normal file
View File

@@ -0,0 +1,702 @@
"""Network view — local + transitive + public payload tests."""
from __future__ import annotations
import base64
import json
from datetime import datetime, timedelta, timezone
from typing import Any, Dict
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine
from psyc import db
from psyc.lines import federation, network_view, translog
from psyc.lines.network_view import (
NetworkEdge,
NetworkNode,
NetworkView,
build_admin_view,
build_explore_view,
build_local_view,
build_public_view,
build_transitive_view,
)
# ---------- 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_transitive_cache(monkeypatch):
"""Prevent cache bleed between tests."""
monkeypatch.setattr(network_view, "_TRANSITIVE_CACHE", {"ts": 0.0, "view": None})
yield
def _make_peer_pubkey() -> tuple[str, str]:
"""Return (fingerprint, pubkey_pem) for a synthetic peer keypair."""
import hashlib
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
priv = ed25519.Ed25519PrivateKey.generate()
pub = priv.public_key()
pem = pub.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("ascii")
raw = pub.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
fp = hashlib.sha256(raw).digest()[:16].hex()
return fp, pem
# ---------- local view --------------------------------------------------
def test_local_view_empty_registry_yields_only_self(fresh_db, fed_dir):
view = build_local_view()
assert isinstance(view, NetworkView)
assert len(view.nodes) == 1
self_node = view.nodes[0]
assert self_node.is_self is True
assert self_node.distance == 0
assert self_node.status == "self"
assert self_node.fingerprint == federation.node_fingerprint()
assert view.edges == []
assert view.stats["total_peers"] == 0
assert view.stats["vouched_peers"] == 0
assert view.stats["signals_buffered_24h"] == 0
def test_local_view_one_trusted_peer_no_edges(fresh_db, fed_dir):
peer_fp, peer_pem = _make_peer_pubkey()
federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted")
view = build_local_view()
assert len(view.nodes) == 2
peer_node = next(n for n in view.nodes if not n.is_self)
assert peer_node.fingerprint == peer_fp
assert peer_node.status == "trusted"
assert peer_node.distance == 1
assert peer_node.domain == "peer.example"
assert view.edges == []
assert view.stats["total_peers"] == 1
assert view.stats["vouched_peers"] == 1
def test_local_view_outbound_vouch_creates_edge(fresh_db, fed_dir):
peer_fp, peer_pem = _make_peer_pubkey()
federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted")
federation.issue_vouch(peer_fp, ttl_days=30)
view = build_local_view()
vouch_edges = [e for e in view.edges if e.kind == "vouch"]
assert len(vouch_edges) == 1
e = vouch_edges[0]
assert e.source_fingerprint == federation.node_fingerprint()
assert e.target_fingerprint == peer_fp
assert view.stats["vouches_issued"] == 1
def test_local_view_inbound_vouch_creates_edge(fresh_db, fed_dir):
"""Vouches received that name us as target → peer → self edge."""
peer_fp, peer_pem = _make_peer_pubkey()
federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted")
# Insert a vouch where peer vouches FOR us, bypassing accept_vouch (which
# we don't need to exercise here — the question is render shape).
our_fp = federation.node_fingerprint()
now = datetime.now(timezone.utc)
db.upsert_vouch(dict(
voucher_fingerprint=peer_fp,
target_fingerprint=our_fp,
issued_at=now.isoformat(),
expires_at=(now + timedelta(days=30)).isoformat(),
signature="x" * 88,
))
view = build_local_view()
vouch_edges = [e for e in view.edges if e.kind == "vouch"]
assert len(vouch_edges) == 1
e = vouch_edges[0]
assert e.source_fingerprint == peer_fp
assert e.target_fingerprint == our_fp
def test_local_view_bidirectional_vouches_collapse(fresh_db, fed_dir):
peer_fp, peer_pem = _make_peer_pubkey()
federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted")
federation.issue_vouch(peer_fp, ttl_days=30)
# And peer vouches back at us.
our_fp = federation.node_fingerprint()
now = datetime.now(timezone.utc)
db.upsert_vouch(dict(
voucher_fingerprint=peer_fp,
target_fingerprint=our_fp,
issued_at=now.isoformat(),
expires_at=(now + timedelta(days=30)).isoformat(),
signature="x" * 88,
))
view = build_local_view()
vouch_edges = [e for e in view.edges if e.kind == "vouch"]
assert len(vouch_edges) == 1
assert vouch_edges[0].bidirectional is True
def test_local_view_signal_edge_weight_is_24h_count(fresh_db, fed_dir):
peer_fp, peer_pem = _make_peer_pubkey()
federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted")
now_iso = datetime.now(timezone.utc).isoformat()
# Three signals from this peer within the window.
for i in range(3):
db.record_signal(dict(
peer_fingerprint=peer_fp,
signal_type="ioc",
signal_id=f"1.2.3.{i}",
signal_hash=f"hash-{i}",
received_at=now_iso,
raw_json="{}",
))
# One stale signal outside the window — must be ignored.
stale = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()
db.record_signal(dict(
peer_fingerprint=peer_fp,
signal_type="ioc",
signal_id="9.9.9.9",
signal_hash="stale",
received_at=stale,
raw_json="{}",
))
view = build_local_view()
sig_edges = [e for e in view.edges if e.kind == "signal"]
assert len(sig_edges) == 1
assert sig_edges[0].weight == 3.0
assert sig_edges[0].source_fingerprint == peer_fp
assert sig_edges[0].target_fingerprint == federation.node_fingerprint()
assert view.stats["signals_buffered_24h"] == 3
assert view.stats["distinct_signal_hashes_24h"] == 3
def test_local_view_blocked_peer_renders_with_blocked_status(fresh_db, fed_dir):
fp, pem = _make_peer_pubkey()
federation.register_peer("blocked.example", fp, pem, status="blocked")
view = build_local_view()
peer = next(n for n in view.nodes if not n.is_self)
assert peer.status == "blocked"
# ---------- public view + signature round-trip --------------------------
def test_public_view_excludes_unknown_and_blocked(fresh_db, fed_dir):
fp_t, pem_t = _make_peer_pubkey()
fp_u, pem_u = _make_peer_pubkey()
fp_b, pem_b = _make_peer_pubkey()
federation.register_peer("trusted.example", fp_t, pem_t, status="trusted")
federation.register_peer("unknown.example", fp_u, pem_u, status="unknown")
federation.register_peer("blocked.example", fp_b, pem_b, status="blocked")
payload = build_public_view()
fps = {p["fingerprint"] for p in payload["peers"]}
assert fp_t in fps
assert fp_u not in fps
assert fp_b not in fps
def test_public_view_signature_round_trip(fresh_db, fed_dir):
fp, pem = _make_peer_pubkey()
federation.register_peer("trusted.example", fp, pem, status="trusted")
federation.issue_vouch(fp, ttl_days=30)
payload = build_public_view()
assert "signature" in payload
assert payload["fingerprint"] == federation.node_fingerprint()
sig = base64.b64decode(payload["signature"])
unsigned = {k: v for k, v in payload.items() if k != "signature"}
assert federation.verify_payload(
federation.canonical_json(unsigned),
sig,
federation.public_key_pem(),
) is True
# Vouch we issued is in the payload.
targets = {v["target_fingerprint"] for v in payload["vouches"]}
assert fp in targets
def test_public_view_omits_signals(fresh_db, fed_dir):
"""Public payload must not leak who's reporting what."""
fp, pem = _make_peer_pubkey()
federation.register_peer("trusted.example", fp, pem, status="trusted")
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id="1.2.3.4",
signal_hash="secret-hash",
received_at=datetime.now(timezone.utc).isoformat(),
raw_json="{}",
))
payload = build_public_view()
# No signal-shaped fields anywhere in the payload.
flat = str(payload)
assert "secret-hash" not in flat
assert "signals" not in payload
# ---------- transitive view ---------------------------------------------
def test_transitive_view_adds_distance_2_nodes(fresh_db, fed_dir):
direct_fp, direct_pem = _make_peer_pubkey()
federation.register_peer("direct.example", direct_fp, direct_pem, status="trusted")
# The peer reports two peers of its own.
far_fp_a, _ = _make_peer_pubkey()
far_fp_b, _ = _make_peer_pubkey()
fake_payload: Dict[str, Any] = {
"fingerprint": direct_fp,
"peers": [
{"fingerprint": far_fp_a, "domain": "far-a.example"},
{"fingerprint": far_fp_b, "domain": "far-b.example"},
],
"vouches": [],
}
with patch.object(network_view, "_fetch_peer_network", return_value=fake_payload):
view = build_transitive_view(force_refresh=True)
distances = sorted(n.distance for n in view.nodes)
assert 0 in distances and 1 in distances and 2 in distances
transitive_fps = {n.fingerprint for n in view.nodes if n.distance == 2}
assert far_fp_a in transitive_fps
assert far_fp_b in transitive_fps
# "knows" edges from direct peer to each transitive.
knows = [e for e in view.edges if e.kind == "knows"]
assert len(knows) == 2
assert all(e.source_fingerprint == direct_fp for e in knows)
assert view.stats["transitive_nodes"] == 2
def test_transitive_view_failed_fetch_does_not_abort(fresh_db, fed_dir):
fp_a, pem_a = _make_peer_pubkey()
fp_b, pem_b = _make_peer_pubkey()
federation.register_peer("peer-a.example", fp_a, pem_a, status="trusted")
federation.register_peer("peer-b.example", fp_b, pem_b, status="trusted")
far_fp, _ = _make_peer_pubkey()
def fake_fetch(domain, timeout=4.0):
if domain == "peer-a.example":
return None # simulate a fetch failure
return {
"fingerprint": fp_b,
"peers": [{"fingerprint": far_fp, "domain": "far.example"}],
"vouches": [],
}
with patch.object(network_view, "_fetch_peer_network", side_effect=fake_fetch):
view = build_transitive_view(force_refresh=True)
# Direct nodes both present, transitive only from B.
assert any(n.fingerprint == fp_a for n in view.nodes)
assert any(n.fingerprint == fp_b for n in view.nodes)
assert any(n.fingerprint == far_fp and n.distance == 2 for n in view.nodes)
assert view.stats["transitive_nodes"] == 1
def test_transitive_view_skips_only_unknown_peers(fresh_db, fed_dir):
"""Unknown peers shouldn't be queried — fetcher only called for trusted."""
fp_unknown, pem_u = _make_peer_pubkey()
fp_trusted, pem_t = _make_peer_pubkey()
federation.register_peer("unknown.example", fp_unknown, pem_u, status="unknown")
federation.register_peer("trusted.example", fp_trusted, pem_t, status="trusted")
calls = []
def fake_fetch(domain, timeout=4.0):
calls.append(domain)
return None
with patch.object(network_view, "_fetch_peer_network", side_effect=fake_fetch):
build_transitive_view(force_refresh=True)
assert "trusted.example" in calls
assert "unknown.example" not in calls
# ---------- admin view: per-peer enrichment + corroboration + timeline ---
def _no_transitive():
"""patch.object helper — silence network fetches in admin-view tests."""
return patch.object(network_view, "_fetch_peer_network", return_value=None)
def test_admin_view_includes_stats_on_peer_nodes(fresh_db, fed_dir):
"""Every non-self node must carry a `stats` dict in the admin view."""
fp, pem = _make_peer_pubkey()
federation.register_peer("peer.example", fp, pem, status="trusted")
with _no_transitive():
view = build_admin_view(include_transitive=False)
self_nodes = [n for n in view["nodes"] if n["is_self"]]
peer_nodes = [n for n in view["nodes"] if not n["is_self"]]
assert len(self_nodes) == 1
assert len(peer_nodes) == 1
# Self has no stats; peers do.
assert self_nodes[0]["stats"] is None
peer_stats = peer_nodes[0]["stats"]
assert isinstance(peer_stats, dict)
for key in (
"signals_24h", "signals_total", "cases_24h", "iocs_24h",
"severity_breakdown", "ioc_type_breakdown",
"vouches_in_count", "vouches_out_count",
"quorum_contribution", "last_seen", "last_seen_relative",
"recent_translog",
):
assert key in peer_stats, f"missing {key}"
# last_seen is None when no signals have landed yet.
assert peer_stats["last_seen"] is None
assert peer_stats["last_seen_relative"] == ""
def test_admin_view_signals_24h_excludes_stale(fresh_db, fed_dir):
"""signals_24h must count only rows inside the 24h window."""
fp, pem = _make_peer_pubkey()
federation.register_peer("peer.example", fp, pem, status="trusted")
now_iso = datetime.now(timezone.utc).isoformat()
stale_iso = (datetime.now(timezone.utc) - timedelta(hours=30)).isoformat()
for i in range(3):
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id=f"v{i}",
signal_hash=f"h{i}",
received_at=now_iso,
raw_json="{}",
))
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id="stale",
signal_hash="stale-hash",
received_at=stale_iso,
raw_json="{}",
))
with _no_transitive():
view = build_admin_view(include_transitive=False)
peer = next(n for n in view["nodes"] if not n["is_self"])
assert peer["stats"]["signals_24h"] == 3
# All-time total still sees the stale row.
assert peer["stats"]["signals_total"] == 4
# last_seen is populated and the relative is a short string.
assert peer["stats"]["last_seen"] is not None
assert peer["stats"]["last_seen_relative"] != ""
def test_admin_view_severity_and_ioc_breakdown_from_raw_json(fresh_db, fed_dir):
"""severity_breakdown is read from raw_json for case rows, ioc_type for ioc rows."""
fp, pem = _make_peer_pubkey()
federation.register_peer("peer.example", fp, pem, status="trusted")
now_iso = datetime.now(timezone.utc).isoformat()
cases = [
{"severity": "critical", "case_id": "c1"},
{"severity": "critical", "case_id": "c2"},
{"severity": "high", "case_id": "c3"},
{"severity": "low", "case_id": "c4"},
]
for c in cases:
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="case",
signal_id=c["case_id"],
signal_hash=f"hash-{c['case_id']}",
received_at=now_iso,
raw_json=json.dumps(c),
))
iocs = [
{"type": "url", "value": "https://a"},
{"type": "url", "value": "https://b"},
{"type": "domain", "value": "x.com"},
{"type": "ip", "value": "1.2.3.4"},
]
for ioc in iocs:
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id=ioc["value"],
signal_hash=f"hash-{ioc['value']}",
received_at=now_iso,
raw_json=json.dumps(ioc),
))
with _no_transitive():
view = build_admin_view(include_transitive=False)
stats = next(n for n in view["nodes"] if not n["is_self"])["stats"]
assert stats["cases_24h"] == 4
assert stats["iocs_24h"] == 4
sev = stats["severity_breakdown"]
assert sev == {"critical": 2, "high": 1, "medium": 0, "low": 1}
ioc_t = stats["ioc_type_breakdown"]
assert ioc_t == {"url": 2, "domain": 1, "ip": 1, "hash": 0, "cve": 0}
def test_admin_view_vouch_in_out_counts(fresh_db, fed_dir):
"""vouches_in_count counts vouches naming this peer; out counts what they've issued."""
fp_a, pem_a = _make_peer_pubkey()
fp_b, pem_b = _make_peer_pubkey()
federation.register_peer("a.example", fp_a, pem_a, status="trusted")
federation.register_peer("b.example", fp_b, pem_b, status="trusted")
now = datetime.now(timezone.utc).isoformat()
# A vouches for B; we vouch for B too — B sees vouches_in=2.
db.upsert_vouch(dict(
voucher_fingerprint=fp_a,
target_fingerprint=fp_b,
issued_at=now, expires_at=None, signature="x",
))
federation.issue_vouch(fp_b, ttl_days=30)
# B vouches for A — A sees vouches_in=1, B sees vouches_out=1.
db.upsert_vouch(dict(
voucher_fingerprint=fp_b,
target_fingerprint=fp_a,
issued_at=now, expires_at=None, signature="y",
))
with _no_transitive():
view = build_admin_view(include_transitive=False)
by_fp = {n["fingerprint"]: n for n in view["nodes"] if not n["is_self"]}
assert by_fp[fp_a]["stats"]["vouches_in_count"] == 1
assert by_fp[fp_a]["stats"]["vouches_out_count"] == 1 # vouches for B
assert by_fp[fp_b]["stats"]["vouches_in_count"] == 2 # A + us
assert by_fp[fp_b]["stats"]["vouches_out_count"] == 1 # vouches for A
def test_admin_view_corroborated_signals(fresh_db, fed_dir):
"""Pairs of peers reporting the same signal_hash → corroborated entry + edge."""
fp_a, pem_a = _make_peer_pubkey()
fp_b, pem_b = _make_peer_pubkey()
federation.register_peer("a.example", fp_a, pem_a, status="trusted")
federation.register_peer("b.example", fp_b, pem_b, status="trusted")
now_iso = datetime.now(timezone.utc).isoformat()
for peer_fp in (fp_a, fp_b):
db.record_signal(dict(
peer_fingerprint=peer_fp,
signal_type="ioc",
signal_id="evil.com",
signal_hash="shared-hash-1",
received_at=now_iso,
raw_json="{}",
))
# A also reports a hash B doesn't — should NOT corroborate.
db.record_signal(dict(
peer_fingerprint=fp_a,
signal_type="ioc",
signal_id="solo.com",
signal_hash="solo-hash",
received_at=now_iso,
raw_json="{}",
))
with _no_transitive():
view = build_admin_view(include_transitive=False)
corr = view["stats"]["corroborated_signals"]
hashes = {c["signal_hash"] for c in corr}
assert "shared-hash-1" in hashes
assert "solo-hash" not in hashes
shared = next(c for c in corr if c["signal_hash"] == "shared-hash-1")
assert set(shared["peer_fingerprints"]) == {fp_a, fp_b}
assert shared["peer_count"] == 2
# One corroborate edge between the pair (orientation-independent).
corr_edges = [e for e in view["edges"] if e["kind"] == "corroborate"]
assert len(corr_edges) == 1
pair = {corr_edges[0]["source_fingerprint"], corr_edges[0]["target_fingerprint"]}
assert pair == {fp_a, fp_b}
assert corr_edges[0]["weight"] == 1.0
def test_admin_view_signal_timeline_24h_returns_24_buckets(fresh_db, fed_dir):
"""signal_timeline_24h is a 24-bucket list with correct totals."""
fp, pem = _make_peer_pubkey()
federation.register_peer("peer.example", fp, pem, status="trusted")
now = datetime.now(timezone.utc)
# Two signals one hour ago, three signals five hours ago.
one_h = (now - timedelta(hours=1, minutes=5)).isoformat()
five_h = (now - timedelta(hours=5, minutes=5)).isoformat()
for i in range(2):
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id=f"a{i}",
signal_hash=f"h-a-{i}",
received_at=one_h,
raw_json="{}",
))
for i in range(3):
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id=f"b{i}",
signal_hash=f"h-b-{i}",
received_at=five_h,
raw_json="{}",
))
# Stale signal — must NOT show up.
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id="stale",
signal_hash="stale-hash",
received_at=(now - timedelta(hours=48)).isoformat(),
raw_json="{}",
))
with _no_transitive():
view = build_admin_view(include_transitive=False)
buckets = view["stats"]["signal_timeline_24h"]
assert isinstance(buckets, list)
assert len(buckets) == 24
totals = [b["total"] for b in buckets]
assert sum(totals) == 5 # stale excluded
# Bucket hour_offsets are 0..23 in oldest-first order.
assert [b["hour_offset"] for b in buckets] == list(range(24))
def test_admin_view_quorum_contribution(fresh_db, fed_dir):
"""quorum_contribution counts this peer's distinct hashes that are quorum-met."""
fp_a, pem_a = _make_peer_pubkey()
fp_b, pem_b = _make_peer_pubkey()
federation.register_peer("a.example", fp_a, pem_a, status="trusted")
federation.register_peer("b.example", fp_b, pem_b, status="trusted")
now_iso = datetime.now(timezone.utc).isoformat()
# Shared hash → both peers report it → quorum-met (default k=2).
for peer_fp in (fp_a, fp_b):
db.record_signal(dict(
peer_fingerprint=peer_fp,
signal_type="ioc",
signal_id="shared",
signal_hash="quorum-hash",
received_at=now_iso,
raw_json="{}",
))
# Solo hash from A → not quorum-met.
db.record_signal(dict(
peer_fingerprint=fp_a,
signal_type="ioc",
signal_id="solo",
signal_hash="solo-hash",
received_at=now_iso,
raw_json="{}",
))
with _no_transitive():
view = build_admin_view(include_transitive=False)
by_fp = {n["fingerprint"]: n for n in view["nodes"] if not n["is_self"]}
assert by_fp[fp_a]["stats"]["quorum_contribution"] == 1
assert by_fp[fp_b]["stats"]["quorum_contribution"] == 1
def test_admin_view_recent_translog_per_peer(fresh_db, fed_dir):
"""recent_translog lists entries where entry_data.peer_fingerprint matches."""
fp_a, pem_a = _make_peer_pubkey()
fp_b, pem_b = _make_peer_pubkey()
federation.register_peer("a.example", fp_a, pem_a, status="trusted")
federation.register_peer("b.example", fp_b, pem_b, status="trusted")
# Append translog rows that name each peer.
translog.append("signal", {"peer_fingerprint": fp_a, "signal_type": "ioc", "signal_id": "x"})
translog.append("signal", {"peer_fingerprint": fp_b, "signal_type": "case", "signal_id": "y"})
translog.append("signal", {"peer_fingerprint": fp_a, "signal_type": "case", "signal_id": "z"})
with _no_transitive():
view = build_admin_view(include_transitive=False)
by_fp = {n["fingerprint"]: n for n in view["nodes"] if not n["is_self"]}
a_log = by_fp[fp_a]["stats"]["recent_translog"]
b_log = by_fp[fp_b]["stats"]["recent_translog"]
assert len(a_log) == 2
assert len(b_log) == 1
# Each row carries the documented shape.
for row in a_log + b_log:
assert set(row.keys()) == {"id", "entry_type", "timestamp", "hash"}
def test_explore_view_omits_ioc_values_case_ids_and_raw_json(fresh_db, fed_dir):
"""The public explore payload must NEVER expose IOC values, case_ids, or raw_json.
This is the load-bearing transparency-vs-leakage contract that lives at
the network-view layer — anyone can audit who's talking to whom and how
much, but never *what* they're saying.
"""
fp, pem = _make_peer_pubkey()
federation.register_peer("trusted.example", fp, pem, status="trusted")
now_iso = datetime.now(timezone.utc).isoformat()
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id="evil-domain-do-not-leak.com",
signal_hash="ioc-hash-leak",
received_at=now_iso,
raw_json=json.dumps({"type": "domain", "value": "evil-domain-do-not-leak.com"}),
))
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="case",
signal_id="CASE-SECRET-42",
signal_hash="case-hash-leak",
received_at=now_iso,
raw_json=json.dumps({"severity": "critical", "case_id": "CASE-SECRET-42"}),
))
with patch.object(network_view, "_fetch_peer_explore", return_value=None), \
patch.object(network_view, "_fetch_peer_network", return_value=None):
payload = build_explore_view()
flat = json.dumps(payload, default=str)
assert "evil-domain-do-not-leak.com" not in flat
assert "CASE-SECRET-42" not in flat
assert "raw_json" not in flat
# Sector-leaking breakdowns must not appear either.
assert "severity_breakdown" not in flat
assert "ioc_type_breakdown" not in flat
# And peer rows carry only public-safe counts.
for p in payload.get("peers", []):
assert "severity_breakdown" not in p
assert "ioc_type_breakdown" not in p
assert "recent_translog" not in p
def test_public_view_still_has_no_stats(fresh_db, fed_dir):
"""Public payload must not surface admin-only enrichments — sensitive.
Even after `build_admin_view` has been invoked (which mutates node.stats
on the cached transitive view), the public view path must stay clean.
"""
fp, pem = _make_peer_pubkey()
federation.register_peer("trusted.example", fp, pem, status="trusted")
# Seed signals + corroborated hash so admin view has rich state.
now_iso = datetime.now(timezone.utc).isoformat()
db.record_signal(dict(
peer_fingerprint=fp,
signal_type="ioc",
signal_id="leak",
signal_hash="leak-hash",
received_at=now_iso,
raw_json=json.dumps({"type": "url", "value": "https://leak"}),
))
# Build admin view first so any caching kicks in.
with _no_transitive():
build_admin_view(include_transitive=False)
# Now build the public view and assert no admin-only fields leak.
payload = build_public_view()
flat = json.dumps(payload, default=str)
assert "signals_24h" not in flat
assert "severity_breakdown" not in flat
assert "corroborated_signals" not in flat
assert "signal_timeline_24h" not in flat
assert "recent_translog" not in flat
assert "leak-hash" not in flat
# Peer entries in the public view never carry a `stats` field.
for p in payload.get("peers", []):
assert "stats" not in p

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"