Compare commits
18 Commits
925bf76a0b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f12e344a8 | ||
|
|
00cd8ca252 | ||
|
|
77e4cb6ab9 | ||
|
|
9ba4cd2189 | ||
|
|
155d6eaaf9 | ||
|
|
d998be276b | ||
|
|
367f17a013 | ||
|
|
a8216d00ef | ||
|
|
8587e079bb | ||
|
|
cef3bcb1ed | ||
|
|
9ab3271bc8 | ||
|
|
c2bd68e246 | ||
|
|
587fd07d38 | ||
|
|
ca6ba83950 | ||
|
|
a10203d8f1 | ||
|
|
56466c334d | ||
|
|
351e16c3ce | ||
|
|
2c7f71eff8 |
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;
|
||||
@@ -1554,3 +1566,326 @@ body.wide #federation-network-graph { height: 720px; }
|
||||
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);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -581,6 +581,28 @@
|
||||
</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>
|
||||
@@ -594,12 +616,29 @@
|
||||
${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);
|
||||
|
||||
@@ -628,6 +667,208 @@
|
||||
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() {
|
||||
|
||||
@@ -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-v7";
|
||||
const CACHE_VERSION = "psyc-v11";
|
||||
const STATIC_ASSETS = [
|
||||
"/static/cockpit.css",
|
||||
"/static/psyc-tokens.css",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<td class="lg-ts">{{ ((m.last_used or '')[:16] | replace('T', ' ')) or '—' }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/members/{{ m.id }}/revoke" class="queue-action"
|
||||
onsubmit="return confirm('Revoke {{ m.label }}? Their codes stop working immediately.');">
|
||||
data-confirm-revoke="member" data-confirm-name="{{ m.label }}">
|
||||
<button type="submit" class="btn btn-reject">revoke</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<button type="submit" class="btn btn-reject" {% if p.status == 'blocked' %}disabled{% endif %}>block</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/federation/peers/{{ p.domain }}/remove" class="queue-action"
|
||||
onsubmit="return confirm('Remove {{ p.domain }}? Their signals will no longer count toward quorum.');">
|
||||
data-confirm-revoke="peer" data-confirm-name="{{ p.domain }}">
|
||||
<button type="submit" class="btn">remove</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
@@ -39,6 +39,21 @@
|
||||
btn.setAttribute("aria-expanded", "false");
|
||||
}));
|
||||
});
|
||||
// data-driven confirms (used by /admin and /admin/federation revoke/remove
|
||||
// buttons; replaces inline onsubmit which was XSS-vulnerable when the
|
||||
// confirm prompt interpolated a member label or peer domain).
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll("form[data-confirm-revoke]").forEach(form => {
|
||||
form.addEventListener("submit", ev => {
|
||||
const kind = form.getAttribute("data-confirm-revoke") || "item";
|
||||
const name = form.getAttribute("data-confirm-name") || "";
|
||||
const msg = kind === "peer"
|
||||
? `Remove ${name}? Their signals will no longer count toward quorum.`
|
||||
: `Revoke ${name}? Their codes stop working immediately.`;
|
||||
if (!confirm(msg)) ev.preventDefault();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<h2>Source</h2>
|
||||
<dl>
|
||||
<dt>Type</dt><dd>{{ case.source_type }}</dd>
|
||||
<dt>Reference</dt><dd>{% if case.source_ref %}<a href="{{ case.source_ref }}" target="_blank" rel="noopener">{{ case.source_ref }}</a>{% else %}—{% endif %}</dd>
|
||||
<dt>Reference</dt><dd>{% if case.source_ref %}{% if case.source_ref.startswith(('http://', 'https://')) %}<a href="{{ case.source_ref }}" target="_blank" rel="noopener">{{ case.source_ref }}</a>{% else %}<code>{{ case.source_ref }}</code>{% endif %}{% else %}—{% endif %}</dd>
|
||||
<dt>Observed</dt><dd class="muted">{{ case.observed_at.strftime('%Y-%m-%d %H:%M UTC') }}</dd>
|
||||
<dt>Ingested</dt><dd class="muted">{{ case.ingested_at.strftime('%Y-%m-%d %H:%M UTC') }}</dd>
|
||||
</dl>
|
||||
|
||||
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
|
||||
|
||||
@@ -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 --------------------------------------------------
|
||||
@@ -773,3 +774,256 @@ def build_admin_view(include_transitive: bool = True) -> Dict[str, Any]:
|
||||
"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
|
||||
@@ -18,6 +18,7 @@ from psyc.lines.network_view import (
|
||||
NetworkNode,
|
||||
NetworkView,
|
||||
build_admin_view,
|
||||
build_explore_view,
|
||||
build_local_view,
|
||||
build_public_view,
|
||||
build_transitive_view,
|
||||
@@ -623,6 +624,49 @@ def test_admin_view_recent_translog_per_peer(fresh_db, fed_dir):
|
||||
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.
|
||||
|
||||
|
||||
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