Compare commits
24 Commits
e33c5b41f5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f12e344a8 | ||
|
|
00cd8ca252 | ||
|
|
77e4cb6ab9 | ||
|
|
9ba4cd2189 | ||
|
|
155d6eaaf9 | ||
|
|
d998be276b | ||
|
|
367f17a013 | ||
|
|
a8216d00ef | ||
|
|
8587e079bb | ||
|
|
cef3bcb1ed | ||
|
|
9ab3271bc8 | ||
|
|
c2bd68e246 | ||
|
|
587fd07d38 | ||
|
|
ca6ba83950 | ||
|
|
a10203d8f1 | ||
|
|
56466c334d | ||
|
|
351e16c3ce | ||
|
|
2c7f71eff8 | ||
|
|
925bf76a0b | ||
|
|
0d9baef4c8 | ||
|
|
980cf74b76 | ||
|
|
70b6af6a35 | ||
|
|
15749e050e | ||
|
|
c6c5d3b2ea |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
65
scripts/deploy-all.sh
Executable 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
6
scripts/hosts.example
Normal 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
|
||||
@@ -13,7 +13,7 @@ import httpx
|
||||
import typer
|
||||
|
||||
from psyc import db, log
|
||||
from psyc.lines import discovery, federation, network_view, pulse, translog
|
||||
from psyc.lines import discovery, federation, network_view, pulse, topology_export, translog
|
||||
from psyc.result import Err, Ok
|
||||
|
||||
|
||||
@@ -326,6 +326,18 @@ def register(typer_app: typer.Typer) -> None:
|
||||
for k, v in view.stats.items():
|
||||
typer.echo(f" {k:<32} {v}")
|
||||
|
||||
@typer_app.command("fed-topology")
|
||||
def fed_topology() -> None:
|
||||
"""Print the sanitized docker topology JSON published at /federation/topology.
|
||||
|
||||
Useful for auditing what gets exposed to peers — pipe through `jq` to
|
||||
confirm no env vars / volume mounts / IPs leak. On a dev box where
|
||||
the docker-socket-proxy isn't running the export will be empty.
|
||||
"""
|
||||
db.init_db()
|
||||
export = topology_export.build_export()
|
||||
typer.echo(json.dumps(export.model_dump(mode="json"), indent=2))
|
||||
|
||||
@typer_app.command("fed-log-verify")
|
||||
def fed_log_verify() -> None:
|
||||
"""Re-walk the chain locally and report verification status."""
|
||||
|
||||
@@ -35,6 +35,19 @@ app = FastAPI(title="psyc Operations Cockpit", version="0.1.0")
|
||||
app.add_middleware(SessionMiddleware, secret_key=adminauth.session_secret(), max_age=3600)
|
||||
app.mount("/static", StaticFiles(directory=str(HERE / "static")), name="static")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def _security_headers(request: Request, call_next):
|
||||
"""Defense-in-depth headers. CSP is intentionally NOT set yet — the
|
||||
cockpit currently uses inline scripts in base.html / journey.html /
|
||||
federation_explore.html which would need nonces or extraction first."""
|
||||
resp = await call_next(request)
|
||||
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
resp.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||
resp.headers.setdefault("X-Frame-Options", "DENY")
|
||||
return resp
|
||||
|
||||
|
||||
pulse_routes.register(app, TEMPLATES)
|
||||
federation_routes.register(app, TEMPLATES)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Red
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from psyc import db, log
|
||||
from psyc.lines import discovery, federation, network_view, pulse, translog
|
||||
from psyc.lines import discovery, federation, network_view, pulse, topology_export, translog
|
||||
from psyc.result import Err
|
||||
|
||||
|
||||
@@ -34,6 +34,25 @@ _PUBLIC_PEERS_TTL = 60.0
|
||||
_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"))
|
||||
@@ -63,6 +82,40 @@ def _cached_public_network() -> Dict[str, Any]:
|
||||
return _PUBLIC_NETWORK_CACHE["payload"]
|
||||
|
||||
|
||||
def _cached_topology() -> Dict[str, Any]:
|
||||
"""Cached sanitized docker topology — same poll-load pattern as the feed."""
|
||||
now = time.time()
|
||||
if _TOPOLOGY_CACHE["payload"] is None or (now - _TOPOLOGY_CACHE["ts"]) > _TOPOLOGY_TTL:
|
||||
export = topology_export.build_export()
|
||||
_TOPOLOGY_CACHE["payload"] = export.model_dump(mode="json")
|
||||
_TOPOLOGY_CACHE["ts"] = now
|
||||
return _TOPOLOGY_CACHE["payload"]
|
||||
|
||||
|
||||
def _cached_explore(domain: Optional[str]) -> Dict[str, Any]:
|
||||
"""Cached explore payload. Re-uses the cache when the host domain matches.
|
||||
|
||||
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`."""
|
||||
|
||||
@@ -180,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:
|
||||
@@ -202,7 +260,7 @@ 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:
|
||||
@@ -212,14 +270,25 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
|
||||
so a fetcher can reconstruct the local web-of-trust shape. Trusted peers
|
||||
only — never unknown or blocked. Signal hashes are deliberately omitted.
|
||||
"""
|
||||
return JSONResponse(_cached_public_network())
|
||||
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()],
|
||||
})
|
||||
@@ -228,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],
|
||||
})
|
||||
@@ -240,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 ---------------------------------
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -1244,3 +1256,636 @@ body.wide #federation-network-graph { height: 720px; }
|
||||
.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); }
|
||||
|
||||
780
src/psyc/cockpit/static/federation_explore.js
Normal file
780
src/psyc/cockpit/static/federation_explore.js
Normal 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 =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -1,13 +1,18 @@
|
||||
/* psyc — federation network force-directed graph.
|
||||
/* psyc — federation network force-directed graph (enriched detail layer).
|
||||
*
|
||||
* Self at center, direct peers around it, transitive peers (distance=2) on
|
||||
* the outer ring. Edges: vouch (solid), signal (dashed, animated, thickness
|
||||
* ∝ weight), knows (dotted grey).
|
||||
* ∝ weight), knows (dotted grey), corroborate (dotted accent, faint pulse).
|
||||
*
|
||||
* Pure vanilla JS, no deps. Mirrors topology.js's proven force-sim loop so
|
||||
* the two pages feel familiar; once both are stable the shared engine can
|
||||
* factor out into a force_graph.js module — for now, a copy keeps the diff
|
||||
* narrow.
|
||||
* Compared to the previous version this file additionally renders:
|
||||
* • per-peer compact stat badge below the sublabel
|
||||
* • opacity-scaled fill based on log(signals_24h) for non-self nodes
|
||||
* • a search/filter bar that dims non-matching nodes/edges
|
||||
* • a hover tooltip with key stats
|
||||
* • a much richer click-to-inspect detail panel
|
||||
* • a 24h stacked timeline strip at the bottom of the page
|
||||
*
|
||||
* Pure vanilla JS, no deps. Mirrors topology.js's proven force-sim loop.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
@@ -18,10 +23,14 @@
|
||||
const loadingEl = document.getElementById("fn-loading");
|
||||
const errorEl = document.getElementById("fn-error");
|
||||
const transitiveCountEl = document.getElementById("fn-transitive-count");
|
||||
const tooltipEl = document.getElementById("fn-tooltip");
|
||||
const searchEl = document.getElementById("fn-search");
|
||||
const searchCountEl = document.getElementById("fn-search-count");
|
||||
const timelineEl = document.getElementById("fn-timeline");
|
||||
const timelineAxisEl = document.getElementById("fn-timeline-axis");
|
||||
const timelineMetaEl = document.getElementById("fn-timeline-meta");
|
||||
if (!svg) return;
|
||||
|
||||
// Fetch data, then build the graph. The /data endpoint includes transitive
|
||||
// peers (mid-cost cached server-side at 5 min TTL).
|
||||
fetch("/admin/federation/network/data", { credentials: "same-origin" })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
@@ -39,23 +48,68 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- shared escape -----------------------------------------------
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
function shortFp(fp) {
|
||||
if (!fp) return "—";
|
||||
if (fp.length >= 16) return fp.slice(0, 8) + "…" + fp.slice(-8);
|
||||
return fp;
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
const selfFp = data.self_fingerprint || "";
|
||||
const nodesData = data.nodes || [];
|
||||
const edgesData = data.edges || [];
|
||||
const topStats = data.stats || {};
|
||||
|
||||
if (transitiveCountEl) {
|
||||
const n = nodesData.filter(n => (n.distance || 0) >= 2).length;
|
||||
transitiveCountEl.textContent = String(n);
|
||||
}
|
||||
|
||||
// ---------- color palette for peers (timeline + tooltips) -------------
|
||||
// 12 evenly-spaced HSL hues, accent-leaning. Used in the timeline strip
|
||||
// to colorize per-peer segments inside each bar.
|
||||
const PEER_PALETTE = [
|
||||
"#1ec8ff", "#a78bfa", "#4ade80", "#fbbf24", "#f87171", "#34d399",
|
||||
"#60a5fa", "#f472b6", "#fb923c", "#22d3ee", "#c084fc", "#facc15",
|
||||
];
|
||||
const peerColor = Object.create(null);
|
||||
const peerOrder = nodesData
|
||||
.filter(n => !n.is_self)
|
||||
.map(n => n.fingerprint)
|
||||
.sort();
|
||||
peerOrder.forEach((fp, i) => {
|
||||
peerColor[fp] = PEER_PALETTE[i % PEER_PALETTE.length];
|
||||
});
|
||||
|
||||
// ---------- build node + edge sim objects -----------------------------
|
||||
// Pre-compute the max 24h signal count so we can log-normalize fill
|
||||
// opacity per node (busy peer → fully saturated, quiet → faint).
|
||||
let maxSignals24h = 0;
|
||||
for (const nd of nodesData) {
|
||||
const s = (nd.stats && nd.stats.signals_24h) || 0;
|
||||
if (s > maxSignals24h) maxSignals24h = s;
|
||||
}
|
||||
|
||||
const nodes = [];
|
||||
const nodeByFp = Object.create(null);
|
||||
for (const nd of nodesData) {
|
||||
const isSelf = !!nd.is_self;
|
||||
const dist = Number(nd.distance || 0);
|
||||
const r = isSelf ? 38 : (dist >= 2 ? 9 : 16);
|
||||
const stats = nd.stats || null;
|
||||
let intensity = 1;
|
||||
if (!isSelf && stats && maxSignals24h > 0) {
|
||||
// log scale so a 100-signal peer doesn't blow out a 5-signal one.
|
||||
const s = stats.signals_24h || 0;
|
||||
const num = Math.log2(s + 1);
|
||||
const den = Math.log2(maxSignals24h + 1) || 1;
|
||||
intensity = 0.18 + 0.82 * (num / den);
|
||||
}
|
||||
const n = {
|
||||
id: nd.fingerprint,
|
||||
fp: nd.fingerprint,
|
||||
@@ -64,9 +118,10 @@
|
||||
status: nd.status || "unknown",
|
||||
is_self: isSelf,
|
||||
distance: dist,
|
||||
stats,
|
||||
intensity,
|
||||
r,
|
||||
x: 0, y: 0, vx: 0, vy: 0, fixed: false,
|
||||
tooltip: buildTooltip(nd),
|
||||
};
|
||||
nodes.push(n);
|
||||
nodeByFp[n.id] = n;
|
||||
@@ -87,15 +142,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
function buildTooltip(nd) {
|
||||
const lines = [];
|
||||
lines.push((nd.is_self ? "self · " : "") + (nd.domain || nd.label || nd.fingerprint));
|
||||
lines.push("fp: " + nd.fingerprint);
|
||||
lines.push("status: " + (nd.status || "unknown"));
|
||||
lines.push("distance: " + (nd.distance || 0));
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ---------- viewport + seeding ---------------------------------------
|
||||
function viewport() {
|
||||
const W = svg.clientWidth || 900;
|
||||
@@ -108,10 +154,7 @@
|
||||
(function seed() {
|
||||
const cx = W / 2, cy = H / 2;
|
||||
nodes.forEach((n, i) => {
|
||||
if (n.is_self) {
|
||||
n.x = cx; n.y = cy;
|
||||
return;
|
||||
}
|
||||
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;
|
||||
@@ -119,8 +162,6 @@
|
||||
});
|
||||
})();
|
||||
|
||||
// Force-sim params — same shape as topology.js, slightly softer springs
|
||||
// so the two rings settle visibly.
|
||||
const REPULSION = 1500;
|
||||
const SPRING_K = 0.035;
|
||||
const SPRING_REST_BASE = 110;
|
||||
@@ -144,7 +185,8 @@
|
||||
for (const e of edges) {
|
||||
const a = nodeByFp[e.source], b = nodeByFp[e.target];
|
||||
if (!a || !b) continue;
|
||||
// "knows" edges (distance-2) rest longer so transitive bands stay clear.
|
||||
// corroborate edges are decorative; don't pull on the layout.
|
||||
if (e.kind === "corroborate") 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;
|
||||
@@ -163,30 +205,38 @@
|
||||
n.y = Math.max(n.r, Math.min(H - n.r, n.y));
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-settle so the first frame isn't a glob.
|
||||
for (let i = 0; i < 280; i++) tick();
|
||||
|
||||
// ---------- render ----------------------------------------------------
|
||||
// ---------- render SVG groups ----------------------------------------
|
||||
// Order matters: corroborate edges go first so they sit behind the
|
||||
// primary edges, then the rest, then nodes on top.
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
const corrG = document.createElementNS(ns, "g");
|
||||
const edgesG = document.createElementNS(ns, "g");
|
||||
const nodesG = document.createElementNS(ns, "g");
|
||||
corrG.setAttribute("class", "fn-edges fn-edges-corr");
|
||||
edgesG.setAttribute("class", "fn-edges");
|
||||
nodesG.setAttribute("class", "fn-nodes");
|
||||
svg.appendChild(corrG);
|
||||
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");
|
||||
// Signal weight controls stroke width; cap at 5px so a noisy peer
|
||||
// doesn't blot out the layout.
|
||||
if (e.kind === "signal") {
|
||||
const w = Math.min(5, 1 + Math.log2(e.weight + 1) * 0.8);
|
||||
ln.setAttribute("stroke-width", w.toFixed(2));
|
||||
}
|
||||
if (e.kind === "corroborate") {
|
||||
// Stroke width hints at how many shared hashes this pair has.
|
||||
const w = Math.min(3, 0.8 + Math.log2(e.weight + 1) * 0.5);
|
||||
ln.setAttribute("stroke-width", w.toFixed(2));
|
||||
}
|
||||
grp.appendChild(ln);
|
||||
if (e.label) {
|
||||
const lbl = document.createElementNS(ns, "text");
|
||||
@@ -194,8 +244,9 @@
|
||||
lbl.textContent = e.label;
|
||||
grp.appendChild(lbl);
|
||||
}
|
||||
edgesG.appendChild(grp);
|
||||
return { line: ln, label: grp.querySelector("text") };
|
||||
const host = e.kind === "corroborate" ? corrG : edgesG;
|
||||
host.appendChild(grp);
|
||||
return { line: ln, label: grp.querySelector("text"), grp };
|
||||
});
|
||||
|
||||
function _classFor(n) {
|
||||
@@ -209,17 +260,20 @@
|
||||
g.setAttribute("class", _classFor(n));
|
||||
g.dataset.fp = n.fp;
|
||||
|
||||
let shape;
|
||||
if (n.is_self) {
|
||||
const sz = n.r;
|
||||
const rect = document.createElementNS(ns, "rect");
|
||||
rect.setAttribute("x", -sz); rect.setAttribute("y", -sz);
|
||||
rect.setAttribute("width", sz * 2); rect.setAttribute("height", sz * 2);
|
||||
rect.setAttribute("rx", 10); rect.setAttribute("ry", 10);
|
||||
g.appendChild(rect);
|
||||
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 {
|
||||
const c = document.createElementNS(ns, "circle");
|
||||
c.setAttribute("r", n.r);
|
||||
g.appendChild(c);
|
||||
shape = document.createElementNS(ns, "circle");
|
||||
shape.setAttribute("r", n.r);
|
||||
// Log-normalized fill opacity: quiet peer → faint, busy → full.
|
||||
shape.setAttribute("fill-opacity", n.intensity.toFixed(2));
|
||||
g.appendChild(shape);
|
||||
}
|
||||
|
||||
const text = document.createElementNS(ns, "text");
|
||||
@@ -234,10 +288,25 @@
|
||||
sub.setAttribute("dy", n.r + 24);
|
||||
sub.textContent = n.fp.slice(0, 8) + "…";
|
||||
g.appendChild(sub);
|
||||
|
||||
// Stat badge — compact "↓ signals · ✓ vouches-in · ⚡ quorum".
|
||||
// Hidden for distance=2 nodes via CSS, since their data is sparse.
|
||||
if (n.stats) {
|
||||
const badge = document.createElementNS(ns, "text");
|
||||
badge.setAttribute("class", "fn-stat-badge");
|
||||
badge.setAttribute("dy", n.r + 36);
|
||||
const s = n.stats;
|
||||
badge.textContent =
|
||||
"↓ " + (s.signals_24h || 0) +
|
||||
" · ✓ " + (s.vouches_in_count || 0) +
|
||||
" · ⚡ " + (s.quorum_contribution || 0);
|
||||
g.appendChild(badge);
|
||||
}
|
||||
}
|
||||
|
||||
// Native <title> stays as an accessibility fallback for keyboard users.
|
||||
const title = document.createElementNS(ns, "title");
|
||||
title.textContent = n.tooltip;
|
||||
title.textContent = (n.is_self ? "self · " : "") + (n.domain || n.label || n.fp);
|
||||
g.appendChild(title);
|
||||
|
||||
nodesG.appendChild(g);
|
||||
@@ -263,7 +332,46 @@
|
||||
}
|
||||
paint();
|
||||
|
||||
// ---------- drag + click --------------------------------------------
|
||||
// ---------- tooltip --------------------------------------------------
|
||||
function showTooltip(n, clientX, clientY) {
|
||||
if (!tooltipEl) return;
|
||||
const s = n.stats || {};
|
||||
const rows = [];
|
||||
rows.push(`<div class="fn-tooltip-title">${esc(n.domain || n.label || n.fp.slice(0,12))}</div>`);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">status</span><span class="v">${esc(n.status)}</span></div>`);
|
||||
if (!n.is_self) {
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">signals 24h</span><span class="v">${s.signals_24h || 0}</span></div>`);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">last seen</span><span class="v">${esc(s.last_seen_relative || "—")}</span></div>`);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">vouches</span><span class="v">in ${s.vouches_in_count || 0} · out ${s.vouches_out_count || 0}</span></div>`);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">quorum</span><span class="v">${s.quorum_contribution || 0}</span></div>`);
|
||||
} else {
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">peers</span><span class="v">${topStats.total_peers || 0}</span></div>`);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">signals 24h</span><span class="v">${topStats.signals_buffered_24h || 0}</span></div>`);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">quorum-met</span><span class="v">${topStats.quorum_met_count || 0}</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;
|
||||
function svgPoint(clientX, clientY) {
|
||||
@@ -271,17 +379,21 @@
|
||||
return pt.matrixTransform(svg.getScreenCTM().inverse());
|
||||
}
|
||||
nodeEls.forEach((g, i) => {
|
||||
const n = nodes[i];
|
||||
g.addEventListener("mousedown", ev => {
|
||||
ev.preventDefault();
|
||||
pressedNode = nodes[i];
|
||||
pressedNode = n;
|
||||
pressedAt = { x: ev.clientX, y: ev.clientY };
|
||||
moved = false;
|
||||
dragging = nodes[i];
|
||||
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) {
|
||||
@@ -309,44 +421,188 @@
|
||||
});
|
||||
|
||||
// ---------- detail panel --------------------------------------------
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
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");
|
||||
renderDetail(n);
|
||||
}
|
||||
|
||||
function jumpToFp(fp) {
|
||||
const target = nodeByFp[fp];
|
||||
if (!target) return;
|
||||
selectNode(target);
|
||||
}
|
||||
function clearSelection() {
|
||||
nodeEls.forEach(el => el.classList.remove("selected"));
|
||||
if (detailEl) detailEl.innerHTML = '<p class="topo-detail-empty">Click any node in the graph above to inspect it.</p>';
|
||||
}
|
||||
|
||||
function countEdges(fp) {
|
||||
let vouchOut = 0, vouchIn = 0, signalsIn = 0, knows = 0;
|
||||
for (const e of edges) {
|
||||
if (e.kind === "vouch" && e.source === fp) vouchOut++;
|
||||
else if (e.kind === "vouch" && e.target === fp) vouchIn++;
|
||||
else if (e.kind === "signal" && e.target === fp) signalsIn += e.weight;
|
||||
else if (e.kind === "signal" && e.source === fp) signalsIn += e.weight;
|
||||
else if (e.kind === "knows" && (e.source === fp || e.target === fp)) knows++;
|
||||
// Aggregate self stats from all peer.stats blocks so the self node has
|
||||
// a meaningful detail card too.
|
||||
function selfStats() {
|
||||
let signals_24h = 0, vouches_in = 0, vouches_out = 0, quorum = 0;
|
||||
let cases_24h = 0, iocs_24h = 0;
|
||||
const sev = { critical: 0, high: 0, medium: 0, low: 0 };
|
||||
const iocType = { url: 0, domain: 0, ip: 0, hash: 0, cve: 0 };
|
||||
for (const nd of nodes) {
|
||||
if (nd.is_self || !nd.stats) continue;
|
||||
signals_24h += nd.stats.signals_24h || 0;
|
||||
cases_24h += nd.stats.cases_24h || 0;
|
||||
iocs_24h += nd.stats.iocs_24h || 0;
|
||||
vouches_in += nd.stats.vouches_in_count || 0;
|
||||
vouches_out += nd.stats.vouches_out_count || 0;
|
||||
quorum += nd.stats.quorum_contribution || 0;
|
||||
const sb = nd.stats.severity_breakdown || {};
|
||||
for (const k of Object.keys(sev)) sev[k] += sb[k] || 0;
|
||||
const ib = nd.stats.ioc_type_breakdown || {};
|
||||
for (const k of Object.keys(iocType)) iocType[k] += ib[k] || 0;
|
||||
}
|
||||
return { vouchOut, vouchIn, signalsIn, knows };
|
||||
return {
|
||||
signals_24h, cases_24h, iocs_24h,
|
||||
severity_breakdown: sev, ioc_type_breakdown: iocType,
|
||||
vouches_in_count: vouches_in, vouches_out_count: vouches_out,
|
||||
quorum_contribution: quorum,
|
||||
};
|
||||
}
|
||||
|
||||
function sevRow(sev) {
|
||||
const order = ["critical", "high", "medium", "low"];
|
||||
const cells = order.map(k => {
|
||||
const n = (sev && sev[k]) || 0;
|
||||
return `<span class="fn-sev-chip fn-sev-${k}">${k}<span class="n">${n}</span></span>`;
|
||||
}).join("");
|
||||
return `<div class="fn-sev-row">${cells}</div>`;
|
||||
}
|
||||
function iocRow(it) {
|
||||
const order = ["url", "domain", "ip", "hash", "cve"];
|
||||
const cells = order.map(k => {
|
||||
const n = (it && it[k]) || 0;
|
||||
return `<span class="fn-ioc-chip"><span class="k">${k}</span><span class="n">${n}</span></span>`;
|
||||
}).join("");
|
||||
return `<div class="fn-sev-row">${cells}</div>`;
|
||||
}
|
||||
|
||||
function renderDetail(n) {
|
||||
if (!detailEl) return;
|
||||
const stats = countEdges(n.fp);
|
||||
const kindLabel = n.is_self ? "SELF" : (n.distance >= 2 ? "TRANSITIVE" : "DIRECT PEER");
|
||||
const kindCls = n.is_self ? "td-kind-host" : (n.distance >= 2 ? "td-kind-cont" : "td-kind-net");
|
||||
const statusBadge = `<span class="state-badge fn-status-badge-${esc(n.status)}">${esc(n.status)}</span>`;
|
||||
const jumpBack = (!n.is_self && n.domain)
|
||||
? `<p style="margin-top:10px;"><a href="/admin/federation" class="td-jump">→ open peer in /admin/federation</a></p>`
|
||||
|
||||
const s = n.is_self ? selfStats() : (n.stats || {});
|
||||
const signals24h = s.signals_24h || 0;
|
||||
const sigQuorum = s.quorum_contribution || 0;
|
||||
const quorumPct = signals24h > 0 ? Math.min(100, Math.round((sigQuorum / signals24h) * 100)) : 0;
|
||||
|
||||
// Identity section. Self has no remote "last_seen"; show "—".
|
||||
const fullFp = `<code class="full-fp">${esc(n.fp)}</code>` +
|
||||
`<button type="button" class="fn-copy-btn" data-copy="${esc(n.fp)}">copy</button>`;
|
||||
const identity = `
|
||||
<div class="fn-detail-sec">
|
||||
<h4>identity</h4>
|
||||
<div class="row"><span class="k">fingerprint</span><span class="v">${fullFp}</span></div>
|
||||
<div class="row"><span class="k">domain</span><span class="v">${n.domain ? esc(n.domain) : "—"}</span></div>
|
||||
<div class="row"><span class="k">status</span><span class="v">${statusBadge}</span></div>
|
||||
<div class="row"><span class="k">distance</span><span class="v">${n.is_self ? "self" : (n.distance >= 2 ? "transitive (2 hops)" : "direct (1 hop)")}</span></div>
|
||||
<div class="row"><span class="k">last seen</span><span class="v">${esc((n.stats && n.stats.last_seen_relative) || "—")}</span></div>
|
||||
</div>`;
|
||||
|
||||
// Signals section.
|
||||
const signals = `
|
||||
<div class="fn-detail-sec">
|
||||
<h4>signals · 24h</h4>
|
||||
<div class="row"><span class="k">total</span><span class="v">${signals24h}</span></div>
|
||||
<div class="row"><span class="k">cases</span><span class="v">${s.cases_24h || 0}</span></div>
|
||||
<div class="row"><span class="k">iocs</span><span class="v">${s.iocs_24h || 0}</span></div>
|
||||
<div class="row"><span class="k">all-time</span><span class="v">${(n.stats && n.stats.signals_total) || (n.is_self ? "—" : 0)}</span></div>
|
||||
${sevRow(s.severity_breakdown)}
|
||||
${iocRow(s.ioc_type_breakdown)}
|
||||
</div>`;
|
||||
|
||||
// Vouches section.
|
||||
const vouchesPeerList = (() => {
|
||||
if (n.is_self) return "";
|
||||
const count = (n.stats && n.stats.vouches_in_count) || 0;
|
||||
if (!count) return "";
|
||||
// Find the actual voucher fingerprints by scanning rendered nodes
|
||||
// — server doesn't ship the list per-peer, but the edge list does.
|
||||
const vouchers = edges
|
||||
.filter(e => e.kind === "vouch" && e.target === n.fp)
|
||||
.map(e => e.source);
|
||||
// Plus the case where peer-to-peer vouches exist as data but no edge
|
||||
// (transitive nodes don't always get vouch edges); show known set.
|
||||
const uniq = Array.from(new Set(vouchers));
|
||||
if (!uniq.length) return "";
|
||||
const chips = uniq.map(fp =>
|
||||
`<button type="button" class="fn-fp-jump" data-jump="${esc(fp)}">${esc(shortFp(fp))}</button>`
|
||||
).join("");
|
||||
return `<div style="margin-top:6px;">${chips}</div>`;
|
||||
})();
|
||||
const vouches = `
|
||||
<div class="fn-detail-sec">
|
||||
<h4>vouches</h4>
|
||||
<div class="row"><span class="k">in</span><span class="v">${s.vouches_in_count || 0}</span></div>
|
||||
<div class="row"><span class="k">out</span><span class="v">${s.vouches_out_count || 0}</span></div>
|
||||
${vouchesPeerList}
|
||||
</div>`;
|
||||
|
||||
// Quorum section — small progress bar of "signals that are quorum-met".
|
||||
const quorum = `
|
||||
<div class="fn-detail-sec">
|
||||
<h4>quorum</h4>
|
||||
<div class="row"><span class="k">contribution</span><span class="v">${sigQuorum}</span></div>
|
||||
<div class="row"><span class="k">of 24h total</span><span class="v">${quorumPct}%</span></div>
|
||||
<div class="fn-quorum-bar"><div class="fn-quorum-fill" style="width:${quorumPct}%;"></div></div>
|
||||
</div>`;
|
||||
|
||||
// Transparency log section.
|
||||
const translog = (() => {
|
||||
const entries = (n.stats && n.stats.recent_translog) || [];
|
||||
if (!entries.length) {
|
||||
return `<div class="fn-detail-sec"><h4>transparency log</h4><div class="row"><span class="k">recent</span><span class="v">—</span></div></div>`;
|
||||
}
|
||||
const items = entries.map(e => {
|
||||
const ts = (e.timestamp || "").slice(0, 19).replace("T", " ");
|
||||
const hash = (e.hash || "").slice(0, 12);
|
||||
return `<li><span class="id">#${esc(e.id)}</span><span class="type">${esc(e.entry_type)}</span><span class="ts">${esc(ts)}</span><span class="hash">${esc(hash)}…</span></li>`;
|
||||
}).join("");
|
||||
return `<div class="fn-detail-sec"><h4>transparency log</h4><ul class="fn-trans-list">${items}</ul></div>`;
|
||||
})();
|
||||
|
||||
// Actions.
|
||||
const rawStatsUrl = "/admin/federation/network/data";
|
||||
const actions = `
|
||||
<div class="fn-detail-sec">
|
||||
<h4>actions</h4>
|
||||
<div class="fn-actions">
|
||||
<a class="fn-action-btn" href="/admin/federation">peer registry</a>
|
||||
<a class="fn-action-btn" href="/admin/federation/vouches">vouches</a>
|
||||
<a class="fn-action-btn" href="/admin/federation/quorum">quorum</a>
|
||||
<a class="fn-action-btn" href="${rawStatsUrl}" target="_blank" rel="noopener">raw JSON</a>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Placeholder for the peer's-own-view, populated by an async fetch below.
|
||||
const remote = (!n.is_self && n.domain)
|
||||
? `<div class="fn-detail-sec fn-remote-sec" data-remote-fp="${esc(n.fp)}">
|
||||
<h4>peer's self-view <span class="fn-remote-status">fetching…</span></h4>
|
||||
<div class="fn-remote-body">loading from <code>${esc(n.domain)}</code>…</div>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
// Placeholder for the peer's container topology. We render this for
|
||||
// self too — the local node's /federation/topology fetches via the same
|
||||
// path, just same-origin. Distance=2 transitive peers usually lack a
|
||||
// direct domain in the network feed; skip them since there's nothing
|
||||
// to fetch.
|
||||
const topoHost = n.is_self ? "" : (n.domain || "");
|
||||
const topoTarget = n.is_self ? "" : (n.domain || "");
|
||||
const topoSec = (n.is_self || n.domain)
|
||||
? `<div class="fn-detail-sec fn-topology-sec" data-topo-fp="${esc(n.fp)}">
|
||||
<h4>containers <span class="fn-remote-status fn-topo-status">fetching…</span></h4>
|
||||
<div class="fn-topology-body">loading${topoHost ? ` from <code>${esc(topoHost)}</code>` : " local topology"}…</div>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const html = `
|
||||
<div class="td-head">
|
||||
<span class="td-kind ${kindCls}">${esc(kindLabel)}</span>
|
||||
@@ -354,23 +610,265 @@
|
||||
${statusBadge}
|
||||
<button type="button" class="td-close" aria-label="close">×</button>
|
||||
</div>
|
||||
<dl class="td-kv">
|
||||
<dt>Fingerprint</dt><dd><code>${esc(n.fp)}</code></dd>
|
||||
<dt>Domain</dt><dd>${n.domain ? esc(n.domain) : "—"}</dd>
|
||||
<dt>Distance</dt><dd>${esc(n.distance)} hop${n.distance === 1 ? "" : "s"}</dd>
|
||||
<dt>Vouches</dt><dd>out: ${stats.vouchOut} · in: ${stats.vouchIn}</dd>
|
||||
<dt>Signals (24h)</dt><dd>${stats.signalsIn ? stats.signalsIn.toFixed(0) : "0"}</dd>
|
||||
<dt>Knows-edges</dt><dd>${stats.knows}</dd>
|
||||
</dl>
|
||||
${jumpBack}
|
||||
<div class="fn-detail-card">
|
||||
${identity}
|
||||
${signals}
|
||||
${vouches}
|
||||
${quorum}
|
||||
${translog}
|
||||
${remote}
|
||||
${topoSec}
|
||||
${actions}
|
||||
</div>
|
||||
`;
|
||||
detailEl.innerHTML = html;
|
||||
detailEl.classList.add("has-selection");
|
||||
|
||||
// Async-fetch the peer's own /federation/explore/data and render their
|
||||
// self-view inline. CORS is set on that endpoint, so the browser can hit
|
||||
// any psyc node directly. Falls back to /federation/network on older nodes.
|
||||
if (!n.is_self && n.domain) {
|
||||
fetchPeerSelfView(n.domain, n.fp);
|
||||
}
|
||||
|
||||
// Async-fetch the topology — sanitized container/network listing. For
|
||||
// SELF we hit our own origin; for peers we hit https://<domain>/.
|
||||
if (n.is_self) {
|
||||
fetchPeerTopology("", n.fp);
|
||||
} else if (n.domain) {
|
||||
fetchPeerTopology(n.domain, n.fp);
|
||||
}
|
||||
|
||||
const close = detailEl.querySelector(".td-close");
|
||||
if (close) close.addEventListener("click", clearSelection);
|
||||
|
||||
// Wire copy buttons.
|
||||
detailEl.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);
|
||||
});
|
||||
});
|
||||
|
||||
// Wire jump-to-fp chips.
|
||||
detailEl.querySelectorAll(".fn-fp-jump").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const fp = btn.getAttribute("data-jump") || "";
|
||||
if (fp) jumpToFp(fp);
|
||||
});
|
||||
});
|
||||
|
||||
detailEl.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
|
||||
// ---------- peer self-view (cross-origin fetch) ----------------------
|
||||
// Each psyc node exposes /federation/explore/data (rich) and falls back to
|
||||
// /federation/network (slim). Both carry CORS=*, so the browser can hit
|
||||
// them from any cockpit page. The fetch is best-effort — if the peer is
|
||||
// unreachable, blocked by CSP, or older, we render whatever we got.
|
||||
|
||||
// Reject anything that isn't a bare hostname (+ optional port). Stops a
|
||||
// hostile peer-supplied `domain` value from steering a click to an
|
||||
// attacker-controlled URL via path/query injection or a different scheme.
|
||||
const DOMAIN_RE = /^[A-Za-z0-9._\-]+(:\d{1,5})?$/;
|
||||
|
||||
async function fetchPeerSelfView(domain, expectedFp) {
|
||||
const sec = detailEl.querySelector(`.fn-remote-sec[data-remote-fp="${expectedFp}"]`);
|
||||
if (!sec) return;
|
||||
const statusEl = sec.querySelector(".fn-remote-status");
|
||||
const bodyEl = sec.querySelector(".fn-remote-body");
|
||||
if (!DOMAIN_RE.test(domain || "")) {
|
||||
statusEl.textContent = "blocked";
|
||||
bodyEl.innerHTML = `<span class="muted">peer domain failed hostname validation — not fetching.</span>`;
|
||||
return;
|
||||
}
|
||||
const base = `https://${domain}`;
|
||||
let data = null;
|
||||
let kind = "";
|
||||
try {
|
||||
const r = await fetch(`${base}/federation/explore/data`, { mode: "cors", cache: "no-store" });
|
||||
if (r.ok) { data = await r.json(); kind = "explore"; }
|
||||
} catch (e) { /* fall through */ }
|
||||
if (!data) {
|
||||
try {
|
||||
const r = await fetch(`${base}/federation/network`, { mode: "cors", cache: "no-store" });
|
||||
if (r.ok) { data = await r.json(); kind = "network"; }
|
||||
} catch (e) { /* fall through */ }
|
||||
}
|
||||
if (!data) {
|
||||
statusEl.textContent = "unreachable";
|
||||
bodyEl.innerHTML = `<span class="muted">couldn't reach ${esc(domain)} — peer may be offline, blocking cross-origin, or on an older psyc.</span>`;
|
||||
return;
|
||||
}
|
||||
// The detail panel may have moved on to another node by now — re-check
|
||||
// the section is still in the DOM before mutating.
|
||||
if (!detailEl.contains(sec)) return;
|
||||
|
||||
const gen = (data.generated_at || (data.node && data.node.generated_at) || "").slice(0, 19).replace("T", " ");
|
||||
const declaredFp = data.fingerprint || (data.node && data.node.fingerprint) || "";
|
||||
const peers = data.peers || [];
|
||||
const vouchesOut = data.vouches_out || data.vouches || [];
|
||||
const vouchesIn = data.vouches_in || [];
|
||||
const transitive = data.transitive_peers || [];
|
||||
const corr = (typeof data.corroboration_count_24h === "number") ? data.corroboration_count_24h : null;
|
||||
const logHead = data.node && data.node.transparency_log_head_hash;
|
||||
const logCount = data.node && data.node.translog_entry_count;
|
||||
|
||||
const fpMatch = declaredFp === expectedFp;
|
||||
const fpBadge = fpMatch
|
||||
? `<span class="fn-remote-ok">fingerprint matches</span>`
|
||||
: `<span class="fn-remote-warn">fingerprint mismatch (${esc((declaredFp || "—").slice(0, 12))}…)</span>`;
|
||||
|
||||
const peersList = peers.length
|
||||
? `<ul class="fn-remote-list">${peers.slice(0, 12).map(p => {
|
||||
const fp = p.fingerprint || "";
|
||||
const dom = p.domain || "—";
|
||||
const sig24 = (typeof p.signal_count_24h === "number") ? ` · ${p.signal_count_24h} sig/24h` : "";
|
||||
return `<li><code>${esc(shortFp(fp))}</code> <span class="muted">${esc(dom)}</span>${sig24}</li>`;
|
||||
}).join("")}</ul>`
|
||||
: `<span class="muted">no trusted peers exposed</span>`;
|
||||
|
||||
const vouchesOutList = vouchesOut.length
|
||||
? `<ul class="fn-remote-list">${vouchesOut.slice(0, 12).map(v => {
|
||||
const tfp = v.target_fingerprint || v.target_fp || "";
|
||||
return `<li><code>${esc(shortFp(tfp))}</code></li>`;
|
||||
}).join("")}</ul>`
|
||||
: `<span class="muted">no outbound vouches</span>`;
|
||||
|
||||
const vouchesInList = vouchesIn.length
|
||||
? `<ul class="fn-remote-list">${vouchesIn.slice(0, 12).map(v => {
|
||||
const vfp = v.voucher_fingerprint || v.voucher_fp || "";
|
||||
return `<li><code>${esc(shortFp(vfp))}</code></li>`;
|
||||
}).join("")}</ul>`
|
||||
: `<span class="muted">no inbound vouches</span>`;
|
||||
|
||||
statusEl.textContent = kind === "explore" ? "explore feed" : "network feed";
|
||||
bodyEl.innerHTML = `
|
||||
<div class="fn-remote-meta">
|
||||
<span>generated <code>${esc(gen || "—")}</code></span>
|
||||
${fpBadge}
|
||||
${corr !== null ? `<span>corroborations 24h: <b>${corr}</b></span>` : ""}
|
||||
${logCount !== undefined ? `<span>translog: <b>${logCount}</b> entries</span>` : ""}
|
||||
${logHead ? `<span>head <code>${esc(String(logHead).slice(0, 12))}…</code></span>` : ""}
|
||||
</div>
|
||||
<div class="fn-remote-cols">
|
||||
<div><div class="fn-remote-h">their peers <span class="muted">(${peers.length})</span></div>${peersList}</div>
|
||||
<div><div class="fn-remote-h">vouches out <span class="muted">(${vouchesOut.length})</span></div>${vouchesOutList}</div>
|
||||
<div><div class="fn-remote-h">vouches in <span class="muted">(${vouchesIn.length})</span></div>${vouchesInList}</div>
|
||||
${transitive.length ? `<div><div class="fn-remote-h">they know <span class="muted">(${transitive.length})</span></div><ul class="fn-remote-list">${transitive.slice(0, 12).map(t => `<li><code>${esc(shortFp(t.fingerprint || ""))}</code></li>`).join("")}</ul></div>` : ""}
|
||||
</div>
|
||||
<div class="fn-remote-actions">
|
||||
<a class="fn-action-btn" href="${esc(base)}/federation/explore" target="_blank" rel="noopener">open their explorer →</a>
|
||||
<a class="fn-action-btn" href="${esc(base)}/federation/explore/data" target="_blank" rel="noopener">raw JSON</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------- peer container topology (cross-origin fetch) ------------
|
||||
// Each psyc node exposes /federation/topology — a whitelist-only docker
|
||||
// snapshot (container names + images + state + network names, nothing
|
||||
// else). CORS=* so we can render every connected node's containers
|
||||
// inside the federation network panel — operator gets a single pane of
|
||||
// glass instead of having to SSH into each node.
|
||||
|
||||
function _topoStateClass(state) {
|
||||
const s = String(state || "").toLowerCase();
|
||||
if (s === "running") return "fn-topo-state-running";
|
||||
if (s === "paused") return "fn-topo-state-paused";
|
||||
if (s === "restarting" || s === "dead") return "fn-topo-state-restarting";
|
||||
// exited / created / removing / unknown → muted
|
||||
return "fn-topo-state-exited";
|
||||
}
|
||||
|
||||
function _truncImage(img, max) {
|
||||
const s = String(img || "");
|
||||
if (s.length <= max) return s;
|
||||
// Keep the tail — repo/image:tag is more meaningful than the registry
|
||||
// prefix when truncating.
|
||||
return "…" + s.slice(-(max - 1));
|
||||
}
|
||||
|
||||
async function fetchPeerTopology(domain, expectedFp) {
|
||||
const sec = detailEl.querySelector(`.fn-topology-sec[data-topo-fp="${expectedFp}"]`);
|
||||
if (!sec) return;
|
||||
const statusEl = sec.querySelector(".fn-topo-status");
|
||||
const bodyEl = sec.querySelector(".fn-topology-body");
|
||||
// Empty domain → same-origin fetch for SELF. Otherwise cross-origin —
|
||||
// guard against a poisoned peer-supplied domain steering elsewhere.
|
||||
if (domain && !DOMAIN_RE.test(domain)) {
|
||||
statusEl.textContent = "blocked";
|
||||
bodyEl.innerHTML = `<span class="muted">peer domain failed hostname validation — not fetching topology.</span>`;
|
||||
return;
|
||||
}
|
||||
const base = domain ? `https://${domain}` : "";
|
||||
let data = null;
|
||||
try {
|
||||
const r = await fetch(`${base}/federation/topology`, { mode: "cors", cache: "no-store" });
|
||||
if (r.ok) data = await r.json();
|
||||
} catch (e) { /* fall through */ }
|
||||
// Detail panel may have moved on; bail if our section is gone.
|
||||
if (!detailEl.contains(sec)) return;
|
||||
if (!data) {
|
||||
if (statusEl) statusEl.textContent = "unreachable";
|
||||
bodyEl.innerHTML = `<span class="muted">couldn't reach ${esc(domain || "self")} — endpoint may be offline, blocking cross-origin, or this peer hasn't been upgraded yet.</span>`;
|
||||
return;
|
||||
}
|
||||
const containers = Array.isArray(data.containers) ? data.containers : [];
|
||||
const networks = Array.isArray(data.networks) ? data.networks : [];
|
||||
const host = data.host_name || "—";
|
||||
const gen = String(data.generated_at || "").slice(0, 19).replace("T", " ");
|
||||
const cCount = Number(data.container_count || containers.length || 0);
|
||||
const nCount = Number(data.network_count || networks.length || 0);
|
||||
|
||||
// Empty-state — node has docker_view disabled or no containers visible.
|
||||
if (!cCount && !nCount) {
|
||||
if (statusEl) statusEl.textContent = "no data";
|
||||
bodyEl.innerHTML = `<div class="fn-topology-meta">host <code>${esc(host)}</code> · ${cCount} containers · ${nCount} networks${gen ? ` · generated <code>${esc(gen)}</code>` : ""}</div><span class="muted">no containers reported — docker-socket-proxy may be down on this node.</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const CONT_VISIBLE = 30, NET_VISIBLE = 10;
|
||||
const contShown = containers.slice(0, CONT_VISIBLE);
|
||||
const contHidden = Math.max(0, containers.length - CONT_VISIBLE);
|
||||
const netShown = networks.slice(0, NET_VISIBLE);
|
||||
const netHidden = Math.max(0, networks.length - NET_VISIBLE);
|
||||
|
||||
const netsList = netShown.length
|
||||
? `<ul class="fn-topology-list">${netShown.map(net => {
|
||||
const drv = esc(net.driver || "—");
|
||||
const ct = Number(net.container_count || 0);
|
||||
const internalChip = net.internal ? ` <span class="fn-topo-int">internal</span>` : "";
|
||||
return `<li><span class="fn-topo-netname">${esc(net.name || "?")}</span> <span class="muted">· ${drv} · ${ct} container${ct === 1 ? "" : "s"}</span>${internalChip}</li>`;
|
||||
}).join("")}${netHidden ? `<li class="muted">+ ${netHidden} more</li>` : ""}</ul>`
|
||||
: `<span class="muted">no networks reported</span>`;
|
||||
|
||||
const contList = contShown.length
|
||||
? `<ul class="fn-topology-list">${contShown.map(c => {
|
||||
const dotCls = _topoStateClass(c.state);
|
||||
const stateTxt = esc(c.state || "?");
|
||||
const img = _truncImage(c.image, 30);
|
||||
const healthPill = (c.health && c.health !== "—")
|
||||
? ` <span class="fn-topo-health fn-topo-h-${esc(c.health)}">${esc(c.health)}</span>` : "";
|
||||
const svc = c.service ? ` <span class="muted">[${esc(c.service)}]</span>` : "";
|
||||
return `<li><span class="fn-topo-state-dot ${dotCls}" title="${stateTxt}"></span><span class="fn-topo-cname">${esc(c.name || "?")}</span>${svc}${healthPill}<div class="fn-topo-image">${esc(img)}</div></li>`;
|
||||
}).join("")}${contHidden ? `<li class="muted">+ ${contHidden} more</li>` : ""}</ul>`
|
||||
: `<span class="muted">no containers reported</span>`;
|
||||
|
||||
if (statusEl) statusEl.textContent = domain ? "peer topology" : "local topology";
|
||||
bodyEl.innerHTML = `
|
||||
<div class="fn-topology-meta">host <code>${esc(host)}</code> · ${cCount} container${cCount === 1 ? "" : "s"} · ${nCount} network${nCount === 1 ? "" : "s"}${gen ? ` · generated <code>${esc(gen)}</code>` : ""}</div>
|
||||
<div class="fn-topology-cols">
|
||||
<div><div class="fn-topology-h">networks <span class="muted">(${networks.length})</span></div>${netsList}</div>
|
||||
<div><div class="fn-topology-h">containers <span class="muted">(${containers.length})</span></div>${contList}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---------- idle animation ------------------------------------------
|
||||
let energyBudget = 40;
|
||||
function loop() {
|
||||
@@ -387,8 +885,6 @@
|
||||
loop();
|
||||
|
||||
// ---------- edge liveness + flow toggle -----------------------------
|
||||
// Signal edges always flow (we just saw N signals in 24h). Vouch edges
|
||||
// are static. Knows edges fade.
|
||||
edges.forEach((e, i) => {
|
||||
const ln = edgeEls[i].line;
|
||||
if (e.kind === "signal") ln.classList.add("alive");
|
||||
@@ -460,8 +956,6 @@
|
||||
|
||||
const LAYOUTS = { force: applyForce, hier: applyHierarchical, radial: applyRadial };
|
||||
let currentLayout = "force";
|
||||
// Force-mode bootstraps with self pinned at center — so the very first
|
||||
// settle radiates outward naturally.
|
||||
const selfNode = nodes.find(n => n.is_self);
|
||||
if (selfNode) { selfNode.x = W / 2; selfNode.y = H / 2; selfNode.fixed = true; }
|
||||
|
||||
@@ -491,6 +985,91 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- search / filter -----------------------------------------
|
||||
function applySearch(qRaw) {
|
||||
const q = (qRaw || "").trim().toLowerCase();
|
||||
if (!q) {
|
||||
nodeEls.forEach(el => el.classList.remove("dimmed", "match"));
|
||||
edgeEls.forEach(els => els.grp.classList.remove("dimmed"));
|
||||
if (searchCountEl) searchCountEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
const matchFps = new Set();
|
||||
nodes.forEach((n, i) => {
|
||||
const hay = (n.label || "") + " " + (n.domain || "") + " " + n.fp;
|
||||
if (hay.toLowerCase().indexOf(q) !== -1) {
|
||||
matchFps.add(n.fp);
|
||||
nodeEls[i].classList.add("match");
|
||||
nodeEls[i].classList.remove("dimmed");
|
||||
} else {
|
||||
nodeEls[i].classList.remove("match");
|
||||
nodeEls[i].classList.add("dimmed");
|
||||
}
|
||||
});
|
||||
edges.forEach((e, i) => {
|
||||
const visible = matchFps.has(e.source) || matchFps.has(e.target);
|
||||
edgeEls[i].grp.classList.toggle("dimmed", !visible);
|
||||
});
|
||||
if (searchCountEl) {
|
||||
searchCountEl.textContent = matchFps.size + " match" + (matchFps.size === 1 ? "" : "es");
|
||||
}
|
||||
}
|
||||
if (searchEl) {
|
||||
searchEl.addEventListener("input", ev => applySearch(ev.target.value));
|
||||
}
|
||||
|
||||
// ---------- 24h timeline strip --------------------------------------
|
||||
function renderTimeline() {
|
||||
if (!timelineEl) return;
|
||||
const buckets = topStats.signal_timeline_24h || [];
|
||||
if (!buckets.length) {
|
||||
timelineEl.innerHTML = `<div class="fn-timeline-empty">no signals in the last 24h</div>`;
|
||||
if (timelineAxisEl) timelineAxisEl.innerHTML = "";
|
||||
if (timelineMetaEl) timelineMetaEl.textContent = "0 signals";
|
||||
return;
|
||||
}
|
||||
// Find max bucket total for height-scaling.
|
||||
let maxTotal = 0;
|
||||
let allTotal = 0;
|
||||
for (const b of buckets) {
|
||||
if ((b.total || 0) > maxTotal) maxTotal = b.total || 0;
|
||||
allTotal += b.total || 0;
|
||||
}
|
||||
if (timelineMetaEl) {
|
||||
timelineMetaEl.textContent =
|
||||
`${allTotal} signal${allTotal === 1 ? "" : "s"} · peak ${maxTotal}/hr`;
|
||||
}
|
||||
const bars = buckets.map((b, idx) => {
|
||||
const total = b.total || 0;
|
||||
const perPeer = b.per_peer || {};
|
||||
const hPct = maxTotal > 0 ? Math.round((total / maxTotal) * 100) : 0;
|
||||
const segHtml = Object.keys(perPeer).map(fp => {
|
||||
const seg = perPeer[fp];
|
||||
const pct = total > 0 ? (seg / total) * hPct : 0;
|
||||
const color = peerColor[fp] || "var(--accent)";
|
||||
return `<div class="fn-timeline-bar-seg" data-fp="${esc(fp)}" data-n="${seg}" style="height:${pct.toFixed(2)}%;background:${color};"></div>`;
|
||||
}).join("");
|
||||
const hoursAgo = 23 - idx;
|
||||
const tooltipLines = [`${hoursAgo}h ago · ${total} signals`];
|
||||
for (const fp of Object.keys(perPeer)) {
|
||||
tooltipLines.push(` ${shortFp(fp)}: ${perPeer[fp]}`);
|
||||
}
|
||||
return `<div class="fn-timeline-bar" data-hour="${hoursAgo}" title="${esc(tooltipLines.join("\n"))}">${segHtml}</div>`;
|
||||
}).join("");
|
||||
timelineEl.innerHTML = bars;
|
||||
|
||||
if (timelineAxisEl) {
|
||||
// Axis labels every 6 hours.
|
||||
const axis = buckets.map((b, idx) => {
|
||||
const hoursAgo = 23 - idx;
|
||||
const show = (hoursAgo % 6 === 0);
|
||||
return `<span>${show ? "-" + hoursAgo + "h" : ""}</span>`;
|
||||
}).join("");
|
||||
timelineAxisEl.innerHTML = axis;
|
||||
}
|
||||
}
|
||||
renderTimeline();
|
||||
|
||||
// Wheel zoom.
|
||||
let zoom = 1, panX = 0, panY = 0;
|
||||
svg.addEventListener("wheel", ev => {
|
||||
|
||||
@@ -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-v6";
|
||||
const CACHE_VERSION = "psyc-v11";
|
||||
const STATIC_ASSETS = [
|
||||
"/static/cockpit.css",
|
||||
"/static/psyc-tokens.css",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<td class="lg-ts">{{ ((m.last_used or '')[:16] | replace('T', ' ')) or '—' }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/members/{{ m.id }}/revoke" class="queue-action"
|
||||
onsubmit="return confirm('Revoke {{ m.label }}? Their codes stop working immediately.');">
|
||||
data-confirm-revoke="member" data-confirm-name="{{ m.label }}">
|
||||
<button type="submit" class="btn btn-reject">revoke</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<button type="submit" class="btn btn-reject" {% if p.status == 'blocked' %}disabled{% endif %}>block</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/federation/peers/{{ p.domain }}/remove" class="queue-action"
|
||||
onsubmit="return confirm('Remove {{ p.domain }}? Their signals will no longer count toward quorum.');">
|
||||
data-confirm-revoke="peer" data-confirm-name="{{ p.domain }}">
|
||||
<button type="submit" class="btn">remove</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
<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">
|
||||
@@ -33,9 +39,10 @@
|
||||
<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</span>
|
||||
<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>
|
||||
@@ -44,6 +51,15 @@
|
||||
<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>
|
||||
|
||||
|
||||
@@ -39,6 +39,21 @@
|
||||
btn.setAttribute("aria-expanded", "false");
|
||||
}));
|
||||
});
|
||||
// data-driven confirms (used by /admin and /admin/federation revoke/remove
|
||||
// buttons; replaces inline onsubmit which was XSS-vulnerable when the
|
||||
// confirm prompt interpolated a member label or peer domain).
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll("form[data-confirm-revoke]").forEach(form => {
|
||||
form.addEventListener("submit", ev => {
|
||||
const kind = form.getAttribute("data-confirm-revoke") || "item";
|
||||
const name = form.getAttribute("data-confirm-name") || "";
|
||||
const msg = kind === "peer"
|
||||
? `Remove ${name}? Their signals will no longer count toward quorum.`
|
||||
: `Revoke ${name}? Their codes stop working immediately.`;
|
||||
if (!confirm(msg)) ev.preventDefault();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<h2>Source</h2>
|
||||
<dl>
|
||||
<dt>Type</dt><dd>{{ case.source_type }}</dd>
|
||||
<dt>Reference</dt><dd>{% if case.source_ref %}<a href="{{ case.source_ref }}" target="_blank" rel="noopener">{{ case.source_ref }}</a>{% else %}—{% endif %}</dd>
|
||||
<dt>Reference</dt><dd>{% if case.source_ref %}{% if case.source_ref.startswith(('http://', 'https://')) %}<a href="{{ case.source_ref }}" target="_blank" rel="noopener">{{ case.source_ref }}</a>{% else %}<code>{{ case.source_ref }}</code>{% endif %}{% else %}—{% endif %}</dd>
|
||||
<dt>Observed</dt><dd class="muted">{{ case.observed_at.strftime('%Y-%m-%d %H:%M UTC') }}</dd>
|
||||
<dt>Ingested</dt><dd class="muted">{{ case.ingested_at.strftime('%Y-%m-%d %H:%M UTC') }}</dd>
|
||||
</dl>
|
||||
|
||||
148
src/psyc/cockpit/templates/federation_explore.html
Normal file
148
src/psyc/cockpit/templates/federation_explore.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -17,11 +17,13 @@ from sqlalchemy import (
|
||||
Table,
|
||||
Text,
|
||||
create_engine,
|
||||
event,
|
||||
func,
|
||||
insert,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from psyc import DATA_DIR, log
|
||||
from psyc.models import Case
|
||||
@@ -209,10 +211,31 @@ _engine: Optional[Engine] = None
|
||||
|
||||
|
||||
def engine(db_path: Path = DB_PATH) -> Engine:
|
||||
"""Lazy-init the SQLite engine.
|
||||
|
||||
Uses NullPool — SQLite doesn't benefit from connection pooling (it's a
|
||||
file, opens are cheap) and the default QueuePool starved the classify +
|
||||
federation + cockpit-request workers under real load. WAL journal mode
|
||||
+ a 30s busy timeout let readers and a writer share the file safely.
|
||||
"""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_engine = create_engine(f"sqlite:///{db_path}", future=True)
|
||||
_engine = create_engine(
|
||||
f"sqlite:///{db_path}",
|
||||
future=True,
|
||||
poolclass=NullPool,
|
||||
connect_args={"check_same_thread": False, "timeout": 30},
|
||||
)
|
||||
|
||||
@event.listens_for(_engine, "connect")
|
||||
def _sqlite_pragmas(dbapi_conn, _connection_record): # noqa: D401
|
||||
cur = dbapi_conn.cursor()
|
||||
cur.execute("PRAGMA journal_mode=WAL")
|
||||
cur.execute("PRAGMA synchronous=NORMAL")
|
||||
cur.execute("PRAGMA busy_timeout=30000")
|
||||
cur.close()
|
||||
|
||||
return _engine
|
||||
|
||||
|
||||
|
||||
@@ -16,9 +16,15 @@ import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# Hostname-with-optional-port pattern for peer domains. Reject anything else at
|
||||
# registration so a hostile domain string can't reach a render context where
|
||||
# it could break out of an HTML attr or JS string.
|
||||
_DOMAIN_RE = re.compile(r"^[A-Za-z0-9._\-]+(:\d{1,5})?$")
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -420,7 +426,17 @@ def _row_to_peer(row: Dict[str, Any]) -> Peer:
|
||||
|
||||
|
||||
def register_peer(domain: str, fingerprint: str, pubkey_pem: str, status: str = "unknown") -> None:
|
||||
"""Insert or update a peer in the registry. Idempotent on `domain`."""
|
||||
"""Insert or update a peer in the registry. Idempotent on `domain`.
|
||||
|
||||
Rejects malformed domain strings — only hostname chars + optional :port.
|
||||
Closes a stored-XSS hole where a hostile `domain` would have been rendered
|
||||
into the admin federation page's confirm() prompt.
|
||||
"""
|
||||
domain = (domain or "").strip()
|
||||
if not _DOMAIN_RE.match(domain):
|
||||
raise ValueError(f"invalid domain: {domain!r}")
|
||||
if fingerprint and not re.fullmatch(r"[0-9a-fA-F]{32}", fingerprint):
|
||||
raise ValueError(f"invalid fingerprint: {fingerprint!r}")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
existing = db.get_peer(domain)
|
||||
discovered_at = existing["discovered_at"] if existing else now
|
||||
|
||||
@@ -29,7 +29,7 @@ import httpx
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from psyc import db, log
|
||||
from psyc.lines import federation
|
||||
from psyc.lines import federation, translog
|
||||
|
||||
|
||||
_log = log.get(__name__)
|
||||
@@ -38,6 +38,7 @@ _log = log.get(__name__)
|
||||
SIGNAL_WINDOW_HOURS = 24
|
||||
TRANSITIVE_CACHE_TTL = 300.0 # 5 minutes
|
||||
TRANSITIVE_FETCH_TIMEOUT = 4.0
|
||||
EXPLORE_FETCH_TIMEOUT = 4.0
|
||||
|
||||
|
||||
# ---------- data model --------------------------------------------------
|
||||
@@ -48,6 +49,11 @@ class NetworkNode(BaseModel):
|
||||
`distance` is the topological hop count from self: 0 for self, 1 for
|
||||
directly-registered peers, 2 for peers-of-peers discovered via the
|
||||
transitive fetch. `status` is the trust label the UI colors by.
|
||||
|
||||
`stats` carries the admin-only per-peer enrichments (24h signal counts,
|
||||
severity breakdown, vouch tallies, quorum contribution, etc.) and is
|
||||
populated by `build_admin_view`. It stays empty in the public/local
|
||||
views so the public JSON never leaks operational state.
|
||||
"""
|
||||
fingerprint: str
|
||||
domain: Optional[str] = None
|
||||
@@ -55,17 +61,19 @@ class NetworkNode(BaseModel):
|
||||
status: str # "self" | "trusted" | "vouched" | "unknown" | "blocked"
|
||||
is_self: bool = False
|
||||
distance: int = 1
|
||||
stats: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class NetworkEdge(BaseModel):
|
||||
"""One edge on the federation map.
|
||||
|
||||
`kind` drives stroke style in the UI: vouch = solid, signal = dashed
|
||||
flow with thickness ∝ weight, knows = dotted grey transitive hint.
|
||||
flow with thickness ∝ weight, knows = dotted grey transitive hint,
|
||||
corroborate = dotted faint accent (two peers share a signal_hash).
|
||||
"""
|
||||
source_fingerprint: str
|
||||
target_fingerprint: str
|
||||
kind: str # "vouch" | "signal" | "knows"
|
||||
kind: str # "vouch" | "signal" | "knows" | "corroborate"
|
||||
weight: float = 1.0
|
||||
label: str = ""
|
||||
bidirectional: bool = False
|
||||
@@ -405,6 +413,246 @@ def build_public_view() -> Dict[str, Any]:
|
||||
return payload
|
||||
|
||||
|
||||
# ---------- admin-only enrichment helpers -------------------------------
|
||||
#
|
||||
# These build the rich per-peer stats the cockpit detail panel renders. They
|
||||
# read directly from the federation_signals / vouches / translog tables and
|
||||
# are only ever called from `build_admin_view` — the public view must stay
|
||||
# slim to avoid leaking operational state to peers.
|
||||
|
||||
SEVERITY_LEVELS = ("critical", "high", "medium", "low")
|
||||
IOC_TYPES = ("url", "domain", "ip", "hash", "cve")
|
||||
SEVERITY_SCAN_LIMIT = 1000
|
||||
TRANSLOG_PER_PEER_LIMIT = 10
|
||||
CORROBORATED_LIMIT = 50
|
||||
|
||||
|
||||
def _relative_time(iso_ts: str, now: datetime) -> str:
|
||||
"""Compact "3m ago" / "1h ago" / "—" for the tooltip + node badge."""
|
||||
if not iso_ts:
|
||||
return "—"
|
||||
try:
|
||||
ts = datetime.fromisoformat(iso_ts)
|
||||
except ValueError:
|
||||
return "—"
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
delta = now - ts
|
||||
secs = int(delta.total_seconds())
|
||||
if secs < 0:
|
||||
return "just now"
|
||||
if secs < 60:
|
||||
return f"{secs}s ago"
|
||||
if secs < 3600:
|
||||
return f"{secs // 60}m ago"
|
||||
if secs < 86400:
|
||||
return f"{secs // 3600}h ago"
|
||||
return f"{secs // 86400}d ago"
|
||||
|
||||
|
||||
def _decode_raw_json(raw: Any) -> Optional[Dict[str, Any]]:
|
||||
"""federation_signals.raw_json is stored as a JSON string; parse defensively."""
|
||||
if not raw:
|
||||
return None
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
try:
|
||||
v = json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
return v if isinstance(v, dict) else None
|
||||
|
||||
|
||||
def _peer_stats(
|
||||
peer_fp: str,
|
||||
now: datetime,
|
||||
signals_24h_rows: List[Dict[str, Any]],
|
||||
all_signals_for_peer_count: int,
|
||||
vouches_in: int,
|
||||
vouches_out: int,
|
||||
quorum_contribution: int,
|
||||
last_seen_iso: str,
|
||||
recent_translog: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Aggregate one peer's 24h slice + tallies into the cockpit-facing dict."""
|
||||
cases_24h = 0
|
||||
iocs_24h = 0
|
||||
severity_breakdown: Dict[str, int] = {k: 0 for k in SEVERITY_LEVELS}
|
||||
ioc_type_breakdown: Dict[str, int] = {k: 0 for k in IOC_TYPES}
|
||||
# We pulled rows newest-first; cap severity/ioc decoding to keep this fast.
|
||||
decoded = 0
|
||||
for row in signals_24h_rows:
|
||||
st = row.get("signal_type") or ""
|
||||
if st == "case":
|
||||
cases_24h += 1
|
||||
if decoded < SEVERITY_SCAN_LIMIT:
|
||||
payload = _decode_raw_json(row.get("raw_json"))
|
||||
if payload:
|
||||
sev = str(payload.get("severity") or "").lower()
|
||||
if sev in severity_breakdown:
|
||||
severity_breakdown[sev] += 1
|
||||
decoded += 1
|
||||
elif st == "ioc":
|
||||
iocs_24h += 1
|
||||
if decoded < SEVERITY_SCAN_LIMIT:
|
||||
payload = _decode_raw_json(row.get("raw_json"))
|
||||
if payload:
|
||||
t = str(payload.get("type") or "").lower()
|
||||
if t in ioc_type_breakdown:
|
||||
ioc_type_breakdown[t] += 1
|
||||
decoded += 1
|
||||
return {
|
||||
"signals_24h": len(signals_24h_rows),
|
||||
"signals_total": all_signals_for_peer_count,
|
||||
"cases_24h": cases_24h,
|
||||
"iocs_24h": iocs_24h,
|
||||
"severity_breakdown": severity_breakdown,
|
||||
"ioc_type_breakdown": ioc_type_breakdown,
|
||||
"vouches_in_count": vouches_in,
|
||||
"vouches_out_count": vouches_out,
|
||||
"quorum_contribution": quorum_contribution,
|
||||
"last_seen": last_seen_iso or None,
|
||||
"last_seen_relative": _relative_time(last_seen_iso, now),
|
||||
"recent_translog": recent_translog,
|
||||
}
|
||||
|
||||
|
||||
def _index_signals_24h(now: datetime) -> Tuple[Dict[str, List[Dict[str, Any]]], List[Dict[str, Any]]]:
|
||||
"""Bucket the 24h signal buffer by peer_fingerprint and return all rows.
|
||||
|
||||
Two return values so the caller can both walk per-peer rows and compute
|
||||
cross-cutting structures (corroboration pairs, timeline buckets) in one
|
||||
pass over the buffer.
|
||||
"""
|
||||
cutoff = (now - timedelta(hours=SIGNAL_WINDOW_HOURS)).isoformat()
|
||||
by_peer: Dict[str, List[Dict[str, Any]]] = {}
|
||||
fresh: List[Dict[str, Any]] = []
|
||||
for row in db.recent_signals(limit=10_000):
|
||||
received = str(row.get("received_at") or "")
|
||||
if received < cutoff:
|
||||
break
|
||||
fp = row.get("peer_fingerprint") or ""
|
||||
if not fp:
|
||||
continue
|
||||
by_peer.setdefault(fp, []).append(row)
|
||||
fresh.append(row)
|
||||
return by_peer, fresh
|
||||
|
||||
|
||||
def _all_signals_by_peer_count() -> Dict[str, int]:
|
||||
"""All-time count of federation_signals rows per peer_fingerprint."""
|
||||
counts: Dict[str, int] = {}
|
||||
# 50k cap — well above any realistic working set, and bounded so a
|
||||
# runaway signal flood can't OOM the admin page render.
|
||||
for row in db.recent_signals(limit=50_000):
|
||||
fp = row.get("peer_fingerprint") or ""
|
||||
if not fp:
|
||||
continue
|
||||
counts[fp] = counts.get(fp, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def _recent_translog_for_peer(peer_fp: str, all_entries: List[Any]) -> List[Dict[str, Any]]:
|
||||
"""Up to TRANSLOG_PER_PEER_LIMIT translog rows that name this peer.
|
||||
|
||||
Walks the pre-fetched batch (newest first) so we make one DB roundtrip
|
||||
for the whole admin view rather than one per peer.
|
||||
"""
|
||||
out: List[Dict[str, Any]] = []
|
||||
for entry in all_entries:
|
||||
data = entry.entry_data or {}
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
if data.get("peer_fingerprint") != peer_fp:
|
||||
continue
|
||||
out.append({
|
||||
"id": entry.id,
|
||||
"entry_type": entry.entry_type,
|
||||
"timestamp": entry.timestamp,
|
||||
"hash": entry.entry_hash,
|
||||
})
|
||||
if len(out) >= TRANSLOG_PER_PEER_LIMIT:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _corroborated_signals(
|
||||
fresh_signals: List[Dict[str, Any]],
|
||||
peer_fps: set,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""signal_hashes seen from ≥2 distinct known peers in last 24h.
|
||||
|
||||
`peer_fps` is the set of peers we render in the graph — corroboration
|
||||
edges that touch peers outside it have nowhere to anchor visually, so
|
||||
we drop them.
|
||||
"""
|
||||
by_hash: Dict[str, Dict[str, Any]] = {}
|
||||
for row in fresh_signals:
|
||||
h = row.get("signal_hash") or ""
|
||||
if not h:
|
||||
continue
|
||||
fp = row.get("peer_fingerprint") or ""
|
||||
if fp not in peer_fps:
|
||||
continue
|
||||
entry = by_hash.setdefault(h, {
|
||||
"signal_hash": h,
|
||||
"signal_type": row.get("signal_type") or "",
|
||||
"signal_id": row.get("signal_id") or "",
|
||||
"peers": set(),
|
||||
})
|
||||
entry["peers"].add(fp)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for h, entry in by_hash.items():
|
||||
if len(entry["peers"]) < 2:
|
||||
continue
|
||||
peers_sorted = sorted(entry["peers"])
|
||||
out.append({
|
||||
"signal_hash": h,
|
||||
"signal_type": entry["signal_type"],
|
||||
"signal_id": entry["signal_id"],
|
||||
"peer_count": len(peers_sorted),
|
||||
"peer_fingerprints": peers_sorted,
|
||||
"quorum_met": federation.is_quorum_met(h),
|
||||
})
|
||||
# Higher peer-counts first so the UI shows the strongest corroborations on top.
|
||||
out.sort(key=lambda r: r["peer_count"], reverse=True)
|
||||
return out[:CORROBORATED_LIMIT]
|
||||
|
||||
|
||||
def _signal_timeline_24h(
|
||||
fresh_signals: List[Dict[str, Any]],
|
||||
now: datetime,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""24 hourly buckets, oldest first. Each bucket: total + per-peer counts.
|
||||
|
||||
`hour_offset` runs 0..23 where 0 is "23–24 hours ago" and 23 is the
|
||||
current hour — left-to-right oldest-to-newest matches how operators
|
||||
read a timeline.
|
||||
"""
|
||||
buckets: List[Dict[str, Any]] = [
|
||||
{"hour_offset": i, "total": 0, "per_peer": {}} for i in range(24)
|
||||
]
|
||||
for row in fresh_signals:
|
||||
try:
|
||||
ts = datetime.fromisoformat(str(row.get("received_at") or ""))
|
||||
except ValueError:
|
||||
continue
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
hours_ago = int((now - ts).total_seconds() // 3600)
|
||||
if hours_ago < 0 or hours_ago >= 24:
|
||||
continue
|
||||
idx = 23 - hours_ago
|
||||
b = buckets[idx]
|
||||
b["total"] += 1
|
||||
fp = row.get("peer_fingerprint") or ""
|
||||
if fp:
|
||||
b["per_peer"][fp] = b["per_peer"].get(fp, 0) + 1
|
||||
return buckets
|
||||
|
||||
|
||||
# ---------- admin-only payload (data endpoint) --------------------------
|
||||
|
||||
def build_admin_view(include_transitive: bool = True) -> Dict[str, Any]:
|
||||
@@ -412,12 +660,370 @@ def build_admin_view(include_transitive: bool = True) -> Dict[str, Any]:
|
||||
|
||||
Unlike `build_public_view`, this DOES include unknown + blocked peers
|
||||
and recent signal hashes — it's only ever served behind admin auth.
|
||||
|
||||
Each non-self node gets a `stats` block:
|
||||
* 24h signal counts (total / cases / iocs)
|
||||
* severity + ioc-type breakdowns from raw_json
|
||||
* vouches in/out tallies
|
||||
* how many of this peer's signal_hashes are quorum-met
|
||||
* last_seen ISO + relative ("3m ago")
|
||||
* up to 10 recent translog rows that name them
|
||||
|
||||
Top-level `stats` gains:
|
||||
* `corroborated_signals` — pairs of peers that share a signal_hash
|
||||
in the last 24h. Drives the corroboration edges below.
|
||||
* `signal_timeline_24h` — 24 hourly buckets for the bottom-of-page
|
||||
timeline strip.
|
||||
|
||||
And the edge list gains a `kind="corroborate"` for every pair of peers
|
||||
that share ≥1 signal_hash in the 24h window. Edge weight = number of
|
||||
shared hashes for that pair.
|
||||
"""
|
||||
view = build_transitive_view() if include_transitive else build_local_view()
|
||||
our_fp = view.nodes[0].fingerprint
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Pre-fetch the tables we'll query per-peer so the admin render is one
|
||||
# batch of DB hits, not one-per-node.
|
||||
signals_by_peer, fresh_signals = _index_signals_24h(now)
|
||||
all_signal_counts = _all_signals_by_peer_count()
|
||||
recent_translog_entries = translog.recent(limit=500)
|
||||
|
||||
# Vouch tallies per peer (in/out).
|
||||
vouches_in: Dict[str, int] = {}
|
||||
vouches_out: Dict[str, int] = {}
|
||||
for row in db.list_vouches():
|
||||
target = row.get("target_fingerprint") or ""
|
||||
voucher = row.get("voucher_fingerprint") or ""
|
||||
if target:
|
||||
vouches_in[target] = vouches_in.get(target, 0) + 1
|
||||
if voucher:
|
||||
vouches_out[voucher] = vouches_out.get(voucher, 0) + 1
|
||||
|
||||
# Per-peer quorum contribution — distinct signal_hashes from this peer
|
||||
# that are quorum-met. Cached per-hash within this build to dedupe work
|
||||
# across peers reporting the same hash.
|
||||
quorum_cache: Dict[str, bool] = {}
|
||||
|
||||
def _quorum_for_hash(h: str) -> bool:
|
||||
if h in quorum_cache:
|
||||
return quorum_cache[h]
|
||||
v = federation.is_quorum_met(h)
|
||||
quorum_cache[h] = v
|
||||
return v
|
||||
|
||||
peer_fps: set = set()
|
||||
for node in view.nodes:
|
||||
if node.is_self:
|
||||
continue
|
||||
peer_fps.add(node.fingerprint)
|
||||
peer_rows = signals_by_peer.get(node.fingerprint, [])
|
||||
last_seen_iso = ""
|
||||
if peer_rows:
|
||||
# recent_signals returns newest-first → first row is latest.
|
||||
last_seen_iso = str(peer_rows[0].get("received_at") or "")
|
||||
peer_quorum_contrib = 0
|
||||
seen_hashes: set = set()
|
||||
for r in peer_rows:
|
||||
h = r.get("signal_hash") or ""
|
||||
if not h or h in seen_hashes:
|
||||
continue
|
||||
seen_hashes.add(h)
|
||||
if _quorum_for_hash(h):
|
||||
peer_quorum_contrib += 1
|
||||
node.stats = _peer_stats(
|
||||
peer_fp=node.fingerprint,
|
||||
now=now,
|
||||
signals_24h_rows=peer_rows,
|
||||
all_signals_for_peer_count=all_signal_counts.get(node.fingerprint, 0),
|
||||
vouches_in=vouches_in.get(node.fingerprint, 0),
|
||||
vouches_out=vouches_out.get(node.fingerprint, 0),
|
||||
quorum_contribution=peer_quorum_contrib,
|
||||
last_seen_iso=last_seen_iso,
|
||||
recent_translog=_recent_translog_for_peer(node.fingerprint, recent_translog_entries),
|
||||
)
|
||||
|
||||
# Corroboration: pairs of rendered peers that share a signal_hash.
|
||||
corroborated = _corroborated_signals(fresh_signals, peer_fps)
|
||||
# Per-pair shared-hash count → corroborate edges.
|
||||
pair_counts: Dict[Tuple[str, str], int] = {}
|
||||
for entry in corroborated:
|
||||
fps = entry["peer_fingerprints"]
|
||||
for i in range(len(fps)):
|
||||
for j in range(i + 1, len(fps)):
|
||||
a, b = fps[i], fps[j]
|
||||
key = (a, b) if a < b else (b, a)
|
||||
pair_counts[key] = pair_counts.get(key, 0) + 1
|
||||
for (a, b), count in pair_counts.items():
|
||||
view.edges.append(NetworkEdge(
|
||||
source_fingerprint=a,
|
||||
target_fingerprint=b,
|
||||
kind="corroborate",
|
||||
weight=float(count),
|
||||
label=f"{count} shared signals",
|
||||
))
|
||||
|
||||
# Top-level stats — keep existing, layer on the new admin extras.
|
||||
view.stats["corroborated_signals"] = corroborated
|
||||
view.stats["signal_timeline_24h"] = _signal_timeline_24h(fresh_signals, now)
|
||||
|
||||
return {
|
||||
"self_fingerprint": view.nodes[0].fingerprint,
|
||||
"self_fingerprint": our_fp,
|
||||
"nodes": [n.model_dump() for n in view.nodes],
|
||||
"edges": [e.model_dump() for e in view.edges],
|
||||
"stats": view.stats,
|
||||
"generated_at": view.generated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ---------- public explore payload --------------------------------------
|
||||
#
|
||||
# "Transparent security" view: the same shape a peer would see at
|
||||
# /federation/network, plus per-peer counts (NEVER values), inbound vouches,
|
||||
# and a thin distance-2 snapshot — enough for a public visitor to draw the
|
||||
# mesh and walk to any peer's own explore page. Everything is signed.
|
||||
|
||||
EXPLORE_TRANSITIVE_CAP = 50 # cap on distinct distance-2 fps to keep payload bounded
|
||||
|
||||
|
||||
def _explore_peer_stats(
|
||||
peer_fp: str,
|
||||
now: datetime,
|
||||
signals_by_peer: Dict[str, List[Dict[str, Any]]],
|
||||
all_signal_counts: Dict[str, int],
|
||||
quorum_cache: Dict[str, bool],
|
||||
) -> Dict[str, Any]:
|
||||
"""Per-peer COUNTS only — no IOC values, no case summaries, no raw_json.
|
||||
|
||||
Counts split by signal_type (cases vs iocs) are safe to expose since
|
||||
the magnitude of "how chatty is this peer" is already implicit in the
|
||||
24h signal count. We deliberately omit severity + ioc_type breakdowns
|
||||
here — those could hint at the target sector.
|
||||
"""
|
||||
rows = signals_by_peer.get(peer_fp, [])
|
||||
cases_24h = 0
|
||||
iocs_24h = 0
|
||||
last_seen_iso = ""
|
||||
seen_hashes: set = set()
|
||||
quorum_contribution_24h = 0
|
||||
for row in rows:
|
||||
st = row.get("signal_type") or ""
|
||||
if st == "case":
|
||||
cases_24h += 1
|
||||
elif st == "ioc":
|
||||
iocs_24h += 1
|
||||
h = row.get("signal_hash") or ""
|
||||
if h and h not in seen_hashes:
|
||||
seen_hashes.add(h)
|
||||
if h not in quorum_cache:
|
||||
quorum_cache[h] = federation.is_quorum_met(h)
|
||||
if quorum_cache[h]:
|
||||
quorum_contribution_24h += 1
|
||||
if rows:
|
||||
# recent_signals returns newest-first → first row is latest.
|
||||
last_seen_iso = str(rows[0].get("received_at") or "")
|
||||
return {
|
||||
"signal_count_24h": len(rows),
|
||||
"signal_count_total": all_signal_counts.get(peer_fp, 0),
|
||||
"cases_24h": cases_24h,
|
||||
"iocs_24h": iocs_24h,
|
||||
"quorum_contribution_24h": quorum_contribution_24h,
|
||||
"last_seen": last_seen_iso or None,
|
||||
}
|
||||
|
||||
|
||||
def _fetch_peer_explore(domain: str, timeout: float = EXPLORE_FETCH_TIMEOUT) -> Optional[Dict[str, Any]]:
|
||||
"""GET /federation/explore/data on a peer. Returns dict on success.
|
||||
|
||||
Mirrors `_fetch_peer_network`'s failure semantics: one slow/broken peer
|
||||
must never abort the explore walk.
|
||||
"""
|
||||
if not domain:
|
||||
return None
|
||||
url = f"https://{domain}/federation/explore/data"
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
r = client.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_log.info("network_view.explore.transitive.skip", domain=domain, reason=str(exc)[:120])
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _explore_transitive_peers(
|
||||
trusted_peers: List[Tuple[str, Optional[str]]],
|
||||
own_fp: str,
|
||||
own_peer_fps: set,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Distance-2 fps learned from trusted peers' explore/data feeds.
|
||||
|
||||
Returns [{fingerprint, via_peer_fingerprint, domain}] entries. Capped at
|
||||
`EXPLORE_TRANSITIVE_CAP` to keep the public payload bounded — first peer
|
||||
to introduce a fingerprint wins so the via attribution stays stable.
|
||||
"""
|
||||
seen: set = set(own_peer_fps)
|
||||
seen.add(own_fp)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for parent_fp, parent_domain in trusted_peers:
|
||||
if not parent_domain or len(out) >= EXPLORE_TRANSITIVE_CAP:
|
||||
continue
|
||||
data = _fetch_peer_explore(parent_domain)
|
||||
if not data:
|
||||
# Fall back to the older /federation/network endpoint — older
|
||||
# psyc nodes won't have /federation/explore/data yet.
|
||||
data = _fetch_peer_network(parent_domain)
|
||||
if not data:
|
||||
continue
|
||||
their_peers = data.get("peers") or []
|
||||
for pp in their_peers:
|
||||
if not isinstance(pp, dict):
|
||||
continue
|
||||
fp = str(pp.get("fingerprint") or "")
|
||||
if not fp or fp in seen:
|
||||
continue
|
||||
seen.add(fp)
|
||||
out.append({
|
||||
"fingerprint": fp,
|
||||
"domain": pp.get("domain") or None,
|
||||
"via_peer_fingerprint": parent_fp,
|
||||
})
|
||||
if len(out) >= EXPLORE_TRANSITIVE_CAP:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def build_explore_view(node_domain: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Signed public explorer payload for /federation/explore/data.
|
||||
|
||||
Extends `build_public_view` with:
|
||||
* `node` — headline stats about THIS node (counts only)
|
||||
* `peers[].*_count_24h` — per-peer chatter levels (no values leak)
|
||||
* `vouches_in` — who has vouched for us (we only include vouchers
|
||||
whose peer we currently trust, so signatures don't
|
||||
leak unknown identities)
|
||||
* `transitive_peers` — distance-2 fingerprints learned from each
|
||||
trusted peer's public explore/network feed.
|
||||
Cached aggressively (mirrors transitive cache).
|
||||
* `corroboration_count_24h` — # distinct signal_hashes seen from ≥2
|
||||
peers in the 24h window.
|
||||
|
||||
The whole payload (sans signature) is Ed25519-signed over canonical JSON.
|
||||
No IOC values, case_ids, raw_json, severity or ioc-type breakdowns are
|
||||
included — anything that could leak the target sector or who reported
|
||||
what stays inside `build_admin_view`.
|
||||
"""
|
||||
our_fp = federation.node_fingerprint()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Reuse the 24h signal bucket scan + all-time count + quorum cache.
|
||||
signals_by_peer, fresh_signals = _index_signals_24h(now)
|
||||
all_signal_counts = _all_signals_by_peer_count()
|
||||
quorum_cache: Dict[str, bool] = {}
|
||||
|
||||
# Build the trusted-peer rows (the only ones we expose), with public-safe
|
||||
# stats. Unknown + blocked never leak — see `build_public_view`.
|
||||
peer_rows: List[Dict[str, Any]] = []
|
||||
trusted_peers_for_walk: List[Tuple[str, Optional[str]]] = []
|
||||
trusted_fps: set = set()
|
||||
for p in federation.list_peers():
|
||||
if p.status != "trusted":
|
||||
continue
|
||||
trusted_fps.add(p.fingerprint)
|
||||
trusted_peers_for_walk.append((p.fingerprint, p.domain))
|
||||
stats = _explore_peer_stats(
|
||||
peer_fp=p.fingerprint,
|
||||
now=now,
|
||||
signals_by_peer=signals_by_peer,
|
||||
all_signal_counts=all_signal_counts,
|
||||
quorum_cache=quorum_cache,
|
||||
)
|
||||
peer_rows.append({
|
||||
"domain": p.domain,
|
||||
"fingerprint": p.fingerprint,
|
||||
"first_seen": p.discovered_at,
|
||||
**stats,
|
||||
})
|
||||
|
||||
# Vouches WE've issued — same shape as build_public_view + signature.
|
||||
vouches_out: List[Dict[str, Any]] = []
|
||||
for v in federation.our_vouches():
|
||||
vouches_out.append({
|
||||
"voucher_fingerprint": v.voucher_fingerprint,
|
||||
"target_fingerprint": v.target_fingerprint,
|
||||
"issued_at": v.issued_at.isoformat(),
|
||||
"expires_at": v.expires_at.isoformat() if v.expires_at else None,
|
||||
"signature": v.signature,
|
||||
})
|
||||
|
||||
# Vouches IN — only those naming us as target where we trust the voucher.
|
||||
# We don't surface vouches from unknown identities: doing so would let any
|
||||
# stranger forge an inbound vouch and show up here.
|
||||
vouches_in: List[Dict[str, Any]] = []
|
||||
for row in db.list_vouches():
|
||||
if (row.get("target_fingerprint") or "") != our_fp:
|
||||
continue
|
||||
voucher_fp = row.get("voucher_fingerprint") or ""
|
||||
if voucher_fp == our_fp:
|
||||
continue
|
||||
if voucher_fp not in trusted_fps:
|
||||
continue
|
||||
vouches_in.append({
|
||||
"voucher_fingerprint": voucher_fp,
|
||||
"target_fingerprint": our_fp,
|
||||
"issued_at": row.get("issued_at") or "",
|
||||
"expires_at": row.get("expires_at") or None,
|
||||
"signature": row.get("signature") or "",
|
||||
})
|
||||
|
||||
# Transitive snapshot. The aim is "one fetch surfaces N hops" — distance-2
|
||||
# fingerprints learned from each trusted peer's own explore/network feed.
|
||||
transitive_peers = _explore_transitive_peers(
|
||||
trusted_peers_for_walk, our_fp, trusted_fps,
|
||||
)
|
||||
|
||||
# Corroboration: # distinct hashes seen from ≥2 distinct peers in 24h.
|
||||
by_hash: Dict[str, set] = {}
|
||||
for row in fresh_signals:
|
||||
h = row.get("signal_hash") or ""
|
||||
if not h:
|
||||
continue
|
||||
by_hash.setdefault(h, set()).add(row.get("peer_fingerprint") or "")
|
||||
corroboration_count_24h = sum(1 for fps in by_hash.values() if len(fps) >= 2)
|
||||
|
||||
# Transparency log headline numbers — chain head + length, never bodies.
|
||||
head_entry = translog.head()
|
||||
translog_head_hash = head_entry.entry_hash if head_entry else None
|
||||
translog_entry_count = int(head_entry.id) if head_entry else 0
|
||||
|
||||
node_block: Dict[str, Any] = {
|
||||
"fingerprint": our_fp,
|
||||
"domain": node_domain,
|
||||
"generated_at": now.isoformat(),
|
||||
"transparency_log_head_hash": translog_head_hash,
|
||||
"translog_entry_count": translog_entry_count,
|
||||
"peer_count": len(peer_rows),
|
||||
"vouches_out_count": len(vouches_out),
|
||||
"vouches_in_count": len(vouches_in),
|
||||
"corroboration_count_24h": corroboration_count_24h,
|
||||
"signals_count_24h": sum(p["signal_count_24h"] for p in peer_rows),
|
||||
}
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"version": federation.FEED_VERSION,
|
||||
"fingerprint": our_fp,
|
||||
"generated_at": now.isoformat(),
|
||||
"node": node_block,
|
||||
"peers": peer_rows,
|
||||
"vouches": vouches_out, # kept for shape-compat with /federation/network
|
||||
"vouches_out": vouches_out,
|
||||
"vouches_in": vouches_in,
|
||||
"transitive_peers": transitive_peers,
|
||||
"corroboration_count_24h": corroboration_count_24h,
|
||||
}
|
||||
sig = federation.sign_payload(federation.canonical_json(payload))
|
||||
payload["signature"] = base64.b64encode(sig).decode("ascii")
|
||||
return payload
|
||||
|
||||
228
src/psyc/lines/topology_export.py
Normal file
228
src/psyc/lines/topology_export.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Topology export — sanitized public docker snapshot.
|
||||
|
||||
The cockpit's `docker_view.topology()` returns a rich daemon view useful to
|
||||
the local operator: container env vars, volume mounts, internal IPs, labels,
|
||||
gateways. None of that may leave the node. This module wraps `docker_view`
|
||||
with a strict whitelist: only container names, images, states, network names
|
||||
and high-level driver/health metadata are exposed. Anything not listed in
|
||||
the Pydantic schemas below is dropped before serialization.
|
||||
|
||||
Used by `/federation/topology` so peer admin pages can render every node's
|
||||
container topology side-by-side with their own.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from psyc import log
|
||||
from psyc.cockpit import docker_view
|
||||
from psyc.lines import federation
|
||||
|
||||
|
||||
_log = log.get(__name__)
|
||||
|
||||
|
||||
# Caps keep the response bounded — a runaway node with thousands of
|
||||
# containers shouldn't blow up the peer's panel.
|
||||
MAX_CONTAINERS = 200
|
||||
MAX_NETWORKS = 50
|
||||
|
||||
|
||||
# ---------- data model --------------------------------------------------
|
||||
|
||||
class TopologyContainer(BaseModel):
|
||||
"""One container — sanitized.
|
||||
|
||||
Strict whitelist: name, short_id, image (tag-only), state, health,
|
||||
network names, compose service label, started_at. No env vars, no
|
||||
volumes, no IPs, no MACs, no port mappings, no full labels dict.
|
||||
"""
|
||||
name: str
|
||||
short_id: str
|
||||
image: str
|
||||
state: str
|
||||
health: str
|
||||
networks: List[str] = Field(default_factory=list)
|
||||
service: Optional[str] = None
|
||||
started_at: Optional[str] = None
|
||||
|
||||
|
||||
class TopologyNetwork(BaseModel):
|
||||
"""One docker network — sanitized.
|
||||
|
||||
Whitelist: name, driver, internal flag, container_count. No subnet,
|
||||
no gateway, no labels, no attached-container details (those are
|
||||
surfaced via the container.networks list).
|
||||
"""
|
||||
name: str
|
||||
driver: str
|
||||
internal: bool
|
||||
container_count: int
|
||||
|
||||
|
||||
class TopologyExport(BaseModel):
|
||||
"""Whole-node container snapshot, public-safe."""
|
||||
node_fingerprint: str
|
||||
generated_at: str
|
||||
host_name: str
|
||||
container_count: int
|
||||
network_count: int
|
||||
containers: List[TopologyContainer] = Field(default_factory=list)
|
||||
networks: List[TopologyNetwork] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------- sanitizers --------------------------------------------------
|
||||
|
||||
_BASIC_AUTH_RE = re.compile(r"^[^/@]+@")
|
||||
|
||||
|
||||
def _filter_image_name(s: str) -> str:
|
||||
"""Strip credentials from an image reference and drop digests.
|
||||
|
||||
Docker accepts `user:pass@registry/image:tag` for registries with HTTP
|
||||
basic auth — we strip everything up to and including the `@` so leaked
|
||||
creds never reach a peer. We also cut content-addressable digests
|
||||
(`...@sha256:...`) to a clean tag-only form.
|
||||
|
||||
Returns the cleaned `repo/image:tag` string. Empty input → "".
|
||||
"""
|
||||
if not s:
|
||||
return ""
|
||||
raw = str(s).strip()
|
||||
if not raw:
|
||||
return ""
|
||||
# Drop digest suffix, e.g. "nginx:1.25@sha256:abcd…" → "nginx:1.25".
|
||||
if "@sha256:" in raw:
|
||||
raw = raw.split("@sha256:", 1)[0]
|
||||
# Strip basic-auth prefix on the registry component.
|
||||
# "user:pass@host/repo:tag" → "host/repo:tag" (we never want creds out).
|
||||
if _BASIC_AUTH_RE.match(raw):
|
||||
raw = raw.split("@", 1)[1]
|
||||
# Cap length defensively.
|
||||
return raw[:160]
|
||||
|
||||
|
||||
def _short_id(raw: Any) -> str:
|
||||
s = str(raw or "")
|
||||
return s[:12]
|
||||
|
||||
|
||||
def _parse_health(status: str) -> str:
|
||||
"""Extract a healthcheck word from the docker "Status" line if present.
|
||||
|
||||
docker's container-list "Status" string includes "(healthy)" or
|
||||
"(unhealthy)" when a healthcheck is configured. We surface just that
|
||||
one-word state and fall back to "—" otherwise — no other free-form
|
||||
text from the daemon leaks out.
|
||||
"""
|
||||
if not status:
|
||||
return "—"
|
||||
low = status.lower()
|
||||
if "(healthy)" in low:
|
||||
return "healthy"
|
||||
if "(unhealthy)" in low:
|
||||
return "unhealthy"
|
||||
if "(starting)" in low or "(health: starting)" in low:
|
||||
return "starting"
|
||||
return "—"
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _empty_export(node_fp: str) -> TopologyExport:
|
||||
return TopologyExport(
|
||||
node_fingerprint=node_fp,
|
||||
generated_at=_now_iso(),
|
||||
host_name="",
|
||||
container_count=0,
|
||||
network_count=0,
|
||||
containers=[],
|
||||
networks=[],
|
||||
)
|
||||
|
||||
|
||||
# ---------- builder ------------------------------------------------------
|
||||
|
||||
def build_export() -> TopologyExport:
|
||||
"""Sanitized snapshot of this node's docker topology.
|
||||
|
||||
Calls `docker_view.topology()` and re-projects every field through the
|
||||
Pydantic whitelist above. If the proxy is unreachable (e.g. dev box
|
||||
without docker-socket-proxy) we return an empty export rather than
|
||||
raising — the public endpoint must never 500.
|
||||
"""
|
||||
try:
|
||||
node_fp = federation.node_fingerprint()
|
||||
except Exception as exc: # noqa: BLE001 — keep endpoint defensive
|
||||
_log.warning("topology_export.fp.error", error=str(exc))
|
||||
node_fp = ""
|
||||
|
||||
try:
|
||||
raw = docker_view.topology()
|
||||
except Exception as exc: # noqa: BLE001 — docker proxy may be down
|
||||
_log.warning("topology_export.docker.error", error=str(exc))
|
||||
return _empty_export(node_fp)
|
||||
|
||||
# docker_view.topology() returns a dict with `containers`, `networks`,
|
||||
# `host`, `error` fields. We treat any non-None error as "empty export"
|
||||
# rather than partially leaking through whatever did succeed.
|
||||
if raw.get("error"):
|
||||
return _empty_export(node_fp)
|
||||
|
||||
raw_host = raw.get("host") or {}
|
||||
host_name_raw = str(raw_host.get("name") or "")
|
||||
# Truncate the docker host id — it can be the actual machine hostname.
|
||||
# Keep it short, no domain. Defensive even though docker host names are
|
||||
# generally low-sensitivity.
|
||||
host_name = host_name_raw[:24]
|
||||
|
||||
raw_containers = raw.get("containers") or []
|
||||
raw_networks = raw.get("networks") or []
|
||||
|
||||
containers: List[TopologyContainer] = []
|
||||
for c in raw_containers[:MAX_CONTAINERS]:
|
||||
nets_raw = c.get("networks") or []
|
||||
net_names: List[str] = []
|
||||
for nd in nets_raw:
|
||||
nm = nd.get("name") if isinstance(nd, dict) else None
|
||||
if nm:
|
||||
net_names.append(str(nm)[:64])
|
||||
containers.append(TopologyContainer(
|
||||
name=str(c.get("name") or "?")[:64],
|
||||
short_id=_short_id(c.get("id")),
|
||||
image=_filter_image_name(c.get("image") or ""),
|
||||
state=str(c.get("state") or "")[:24],
|
||||
health=_parse_health(str(c.get("status") or "")),
|
||||
networks=net_names[:12],
|
||||
# docker_view doesn't currently surface the compose service label
|
||||
# or started_at; leave them None until that lands.
|
||||
service=None,
|
||||
started_at=None,
|
||||
))
|
||||
|
||||
networks: List[TopologyNetwork] = []
|
||||
for n in raw_networks[:MAX_NETWORKS]:
|
||||
attached = n.get("containers") or []
|
||||
networks.append(TopologyNetwork(
|
||||
name=str(n.get("name") or "")[:64],
|
||||
driver=str(n.get("driver") or "")[:24],
|
||||
internal=bool(n.get("internal")),
|
||||
container_count=len(attached),
|
||||
))
|
||||
|
||||
return TopologyExport(
|
||||
node_fingerprint=node_fp,
|
||||
generated_at=_now_iso(),
|
||||
host_name=host_name,
|
||||
container_count=len(containers),
|
||||
network_count=len(networks),
|
||||
containers=containers,
|
||||
networks=networks,
|
||||
)
|
||||
427
tests/test_explore_view.py
Normal file
427
tests/test_explore_view.py
Normal 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"
|
||||
@@ -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
151
tests/test_inference.py
Normal 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
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict
|
||||
from unittest.mock import patch
|
||||
@@ -11,11 +12,13 @@ import pytest
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
from psyc import db
|
||||
from psyc.lines import federation, network_view
|
||||
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,
|
||||
@@ -332,3 +335,368 @@ def test_transitive_view_skips_only_unknown_peers(fresh_db, fed_dir):
|
||||
|
||||
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
|
||||
|
||||
313
tests/test_topology_export.py
Normal file
313
tests/test_topology_export.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""Topology export — whitelist sanitization + endpoint contract.
|
||||
|
||||
The big invariant: nothing from docker_view.topology() escapes that isn't
|
||||
in the Pydantic schema. We assert via model_fields introspection AND via a
|
||||
JSON-dump scan over a fixture that contains every dangerous field.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from psyc import db
|
||||
from psyc.cockpit import docker_view, federation_routes
|
||||
from psyc.lines import federation, topology_export
|
||||
from psyc.lines.topology_export import (
|
||||
TopologyContainer,
|
||||
TopologyExport,
|
||||
TopologyNetwork,
|
||||
_filter_image_name,
|
||||
build_export,
|
||||
)
|
||||
|
||||
|
||||
# ---------- fixtures ----------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
test_db = tmp_path / "test.db"
|
||||
eng = create_engine(f"sqlite:///{test_db}", future=True)
|
||||
db._metadata.create_all(eng, checkfirst=True)
|
||||
monkeypatch.setattr(db, "_engine", eng)
|
||||
monkeypatch.setattr(db, "DB_PATH", test_db)
|
||||
yield test_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fed_dir(tmp_path, monkeypatch):
|
||||
d = tmp_path / "federation"
|
||||
monkeypatch.setattr(federation, "FED_DIR", d)
|
||||
monkeypatch.setattr(federation, "PRIVATE_KEY_PATH", d / "node.key")
|
||||
monkeypatch.setattr(federation, "PUBLIC_KEY_PATH", d / "node.pub")
|
||||
yield d
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_topology_cache():
|
||||
if hasattr(federation_routes, "_TOPOLOGY_CACHE"):
|
||||
federation_routes._TOPOLOGY_CACHE["payload"] = None
|
||||
federation_routes._TOPOLOGY_CACHE["ts"] = 0.0
|
||||
yield
|
||||
|
||||
|
||||
# ---------- fixture data: hostile docker_view output --------------------
|
||||
|
||||
# This payload has every leaky field docker_view *could* surface, plus
|
||||
# nested env-style data — used to prove the export is whitelist-only.
|
||||
_LEAKY_TOPOLOGY: Dict[str, Any] = {
|
||||
"containers": [
|
||||
{
|
||||
"id": "abcdef1234567890ffff",
|
||||
"name": "psyc-cockpit-1",
|
||||
"image": "registry.example/psyc:1.2",
|
||||
"state": "running",
|
||||
"status": "Up 5 minutes (healthy)",
|
||||
"networks": [
|
||||
{"name": "backend", "ip": "172.20.0.5", "gateway": "172.20.0.1", "mac": "02:42:ac:14:00:05"},
|
||||
{"name": "frontend", "ip": "172.21.0.7", "gateway": "172.21.0.1", "mac": "02:42:ac:15:00:07"},
|
||||
],
|
||||
"ports": ["0.0.0.0:8767->8767/tcp"],
|
||||
"published_ports": ["8767/tcp"],
|
||||
# These are NOT current docker_view fields but defend in depth —
|
||||
# if a future docker_view change adds them, sanitizer drops them.
|
||||
"env": ["SECRET_TOKEN=abc123", "DB_PASSWORD=hunter2"],
|
||||
"mounts": ["/var/run/docker.sock", "/etc/secrets:/secrets"],
|
||||
"labels": {"com.docker.compose.project": "psyc", "secret_label": "shh"},
|
||||
},
|
||||
{
|
||||
"id": "fedcba0987654321",
|
||||
"name": "some-stopped",
|
||||
"image": "alpine",
|
||||
"state": "exited",
|
||||
"status": "Exited (0) 2 hours ago",
|
||||
"networks": [],
|
||||
"ports": [],
|
||||
"published_ports": [],
|
||||
},
|
||||
],
|
||||
"networks": [
|
||||
{
|
||||
"id": "n1", "name": "backend", "driver": "bridge", "scope": "local",
|
||||
"internal": False, "subnet": "172.20.0.0/16", "gateway": "172.20.0.1",
|
||||
"containers": [
|
||||
{"id": "abcdef123456", "name": "psyc-cockpit-1", "ip": "172.20.0.5", "mac": "02:42:ac:14:00:05"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "n2", "name": "internal-only", "driver": "bridge", "scope": "local",
|
||||
"internal": True, "subnet": "10.99.0.0/16", "gateway": "10.99.0.1",
|
||||
"containers": [],
|
||||
},
|
||||
],
|
||||
"host": {"name": "docker-host-secret-internal.example.com", "os": "linux", "ncpu": 8},
|
||||
"error": None,
|
||||
"proxy": "http://docker-socket-proxy:2375",
|
||||
}
|
||||
|
||||
|
||||
# Sensitive strings that MUST NOT appear anywhere in the export JSON.
|
||||
_FORBIDDEN_STRINGS = (
|
||||
"SECRET_TOKEN", "DB_PASSWORD", "hunter2", "abc123",
|
||||
"/var/run/docker.sock", "/etc/secrets",
|
||||
"secret_label", "shh",
|
||||
"172.20.0.5", "172.21.0.7", # IPs
|
||||
"02:42:ac", # MAC prefix
|
||||
"172.20.0.1", # gateway
|
||||
"172.20.0.0/16", "10.99.0.0/16", # subnets
|
||||
"0.0.0.0:8767", # port mapping
|
||||
"internal.example.com", # full host
|
||||
)
|
||||
|
||||
|
||||
# ---------- model field introspection -----------------------------------
|
||||
|
||||
def test_container_model_has_no_dangerous_fields():
|
||||
fields = set(TopologyContainer.model_fields.keys())
|
||||
# whitelist — must match the design contract exactly
|
||||
assert fields == {
|
||||
"name", "short_id", "image", "state", "health",
|
||||
"networks", "service", "started_at",
|
||||
}
|
||||
# explicit deny-list, double-belt
|
||||
for forbidden in ("env", "environment", "mounts", "volumes",
|
||||
"labels", "ip", "ip_address", "ipaddress",
|
||||
"ports", "published_ports", "mac", "gateway"):
|
||||
assert forbidden not in fields, f"{forbidden} must not be a field"
|
||||
|
||||
|
||||
def test_network_model_has_no_dangerous_fields():
|
||||
fields = set(TopologyNetwork.model_fields.keys())
|
||||
assert fields == {"name", "driver", "internal", "container_count"}
|
||||
for forbidden in ("subnet", "gateway", "labels", "ipam",
|
||||
"containers", "scope", "id"):
|
||||
assert forbidden not in fields, f"{forbidden} must not be a field"
|
||||
|
||||
|
||||
def test_export_model_top_level_fields():
|
||||
fields = set(TopologyExport.model_fields.keys())
|
||||
assert fields == {
|
||||
"node_fingerprint", "generated_at", "host_name",
|
||||
"container_count", "network_count", "containers", "networks",
|
||||
}
|
||||
|
||||
|
||||
# ---------- image-name filter -------------------------------------------
|
||||
|
||||
def test_filter_image_strips_basic_auth_prefix():
|
||||
# user:pass@host/repo:tag → host/repo:tag (creds gone)
|
||||
assert _filter_image_name("user:pass@host/repo:tag") == "host/repo:tag"
|
||||
|
||||
|
||||
def test_filter_image_drops_digest_suffix():
|
||||
assert _filter_image_name(
|
||||
"nginx:1.25@sha256:abcdef0123"
|
||||
) == "nginx:1.25"
|
||||
|
||||
|
||||
def test_filter_image_passes_clean_refs_untouched():
|
||||
assert _filter_image_name("psyc:latest") == "psyc:latest"
|
||||
assert _filter_image_name(
|
||||
"ghcr.io/example/psyc:v0.3.1"
|
||||
) == "ghcr.io/example/psyc:v0.3.1"
|
||||
|
||||
|
||||
def test_filter_image_handles_empty():
|
||||
assert _filter_image_name("") == ""
|
||||
assert _filter_image_name(None) == "" # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ---------- build_export contract ---------------------------------------
|
||||
|
||||
def test_build_export_returns_empty_when_docker_view_raises(fresh_db, fed_dir, monkeypatch):
|
||||
def boom():
|
||||
raise docker_view.DockerProxyError("connection refused")
|
||||
monkeypatch.setattr(docker_view, "topology", boom)
|
||||
out = build_export()
|
||||
assert isinstance(out, TopologyExport)
|
||||
assert out.container_count == 0
|
||||
assert out.containers == []
|
||||
assert out.networks == []
|
||||
# fingerprint is still real (federation key was generated)
|
||||
assert len(out.node_fingerprint) == 32
|
||||
|
||||
|
||||
def test_build_export_returns_empty_when_docker_view_reports_error(fresh_db, fed_dir, monkeypatch):
|
||||
monkeypatch.setattr(docker_view, "topology", lambda: {
|
||||
"containers": [], "networks": [], "host": {"name": "x"},
|
||||
"error": "containers: refused", "proxy": "x",
|
||||
})
|
||||
out = build_export()
|
||||
assert out.container_count == 0
|
||||
assert out.containers == []
|
||||
|
||||
|
||||
def test_build_export_sanitizes_every_field(fresh_db, fed_dir, monkeypatch):
|
||||
monkeypatch.setattr(docker_view, "topology", lambda: _LEAKY_TOPOLOGY)
|
||||
out = build_export()
|
||||
# Containers came through, but as TopologyContainer (no leaky attrs).
|
||||
assert out.container_count == 2
|
||||
by_name = {c.name: c for c in out.containers}
|
||||
cp = by_name["psyc-cockpit-1"]
|
||||
assert cp.short_id == "abcdef123456"
|
||||
assert cp.image == "registry.example/psyc:1.2"
|
||||
assert cp.state == "running"
|
||||
assert cp.health == "healthy"
|
||||
assert cp.networks == ["backend", "frontend"]
|
||||
assert cp.service is None
|
||||
# Networks came through, sanitized.
|
||||
assert out.network_count == 2
|
||||
by_net = {n.name: n for n in out.networks}
|
||||
assert by_net["backend"].driver == "bridge"
|
||||
assert by_net["backend"].internal is False
|
||||
assert by_net["backend"].container_count == 1
|
||||
assert by_net["internal-only"].internal is True
|
||||
|
||||
|
||||
def test_export_json_contains_no_dangerous_strings(fresh_db, fed_dir, monkeypatch):
|
||||
"""Strict no-leak: serialize and grep for everything sensitive."""
|
||||
monkeypatch.setattr(docker_view, "topology", lambda: _LEAKY_TOPOLOGY)
|
||||
out = build_export()
|
||||
blob = json.dumps(out.model_dump(mode="json"))
|
||||
for forbidden in _FORBIDDEN_STRINGS:
|
||||
assert forbidden not in blob, f"leak: {forbidden!r} appeared in export JSON"
|
||||
|
||||
|
||||
def test_build_export_caps_at_max_containers(fresh_db, fed_dir, monkeypatch):
|
||||
fake = {
|
||||
"containers": [
|
||||
{"id": f"id{i:04d}", "name": f"c{i}", "image": "x", "state": "running", "status": "Up", "networks": []}
|
||||
for i in range(topology_export.MAX_CONTAINERS + 50)
|
||||
],
|
||||
"networks": [], "host": {"name": "h"}, "error": None, "proxy": "",
|
||||
}
|
||||
monkeypatch.setattr(docker_view, "topology", lambda: fake)
|
||||
out = build_export()
|
||||
assert out.container_count == topology_export.MAX_CONTAINERS
|
||||
|
||||
|
||||
# ---------- HTTP endpoint -----------------------------------------------
|
||||
|
||||
def _mk_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret")
|
||||
from pathlib import Path as _Path
|
||||
here = _Path(__file__).resolve().parent.parent / "src" / "psyc" / "cockpit" / "templates"
|
||||
templates = Jinja2Templates(directory=str(here))
|
||||
federation_routes.register(app, templates)
|
||||
return app
|
||||
|
||||
|
||||
def test_federation_topology_endpoint_returns_json_with_cors(fresh_db, fed_dir, monkeypatch):
|
||||
monkeypatch.setattr(docker_view, "topology", lambda: _LEAKY_TOPOLOGY)
|
||||
client = TestClient(_mk_app())
|
||||
r = client.get("/federation/topology")
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get("access-control-allow-origin") == "*"
|
||||
data = r.json()
|
||||
# Schema check.
|
||||
for key in ("node_fingerprint", "generated_at", "host_name",
|
||||
"container_count", "network_count", "containers", "networks"):
|
||||
assert key in data
|
||||
assert data["container_count"] == 2
|
||||
assert len(data["containers"]) == 2
|
||||
# No leaks in the wire response either.
|
||||
blob = r.text
|
||||
for forbidden in _FORBIDDEN_STRINGS:
|
||||
assert forbidden not in blob, f"leak via endpoint: {forbidden!r}"
|
||||
|
||||
|
||||
def test_federation_topology_endpoint_resilient_when_docker_unavailable(fresh_db, fed_dir, monkeypatch):
|
||||
def boom():
|
||||
raise docker_view.DockerProxyError("proxy down")
|
||||
monkeypatch.setattr(docker_view, "topology", boom)
|
||||
client = TestClient(_mk_app())
|
||||
r = client.get("/federation/topology")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["container_count"] == 0
|
||||
assert data["containers"] == []
|
||||
|
||||
|
||||
def test_federation_topology_cache_short_circuits_repeated_calls(fresh_db, fed_dir, monkeypatch):
|
||||
"""Within TTL, a second hit must not re-call docker_view."""
|
||||
calls = {"n": 0}
|
||||
|
||||
def counted():
|
||||
calls["n"] += 1
|
||||
return _LEAKY_TOPOLOGY
|
||||
|
||||
monkeypatch.setattr(docker_view, "topology", counted)
|
||||
client = TestClient(_mk_app())
|
||||
r1 = client.get("/federation/topology")
|
||||
r2 = client.get("/federation/topology")
|
||||
assert r1.status_code == 200 and r2.status_code == 200
|
||||
assert calls["n"] == 1, "cache should suppress the second docker_view call"
|
||||
Reference in New Issue
Block a user