From fbad78a61175663d57b778a58f39826f3dd23e04 Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Sun, 7 Jun 2026 00:36:29 +0200 Subject: [PATCH 1/6] stage-net-a network view: data model + local view builder --- src/psyc/lines/network_view.py | 423 +++++++++++++++++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 src/psyc/lines/network_view.py diff --git a/src/psyc/lines/network_view.py b/src/psyc/lines/network_view.py new file mode 100644 index 0000000..f33fc14 --- /dev/null +++ b/src/psyc/lines/network_view.py @@ -0,0 +1,423 @@ +"""Network view — federation graph: self + peers + edges (vouches + signals). + +The federation/identity layer (psyc.lines.federation) gives us node identity +and a peer registry; discovery walks DNS-SD; vouching adds the web-of-trust; +the signal buffer records what peers have told us. This module is the +*visualization primitive* — it stitches those into a node+edge graph the +cockpit renders as a force-directed map of the federation we sit in. + +Three views: + * build_local_view() — what we know first-hand: self at center, direct + peers around us, edges = vouches + 24h signals. + * build_transitive_view() — local_view plus peers-of-peers, fetched from + each trusted peer's /federation/network endpoint. + Cached aggressively to avoid spamming peers. + * build_public_view() — JSON-safe payload to publish at /federation/network. + Only TRUSTED peers + our outbound vouches; signed. +""" + +from __future__ import annotations + +import base64 +import json +import threading +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Tuple + +import httpx +from pydantic import BaseModel, Field + +from psyc import db, log +from psyc.lines import federation + + +_log = log.get(__name__) + + +SIGNAL_WINDOW_HOURS = 24 +TRANSITIVE_CACHE_TTL = 300.0 # 5 minutes +TRANSITIVE_FETCH_TIMEOUT = 4.0 + + +# ---------- data model -------------------------------------------------- + +class NetworkNode(BaseModel): + """One vertex on the federation map. + + `distance` is the topological hop count from self: 0 for self, 1 for + directly-registered peers, 2 for peers-of-peers discovered via the + transitive fetch. `status` is the trust label the UI colors by. + """ + fingerprint: str + domain: Optional[str] = None + label: str + status: str # "self" | "trusted" | "vouched" | "unknown" | "blocked" + is_self: bool = False + distance: int = 1 + + +class NetworkEdge(BaseModel): + """One edge on the federation map. + + `kind` drives stroke style in the UI: vouch = solid, signal = dashed + flow with thickness ∝ weight, knows = dotted grey transitive hint. + """ + source_fingerprint: str + target_fingerprint: str + kind: str # "vouch" | "signal" | "knows" + weight: float = 1.0 + label: str = "" + bidirectional: bool = False + + +class NetworkView(BaseModel): + """Renderable snapshot — nodes + edges + headline stats.""" + nodes: List[NetworkNode] = Field(default_factory=list) + edges: List[NetworkEdge] = Field(default_factory=list) + generated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + stats: Dict[str, Any] = Field(default_factory=dict) + + +# ---------- internal helpers -------------------------------------------- + +def _short_fp(fp: str) -> str: + if len(fp) >= 16: + return f"{fp[:8]}…{fp[-8:]}" + return fp + + +def _peer_status_label(peer: federation.Peer) -> str: + """Map peers.status + vouch-eligibility into the UI status taxonomy.""" + if peer.status == "trusted": + return "trusted" + if peer.status == "blocked": + return "blocked" + # unknown — but if a quorum of trusted peers have vouched, surface as "vouched" + if federation.peer_is_listening_eligible(peer.fingerprint): + return "vouched" + return "unknown" + + +def _signal_counts_24h() -> Tuple[Dict[str, int], int, int]: + """Per-peer signal count over the last `SIGNAL_WINDOW_HOURS`, plus totals. + + Returns (counts_by_fingerprint, total_buffered, distinct_hashes). + """ + cutoff = (datetime.now(timezone.utc) - timedelta(hours=SIGNAL_WINDOW_HOURS)).isoformat() + counts: Dict[str, int] = {} + distinct_hashes: set = set() + total = 0 + # recent_signals gives newest first; one call is enough for a 24h window + # under any realistic volume — the buffer is small. + for row in db.recent_signals(limit=10_000): + received = str(row.get("received_at") or "") + if received < cutoff: + break + fp = row.get("peer_fingerprint") or "" + if not fp: + continue + counts[fp] = counts.get(fp, 0) + 1 + distinct_hashes.add(row.get("signal_hash") or "") + total += 1 + return counts, total, len(distinct_hashes) + + +# ---------- local view -------------------------------------------------- + +def build_local_view() -> NetworkView: + """Everything we know first-hand — self + direct peers + their edges to us. + + Self sits at distance 0. Each row from `db.list_peers()` becomes a + distance-1 node, with `status` derived from `peers.status` (falling + back to "vouched" if a vouch quorum names them) or "blocked". + + Edges: + * self -> peer vouch — for every peer we've issued a vouch for + * peer -> self vouch — for every received vouch naming our fp + * self -> peer signal — one per peer that has sent us ≥1 signal in 24h, + weight = count, label = "N signals" + + Stats line up with what the admin page header shows. + """ + our_fp = federation.node_fingerprint() + peers = federation.list_peers() + nodes: List[NetworkNode] = [ + NetworkNode( + fingerprint=our_fp, + label="self", + status="self", + is_self=True, + distance=0, + ) + ] + peer_by_fp: Dict[str, federation.Peer] = {} + for p in peers: + if p.fingerprint == our_fp: + # Defensive — should never happen, but don't double-render self. + continue + peer_by_fp[p.fingerprint] = p + nodes.append(NetworkNode( + fingerprint=p.fingerprint, + domain=p.domain, + label=p.domain or _short_fp(p.fingerprint), + status=_peer_status_label(p), + is_self=False, + distance=1, + )) + + edges: List[NetworkEdge] = [] + + # Vouches WE have issued — self -> target. + out_vouch_targets: set = set() + for v in federation.our_vouches(): + # Only render an edge if the target is in our peer registry + # OR is the federation broadcast target — but for the local map we + # only show edges to nodes we render, so skip strangers. + if v.target_fingerprint not in peer_by_fp: + continue + out_vouch_targets.add(v.target_fingerprint) + edges.append(NetworkEdge( + source_fingerprint=our_fp, + target_fingerprint=v.target_fingerprint, + kind="vouch", + weight=1.0, + label="vouched", + )) + + # Vouches received — non-self vouches where target == our fp. + for v in federation.vouches_for(our_fp): + if v.voucher_fingerprint == our_fp: + continue + if v.voucher_fingerprint not in peer_by_fp: + # Voucher not in our peer table → no node to attach to. Skip; + # the local map is honest about what's renderable. + continue + # Mark bidirectional on the existing outbound vouch when applicable so + # the UI can collapse the two lines. + existing = next( + (e for e in edges + if e.kind == "vouch" + and e.source_fingerprint == our_fp + and e.target_fingerprint == v.voucher_fingerprint), + None, + ) + if existing is not None: + existing.bidirectional = True + existing.label = "vouched ↔" + continue + edges.append(NetworkEdge( + source_fingerprint=v.voucher_fingerprint, + target_fingerprint=our_fp, + kind="vouch", + weight=1.0, + label="vouches us", + )) + + # Signal edges — one per peer that has reported anything in 24h. + sig_counts, total_signals, distinct_hashes = _signal_counts_24h() + for fp, count in sig_counts.items(): + if fp not in peer_by_fp: + # Signals only land from listening-eligible peers, but if the + # operator just demoted a peer the buffer can still contain + # rows referencing a now-deleted peer. Skip cleanly. + continue + edges.append(NetworkEdge( + source_fingerprint=fp, + target_fingerprint=our_fp, + kind="signal", + weight=float(count), + label=f"{count} signals/24h", + )) + + # Quorum-met count over the signals we've seen in the window. + quorum_met = 0 + seen_hashes: set = set() + for row in db.recent_signals(limit=10_000): + h = row.get("signal_hash") or "" + if not h or h in seen_hashes: + continue + seen_hashes.add(h) + if federation.is_quorum_met(h): + quorum_met += 1 + + vouched_peers = sum( + 1 for n in nodes + if not n.is_self and n.status in ("trusted", "vouched") + ) + stats: Dict[str, Any] = { + "total_peers": len(peer_by_fp), + "vouched_peers": vouched_peers, + "signals_buffered_24h": total_signals, + "distinct_signal_hashes_24h": distinct_hashes, + "quorum_met_count": quorum_met, + "vouches_issued": len(out_vouch_targets), + } + + return NetworkView(nodes=nodes, edges=edges, stats=stats) + + +# ---------- transitive view --------------------------------------------- + +_TRANSITIVE_CACHE: Dict[str, Any] = {"ts": 0.0, "view": None} +_TRANSITIVE_LOCK = threading.Lock() + + +def _fetch_peer_network(domain: str, timeout: float = TRANSITIVE_FETCH_TIMEOUT) -> Optional[Dict[str, Any]]: + """GET /federation/network on a peer. Returns dict on success, None otherwise. + + Failure modes are logged at info — one slow/broken peer must NEVER abort + the transitive walk. + """ + if not domain: + return None + url = f"https://{domain}/federation/network" + 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 — every failure mode is "skip this peer" + _log.info("network_view.transitive.skip", domain=domain, reason=str(exc)[:120]) + return None + if not isinstance(data, dict): + _log.info("network_view.transitive.bad_shape", domain=domain, kind=type(data).__name__) + return None + return data + + +def build_transitive_view(force_refresh: bool = False) -> NetworkView: + """Local view + distance-2 nodes pulled from each trusted peer. + + Cached for `TRANSITIVE_CACHE_TTL` seconds — peer fan-out is expensive. + Cache is opt-out via `force_refresh` (the admin "re-fetch" button). + """ + now = time.time() + if not force_refresh: + with _TRANSITIVE_LOCK: + cached = _TRANSITIVE_CACHE.get("view") + cached_ts = float(_TRANSITIVE_CACHE.get("ts") or 0.0) + if cached is not None and (now - cached_ts) < TRANSITIVE_CACHE_TTL: + return cached + + view = build_local_view() + our_fp = view.nodes[0].fingerprint + existing_fps: set = {n.fingerprint for n in view.nodes} + + # Index direct peers by domain so we can attribute distance=2 to a parent. + direct_by_fp: Dict[str, NetworkNode] = { + n.fingerprint: n for n in view.nodes if not n.is_self + } + direct_trusted = [n for n in direct_by_fp.values() if n.status == "trusted" and n.domain] + + transitive_count = 0 + for parent in direct_trusted: + data = _fetch_peer_network(parent.domain or "") + if data is None: + continue + # The peer's payload mirrors build_public_view's shape: + # {fingerprint, peers: [{fingerprint, domain}], vouches: [{voucher, target}]}. + 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 == our_fp: + continue + if fp in existing_fps: + # Already a direct peer (or our self) — but still draw the + # "knows" edge from parent to it to express the topology. + view.edges.append(NetworkEdge( + source_fingerprint=parent.fingerprint, + target_fingerprint=fp, + kind="knows", + weight=0.5, + label="knows", + )) + continue + domain = str(pp.get("domain") or "") or None + view.nodes.append(NetworkNode( + fingerprint=fp, + domain=domain, + label=domain or _short_fp(fp), + status="unknown", + is_self=False, + distance=2, + )) + existing_fps.add(fp) + transitive_count += 1 + view.edges.append(NetworkEdge( + source_fingerprint=parent.fingerprint, + target_fingerprint=fp, + kind="knows", + weight=0.5, + label="knows", + )) + + view.stats["transitive_nodes"] = transitive_count + + with _TRANSITIVE_LOCK: + _TRANSITIVE_CACHE["view"] = view + _TRANSITIVE_CACHE["ts"] = now + return view + + +# ---------- public payload ---------------------------------------------- + +def build_public_view() -> Dict[str, Any]: + """JSON-safe public payload returned from GET /federation/network. + + Only TRUSTED peers leak — never unknown or blocked. Vouches we've + issued ride along so peers can reconstruct the WoT shape. The whole + payload is signed with our Ed25519 key (canonical-JSON, then base64). + + Signal hashes are deliberately omitted — those are internal state and + would leak who's reporting what to whom. + """ + our_fp = federation.node_fingerprint() + trusted_peers: List[Dict[str, Any]] = [] + for p in federation.list_peers(): + if p.status != "trusted": + continue + trusted_peers.append({ + "domain": p.domain, + "fingerprint": p.fingerprint, + "first_seen": p.discovered_at, + }) + vouches: List[Dict[str, Any]] = [] + for v in federation.our_vouches(): + vouches.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, + }) + + payload: Dict[str, Any] = { + "version": federation.FEED_VERSION, + "fingerprint": our_fp, + "generated_at": datetime.now(timezone.utc).isoformat(), + "peers": trusted_peers, + "vouches": vouches, + } + sig = federation.sign_payload(federation.canonical_json(payload)) + payload["signature"] = base64.b64encode(sig).decode("ascii") + return payload + + +# ---------- admin-only payload (data endpoint) -------------------------- + +def build_admin_view(include_transitive: bool = True) -> Dict[str, Any]: + """Full view for the admin data endpoint — JSON-serializable. + + Unlike `build_public_view`, this DOES include unknown + blocked peers + and recent signal hashes — it's only ever served behind admin auth. + """ + view = build_transitive_view() if include_transitive else build_local_view() + return { + "self_fingerprint": view.nodes[0].fingerprint, + "nodes": [n.model_dump() for n in view.nodes], + "edges": [e.model_dump() for e in view.edges], + "stats": view.stats, + "generated_at": view.generated_at.isoformat(), + } From 6dcaae39c3fedb79f87a920d3987d52a72ceee02 Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Sun, 7 Jun 2026 00:37:12 +0200 Subject: [PATCH 2/6] stage-net-b network view: public endpoint + signed payload --- src/psyc/cockpit/federation_routes.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/psyc/cockpit/federation_routes.py b/src/psyc/cockpit/federation_routes.py index 90bdcb0..c1adab6 100644 --- a/src/psyc/cockpit/federation_routes.py +++ b/src/psyc/cockpit/federation_routes.py @@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Red from fastapi.templating import Jinja2Templates from psyc import db, log -from psyc.lines import discovery, federation, pulse, translog +from psyc.lines import discovery, federation, network_view, pulse, translog from psyc.result import Err @@ -30,6 +30,10 @@ _FEED_TTL = 60.0 _PUBLIC_PEERS_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None} _PUBLIC_PEERS_TTL = 60.0 +# And again for the public federation-network payload (signed JSON view). +_PUBLIC_NETWORK_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None} +_PUBLIC_NETWORK_TTL = 60.0 + def _admin_ok(request: Request) -> bool: return bool(request.session.get("admin_ok")) @@ -51,6 +55,14 @@ def _cached_public_peers() -> Any: return _PUBLIC_PEERS_CACHE["payload"] +def _cached_public_network() -> Dict[str, Any]: + now = time.time() + if _PUBLIC_NETWORK_CACHE["payload"] is None or (now - _PUBLIC_NETWORK_CACHE["ts"]) > _PUBLIC_NETWORK_TTL: + _PUBLIC_NETWORK_CACHE["payload"] = network_view.build_public_view() + _PUBLIC_NETWORK_CACHE["ts"] = now + return _PUBLIC_NETWORK_CACHE["payload"] + + def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None: """Mount all federation routes onto `app`.""" @@ -192,6 +204,16 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None: """ return JSONResponse(_cached_public_peers()) + @app.get("/federation/network") + def federation_network_public() -> JSONResponse: + """Signed federation-network attestation — for transitive-view fetchers. + + Mirrors /federation/peers/public in spirit but adds our outbound vouches + so a fetcher can reconstruct the local web-of-trust shape. Trusted peers + only — never unknown or blocked. Signal hashes are deliberately omitted. + """ + return JSONResponse(_cached_public_network()) + # ---------- public vouches + transparency log -------------------- @app.get("/federation/vouches") From 5ff6d80333ed74ed52e58d037fe34f02817290af Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Sun, 7 Jun 2026 00:37:32 +0200 Subject: [PATCH 3/6] stage-net-c network view: transitive fetcher + admin data endpoint --- src/psyc/cockpit/federation_routes.py | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/psyc/cockpit/federation_routes.py b/src/psyc/cockpit/federation_routes.py index c1adab6..02c9a96 100644 --- a/src/psyc/cockpit/federation_routes.py +++ b/src/psyc/cockpit/federation_routes.py @@ -322,6 +322,38 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None: }, ) + # ---------- admin: federation network view ---------------------- + + @app.get("/admin/federation/network", response_class=HTMLResponse) + def admin_federation_network(request: Request) -> HTMLResponse: + """Cockpit page — force-directed federation map. Data lives at /data.""" + if not _admin_ok(request): + return RedirectResponse("/admin", status_code=303) + # Build local stats up front so the header card renders even if the + # JS data-endpoint fetch fails (defensive — never give the operator a + # blank page). + view = network_view.build_local_view() + return TEMPLATES.TemplateResponse( + request, + "admin_federation_network.html", + { + "fingerprint": federation.node_fingerprint(), + "stats": view.stats, + }, + ) + + @app.get("/admin/federation/network/data") + def admin_federation_network_data(request: Request) -> JSONResponse: + """Full admin view — includes unknown/blocked peers + transitive peers. + + Public /federation/network filters those out; this surface does not, + because it sits behind the admin gate and the operator needs to see + the real shape of the federation including the parts being ignored. + """ + if not _admin_ok(request): + raise HTTPException(status_code=403, detail="admin session required") + return JSONResponse(network_view.build_admin_view(include_transitive=True)) + # ---------- admin: quorum config + per-peer/per-hash view ------- @app.get("/admin/federation/quorum", response_class=HTMLResponse) From 5950d34debe01f4b49b91c1b881294db561741dc Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Sun, 7 Jun 2026 00:40:13 +0200 Subject: [PATCH 4/6] stage-net-d network view: cockpit page + JS force-directed graph --- src/psyc/cockpit/static/cockpit.css | 78 +++ src/psyc/cockpit/static/federation_network.js | 510 ++++++++++++++++++ src/psyc/cockpit/static/sw.js | 2 +- .../cockpit/templates/admin_federation.html | 2 +- .../templates/admin_federation_network.html | 51 ++ 5 files changed, 641 insertions(+), 2 deletions(-) create mode 100644 src/psyc/cockpit/static/federation_network.js create mode 100644 src/psyc/cockpit/templates/admin_federation_network.html diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css index 920ceb9..35125f7 100644 --- a/src/psyc/cockpit/static/cockpit.css +++ b/src/psyc/cockpit/static/cockpit.css @@ -1166,3 +1166,81 @@ body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr .stat-country { border-color: rgba(251,191,36,0.4); color: var(--amber); } .stat-confidence { border-color: rgba(167,139,250,0.4); color: #c4b5fd; } .stat-family { border-color: rgba(248,113,113,0.4); color: var(--red); } + +/* ── federation network graph ──────────────────────────────── */ +.fn-stats { display: flex; flex-wrap: wrap; gap: 10px; margin: 8px 0 18px; } +.fn-stat { + flex: 1; min-width: 120px; + background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; + padding: 10px 14px; +} +.fn-stat-label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; } +.fn-stat-value { font-family: var(--font-display); font-size: 22px; font-weight: 700; color: var(--accent); margin-top: 4px; text-shadow: 0 0 12px var(--accent-glow); } + +#federation-network-graph { display: block; width: 100%; height: 620px; cursor: grab; } +#federation-network-graph:active { cursor: grabbing; } + +body.wide #federation-network-graph { height: 720px; } + +.fn-edge { stroke: rgba(125,133,151,0.45); stroke-width: 1.2; fill: none; } +.fn-edge-label { fill: var(--muted); font-size: 9px; text-anchor: middle; font-family: ui-monospace, Menlo, monospace; opacity: 0.7; pointer-events: none; } + +.fn-kind-vouch .fn-edge { stroke: rgba(74,222,128,0.7); stroke-width: 1.8; } +.fn-kind-vouch .fn-edge-label { fill: rgba(160,240,190,0.85); font-weight: 600; } +.fn-kind-signal .fn-edge { stroke: rgba(30,200,255,0.65); stroke-dasharray: 5 4; } +.fn-kind-signal .fn-edge-label { fill: rgba(170, 220, 255, 0.85); } +.fn-kind-knows .fn-edge { stroke: rgba(125,133,151,0.32); stroke-dasharray: 2 4; } +.fn-kind-knows .fn-edge-label { display: none; } + +.fn-edge.alive { animation: fn-flow 1.6s linear infinite; } +.fn-edge.dim { opacity: 0.55; } +@keyframes fn-flow { to { stroke-dashoffset: -54; } } +#federation-network-graph.flow-off .fn-edge.alive { animation: none; } +@media (prefers-reduced-motion: reduce) { .fn-edge.alive { animation: none; } } + +.fn-node { cursor: grab; } +.fn-node.dragging { cursor: grabbing; } +.fn-node circle, .fn-node rect { transition: filter 0.15s; } +.fn-node:hover circle, .fn-node:hover rect { filter: drop-shadow(0 0 10px var(--accent)); } + +/* Self — accent-glowing rounded square. */ +.fn-self rect { + fill: rgba(30,200,255,0.18); stroke: var(--accent); stroke-width: 2; + filter: drop-shadow(0 0 14px var(--accent-glow)); +} +.fn-self .fn-label { fill: var(--accent); font-weight: 700; letter-spacing: 0.10em; font-size: 13px; } + +/* Direct peers (distance=1). Status drives color. */ +.fn-status-trusted circle { fill: rgba(74,222,128,0.12); stroke: var(--green); stroke-width: 2; } +.fn-status-vouched circle { fill: rgba(167,139,250,0.12); stroke: #a78bfa; stroke-width: 1.8; stroke-dasharray: 4 3; } +.fn-status-unknown circle { fill: rgba(125,133,151,0.10); stroke: var(--muted); stroke-width: 1.6; } +.fn-status-blocked circle { fill: rgba(248,113,113,0.10); stroke: var(--red); stroke-width: 1.6; } + +/* Transitive (distance=2) — fade and shrink the stroke. */ +.fn-distance-2 circle { opacity: 0.78; stroke-width: 1.2; } +.fn-distance-2 .fn-label { fill: var(--muted); font-size: 9.5px; } +.fn-distance-2 .fn-sublabel { display: none; } + +.fn-label, .fn-sublabel { text-anchor: middle; pointer-events: none; font-family: var(--font-display); } +.fn-label { fill: var(--text); font-size: 11px; } +.fn-sublabel { fill: var(--muted); font-size: 9px; font-family: ui-monospace, Menlo, monospace; letter-spacing: 0.04em; } + +.fn-node.selected circle, .fn-node.selected rect { + filter: drop-shadow(0 0 14px var(--accent)); +} +.fn-node.selected .fn-label { fill: #eaf6ff; font-weight: 700; } + +/* Legend swatches. */ +.lg-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 50%; border: 1.5px solid; vertical-align: -1px; } +.fn-lg-self { border-color: var(--accent); background: rgba(30,200,255,0.18); } +.fn-lg-trusted { border-color: var(--green); background: rgba(74,222,128,0.18); } +.fn-lg-vouched { border-color: #a78bfa; background: rgba(167,139,250,0.18); } +.fn-lg-unknown { border-color: var(--muted); background: rgba(125,133,151,0.18); } +.fn-lg-blocked { border-color: var(--red); background: rgba(248,113,113,0.18); } + +/* Detail status badge tinting. */ +.fn-status-badge-self { color: var(--accent); border-color: var(--accent); background: rgba(30,200,255,0.10); } +.fn-status-badge-trusted { color: var(--green); border-color: var(--green); background: rgba(74,222,128,0.10); } +.fn-status-badge-vouched { color: #c4b5fd; border-color: #a78bfa; background: rgba(167,139,250,0.10); } +.fn-status-badge-unknown { color: var(--muted); border-color: var(--muted); } +.fn-status-badge-blocked { color: var(--red); border-color: var(--red); background: rgba(248,113,113,0.10); } diff --git a/src/psyc/cockpit/static/federation_network.js b/src/psyc/cockpit/static/federation_network.js new file mode 100644 index 0000000..2c1dbda --- /dev/null +++ b/src/psyc/cockpit/static/federation_network.js @@ -0,0 +1,510 @@ +/* psyc — federation network force-directed graph. + * + * Self at center, direct peers around it, transitive peers (distance=2) on + * the outer ring. Edges: vouch (solid), signal (dashed, animated, thickness + * ∝ weight), knows (dotted grey). + * + * Pure vanilla JS, no deps. Mirrors topology.js's proven force-sim loop so + * the two pages feel familiar; once both are stable the shared engine can + * factor out into a force_graph.js module — for now, a copy keeps the diff + * narrow. + */ + +(function () { + "use strict"; + + const svg = document.getElementById("federation-network-graph"); + const detailEl = document.getElementById("fn-detail"); + const loadingEl = document.getElementById("fn-loading"); + const errorEl = document.getElementById("fn-error"); + const transitiveCountEl = document.getElementById("fn-transitive-count"); + if (!svg) return; + + // Fetch data, then build the graph. The /data endpoint includes transitive + // peers (mid-cost cached server-side at 5 min TTL). + fetch("/admin/federation/network/data", { credentials: "same-origin" }) + .then(r => { + if (!r.ok) throw new Error("HTTP " + r.status); + 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 network data: " + err.message; + } + }); + + function render(data) { + const selfFp = data.self_fingerprint || ""; + const nodesData = data.nodes || []; + const edgesData = data.edges || []; + + if (transitiveCountEl) { + const n = nodesData.filter(n => (n.distance || 0) >= 2).length; + transitiveCountEl.textContent = String(n); + } + + // ---------- build node + edge sim objects ----------------------------- + const nodes = []; + const nodeByFp = Object.create(null); + for (const nd of nodesData) { + const isSelf = !!nd.is_self; + const dist = Number(nd.distance || 0); + const r = isSelf ? 38 : (dist >= 2 ? 9 : 16); + const n = { + id: nd.fingerprint, + fp: nd.fingerprint, + domain: nd.domain || "", + label: nd.label || nd.fingerprint.slice(0, 8), + status: nd.status || "unknown", + is_self: isSelf, + distance: dist, + r, + x: 0, y: 0, vx: 0, vy: 0, fixed: false, + tooltip: buildTooltip(nd), + }; + nodes.push(n); + nodeByFp[n.id] = n; + } + + const edges = []; + for (const ed of edgesData) { + const a = nodeByFp[ed.source_fingerprint]; + const b = nodeByFp[ed.target_fingerprint]; + if (!a || !b) continue; + edges.push({ + source: a.id, + target: b.id, + kind: ed.kind || "knows", + weight: Number(ed.weight || 1), + label: ed.label || "", + bidirectional: !!ed.bidirectional, + }); + } + + function buildTooltip(nd) { + const lines = []; + lines.push((nd.is_self ? "self · " : "") + (nd.domain || nd.label || nd.fingerprint)); + lines.push("fp: " + nd.fingerprint); + lines.push("status: " + (nd.status || "unknown")); + lines.push("distance: " + (nd.distance || 0)); + return lines.join("\n"); + } + + // ---------- viewport + seeding --------------------------------------- + function viewport() { + const W = svg.clientWidth || 900; + 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; + }); + })(); + + // Force-sim params — same shape as topology.js, slightly softer springs + // so the two rings settle visibly. + const REPULSION = 1500; + const SPRING_K = 0.035; + const SPRING_REST_BASE = 110; + 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 = nodeByFp[e.source], b = nodeByFp[e.target]; + if (!a || !b) continue; + // "knows" edges (distance-2) rest longer so transitive bands stay clear. + 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)); + } + } + + // Pre-settle so the first frame isn't a glob. + for (let i = 0; i < 280; i++) tick(); + + // ---------- render ---------------------------------------------------- + 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); + const ln = document.createElementNS(ns, "line"); + ln.setAttribute("class", "fn-edge"); + // Signal weight controls stroke width; cap at 5px so a noisy peer + // doesn't blot out the layout. + if (e.kind === "signal") { + const w = Math.min(5, 1 + Math.log2(e.weight + 1) * 0.8); + ln.setAttribute("stroke-width", w.toFixed(2)); + } + 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") }; + }); + + 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; + + if (n.is_self) { + const sz = n.r; + const rect = document.createElementNS(ns, "rect"); + rect.setAttribute("x", -sz); rect.setAttribute("y", -sz); + rect.setAttribute("width", sz * 2); rect.setAttribute("height", sz * 2); + rect.setAttribute("rx", 10); rect.setAttribute("ry", 10); + g.appendChild(rect); + } else { + const c = document.createElementNS(ns, "circle"); + c.setAttribute("r", n.r); + g.appendChild(c); + } + + 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); + } + + const title = document.createElementNS(ns, "title"); + title.textContent = n.tooltip; + g.appendChild(title); + + nodesG.appendChild(g); + return g; + }); + + function paint() { + for (let i = 0; i < edges.length; i++) { + const e = edges[i]; + const a = nodeByFp[e.source], b = nodeByFp[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(); + + // ---------- drag + click -------------------------------------------- + let dragging = null, dragOffset = { x: 0, y: 0 }; + let pressedNode = null, pressedAt = null, moved = false; + function svgPoint(clientX, clientY) { + const pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY; + return pt.matrixTransform(svg.getScreenCTM().inverse()); + } + nodeEls.forEach((g, i) => { + g.addEventListener("mousedown", ev => { + ev.preventDefault(); + pressedNode = nodes[i]; + pressedAt = { x: ev.clientX, y: ev.clientY }; + moved = false; + dragging = nodes[i]; + 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"); + }); + }); + 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; + }); + svg.addEventListener("click", ev => { + if (!ev.target.closest(".fn-node")) clearSelection(); + }); + + // ---------- detail panel -------------------------------------------- + function esc(s) { + return String(s == null ? "" : s).replace(/[&<>"']/g, c => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + } + + function selectNode(n) { + nodeEls.forEach(el => el.classList.remove("selected")); + const me = nodesG.querySelector(`[data-fp="${CSS.escape(n.fp)}"]`); + if (me) me.classList.add("selected"); + renderDetail(n); + } + + function clearSelection() { + nodeEls.forEach(el => el.classList.remove("selected")); + if (detailEl) detailEl.innerHTML = '

Click any node in the graph above to inspect it.

'; + } + + function countEdges(fp) { + let vouchOut = 0, vouchIn = 0, signalsIn = 0, knows = 0; + for (const e of edges) { + if (e.kind === "vouch" && e.source === fp) vouchOut++; + else if (e.kind === "vouch" && e.target === fp) vouchIn++; + else if (e.kind === "signal" && e.target === fp) signalsIn += e.weight; + else if (e.kind === "signal" && e.source === fp) signalsIn += e.weight; + else if (e.kind === "knows" && (e.source === fp || e.target === fp)) knows++; + } + return { vouchOut, vouchIn, signalsIn, knows }; + } + + function renderDetail(n) { + if (!detailEl) return; + const stats = countEdges(n.fp); + const kindLabel = n.is_self ? "SELF" : (n.distance >= 2 ? "TRANSITIVE" : "DIRECT PEER"); + const kindCls = n.is_self ? "td-kind-host" : (n.distance >= 2 ? "td-kind-cont" : "td-kind-net"); + const statusBadge = `${esc(n.status)}`; + const jumpBack = (!n.is_self && n.domain) + ? `

→ open peer in /admin/federation

` + : ""; + const html = ` +
+ ${esc(kindLabel)} +

${esc(n.domain || n.label || n.fp.slice(0, 12))}

+ ${statusBadge} + +
+
+
Fingerprint
${esc(n.fp)}
+
Domain
${n.domain ? esc(n.domain) : "—"}
+
Distance
${esc(n.distance)} hop${n.distance === 1 ? "" : "s"}
+
Vouches
out: ${stats.vouchOut} · in: ${stats.vouchIn}
+
Signals (24h)
${stats.signalsIn ? stats.signalsIn.toFixed(0) : "0"}
+
Knows-edges
${stats.knows}
+
+ ${jumpBack} + `; + detailEl.innerHTML = html; + detailEl.classList.add("has-selection"); + const close = detailEl.querySelector(".td-close"); + if (close) close.addEventListener("click", clearSelection); + detailEl.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + + // ---------- idle animation ------------------------------------------ + let energyBudget = 40; + 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 ----------------------------- + // Signal edges always flow (we just saw N signals in 24h). Vouch edges + // are static. Knows edges fade. + edges.forEach((e, i) => { + const ln = edgeEls[i].line; + if (e.kind === "signal") ln.classList.add("alive"); + 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"; + // Force-mode bootstraps with self pinned at center — so the very first + // settle radiates outward naturally. + const selfNode = nodes.find(n => n.is_self); + if (selfNode) { selfNode.x = W / 2; selfNode.y = H / 2; selfNode.fixed = true; } + + 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. + 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; + }); + } +})(); diff --git a/src/psyc/cockpit/static/sw.js b/src/psyc/cockpit/static/sw.js index 32c8dc1..61c9ee9 100644 --- a/src/psyc/cockpit/static/sw.js +++ b/src/psyc/cockpit/static/sw.js @@ -5,7 +5,7 @@ // This makes the cockpit installable as a PWA and survives flaky connections, // without serving stale operational data behind the operator's back. -const CACHE_VERSION = "psyc-v5"; +const CACHE_VERSION = "psyc-v6"; const STATIC_ASSETS = [ "/static/cockpit.css", "/static/psyc-tokens.css", diff --git a/src/psyc/cockpit/templates/admin_federation.html b/src/psyc/cockpit/templates/admin_federation.html index d201be0..58e8fbe 100644 --- a/src/psyc/cockpit/templates/admin_federation.html +++ b/src/psyc/cockpit/templates/admin_federation.html @@ -8,7 +8,7 @@ {{ peers|length }} peer{{ '' if peers|length == 1 else 's' }}

This node's Ed25519 identity. The fingerprint goes into a DNS TXT record so other psyc nodes can discover this one. The public key lets them verify any feed we publish — the private key never leaves this box.

-

← back to admin  ·  discovery  ·  vouches  ·  quorum config  ·  transparency log

+

← back to admin  ·  discovery  ·  vouches  ·  quorum config  ·  transparency log  ·  network

node fingerprint
diff --git a/src/psyc/cockpit/templates/admin_federation_network.html b/src/psyc/cockpit/templates/admin_federation_network.html new file mode 100644 index 0000000..98487cf --- /dev/null +++ b/src/psyc/cockpit/templates/admin_federation_network.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}Federation network — psyc admin{% endblock %} +{% block body_class %}wide{% endblock %} +{% block content %} +
+
+

Federation Network

+ {{ stats.total_peers }} direct · transitive +
+

Force-directed map of the federation this node sits inside. Self at the center, directly-registered peers at distance 1, peers-of-peers (fetched from each trusted peer's /federation/network) at distance 2. Edges: vouches (solid), signals (dashed, thickness ∝ 24h volume), knows (dotted grey).

+

← federation hub  ·  discovery  ·  vouches  ·  quorum  ·  log

+ +
+
direct peers
{{ stats.total_peers }}
+
vouched / trusted
{{ stats.vouched_peers }}
+
vouches issued
{{ stats.vouches_issued }}
+
signals (24h)
{{ stats.signals_buffered_24h }}
+
distinct hashes
{{ stats.distinct_signal_hashes_24h }}
+
quorum-met
{{ stats.quorum_met_count }}
+
+ +
+
+
+ + + +
+ + self + trusted + vouched + unknown + blocked + + drag · scroll to zoom +
+ +
loading federation network…
+ +
+ +
+

Click any node in the graph above to inspect it.

+
+ +

Self fingerprint: {{ fingerprint }}

+
+ + +{% endblock %} From ff44e9e4507176bed68f742487b30698ea1b8392 Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Sun, 7 Jun 2026 00:40:43 +0200 Subject: [PATCH 5/6] stage-net-e network view: CLI fed-network command --- src/psyc/_federation_cli.py | 54 ++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/psyc/_federation_cli.py b/src/psyc/_federation_cli.py index e36bfd7..b51223c 100644 --- a/src/psyc/_federation_cli.py +++ b/src/psyc/_federation_cli.py @@ -13,7 +13,7 @@ import httpx import typer from psyc import db, log -from psyc.lines import discovery, federation, pulse, translog +from psyc.lines import discovery, federation, network_view, pulse, translog from psyc.result import Err, Ok @@ -274,6 +274,58 @@ def register(typer_app: typer.Typer) -> None: f" id={e.id:5d} {e.entry_type:6s} {e.timestamp[:19]} hash={e.entry_hash[:16]}…" ) + # ---------- network view ------------------------------------------ + + @typer_app.command("fed-network") + def fed_network() -> None: + """Print the local federation network view — nodes, vouches, stats.""" + db.init_db() + view = network_view.build_local_view() + + # Nodes table. + typer.echo("NODES") + typer.echo(f" {'fingerprint':<34} {'label':<32} {'status':<9} dist") + for n in view.nodes: + fp = f"{n.fingerprint[:8]}…{n.fingerprint[-8:]}" if len(n.fingerprint) >= 16 else n.fingerprint + label = (n.label or "")[:30] + typer.echo(f" {fp:<34} {label:<32} {n.status:<9} {n.distance}") + + # Vouches breakdown. + our_fp = view.nodes[0].fingerprint + vouch_out = [e for e in view.edges if e.kind == "vouch" and e.source_fingerprint == our_fp] + vouch_in = [e for e in view.edges if e.kind == "vouch" and e.target_fingerprint == our_fp] + bidir = [e for e in vouch_out if e.bidirectional] + + typer.echo("") + typer.echo("VOUCHES") + if not vouch_out and not vouch_in and not bidir: + typer.echo(" (no vouches)") + else: + for e in vouch_out: + arrow = "↔" if e.bidirectional else "→" + fp = f"{e.target_fingerprint[:8]}…{e.target_fingerprint[-8:]}" + typer.echo(f" us {arrow} {fp}") + for e in vouch_in: + fp = f"{e.source_fingerprint[:8]}…{e.source_fingerprint[-8:]}" + typer.echo(f" {fp} → us") + + # Signal edges. + sig_edges = [e for e in view.edges if e.kind == "signal"] + typer.echo("") + typer.echo("SIGNALS (24h)") + if not sig_edges: + typer.echo(" (no signals)") + else: + for e in sig_edges: + fp = f"{e.source_fingerprint[:8]}…{e.source_fingerprint[-8:]}" + typer.echo(f" from {fp}: {int(e.weight)}") + + # Stats footer. + typer.echo("") + typer.echo("STATS") + for k, v in view.stats.items(): + typer.echo(f" {k:<32} {v}") + @typer_app.command("fed-log-verify") def fed_log_verify() -> None: """Re-walk the chain locally and report verification status.""" From 865be2e239861e8ecdf5ff1822992f54ebfdf76d Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Sun, 7 Jun 2026 00:42:11 +0200 Subject: [PATCH 6/6] stage-net-f network view: tests --- tests/test_network_view.py | 334 +++++++++++++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 tests/test_network_view.py diff --git a/tests/test_network_view.py b/tests/test_network_view.py new file mode 100644 index 0000000..152da47 --- /dev/null +++ b/tests/test_network_view.py @@ -0,0 +1,334 @@ +"""Network view — local + transitive + public payload tests.""" + +from __future__ import annotations + +import base64 +from datetime import datetime, timedelta, timezone +from typing import Any, Dict +from unittest.mock import patch + +import pytest +from sqlalchemy import create_engine + +from psyc import db +from psyc.lines import federation, network_view +from psyc.lines.network_view import ( + NetworkEdge, + NetworkNode, + NetworkView, + build_local_view, + build_public_view, + build_transitive_view, +) + + +# ---------- fixtures ---------------------------------------------------- + +@pytest.fixture +def fresh_db(tmp_path, monkeypatch): + test_db = tmp_path / "test.db" + eng = create_engine(f"sqlite:///{test_db}", future=True) + db._metadata.create_all(eng, checkfirst=True) + monkeypatch.setattr(db, "_engine", eng) + monkeypatch.setattr(db, "DB_PATH", test_db) + yield test_db + + +@pytest.fixture +def fed_dir(tmp_path, monkeypatch): + d = tmp_path / "federation" + monkeypatch.setattr(federation, "FED_DIR", d) + monkeypatch.setattr(federation, "PRIVATE_KEY_PATH", d / "node.key") + monkeypatch.setattr(federation, "PUBLIC_KEY_PATH", d / "node.pub") + yield d + + +@pytest.fixture(autouse=True) +def reset_transitive_cache(monkeypatch): + """Prevent cache bleed between tests.""" + monkeypatch.setattr(network_view, "_TRANSITIVE_CACHE", {"ts": 0.0, "view": None}) + yield + + +def _make_peer_pubkey() -> tuple[str, str]: + """Return (fingerprint, pubkey_pem) for a synthetic peer keypair.""" + import hashlib + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ed25519 + priv = ed25519.Ed25519PrivateKey.generate() + pub = priv.public_key() + pem = pub.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("ascii") + raw = pub.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + fp = hashlib.sha256(raw).digest()[:16].hex() + return fp, pem + + +# ---------- local view -------------------------------------------------- + +def test_local_view_empty_registry_yields_only_self(fresh_db, fed_dir): + view = build_local_view() + assert isinstance(view, NetworkView) + assert len(view.nodes) == 1 + self_node = view.nodes[0] + assert self_node.is_self is True + assert self_node.distance == 0 + assert self_node.status == "self" + assert self_node.fingerprint == federation.node_fingerprint() + assert view.edges == [] + assert view.stats["total_peers"] == 0 + assert view.stats["vouched_peers"] == 0 + assert view.stats["signals_buffered_24h"] == 0 + + +def test_local_view_one_trusted_peer_no_edges(fresh_db, fed_dir): + peer_fp, peer_pem = _make_peer_pubkey() + federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted") + view = build_local_view() + assert len(view.nodes) == 2 + peer_node = next(n for n in view.nodes if not n.is_self) + assert peer_node.fingerprint == peer_fp + assert peer_node.status == "trusted" + assert peer_node.distance == 1 + assert peer_node.domain == "peer.example" + assert view.edges == [] + assert view.stats["total_peers"] == 1 + assert view.stats["vouched_peers"] == 1 + + +def test_local_view_outbound_vouch_creates_edge(fresh_db, fed_dir): + peer_fp, peer_pem = _make_peer_pubkey() + federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted") + federation.issue_vouch(peer_fp, ttl_days=30) + view = build_local_view() + vouch_edges = [e for e in view.edges if e.kind == "vouch"] + assert len(vouch_edges) == 1 + e = vouch_edges[0] + assert e.source_fingerprint == federation.node_fingerprint() + assert e.target_fingerprint == peer_fp + assert view.stats["vouches_issued"] == 1 + + +def test_local_view_inbound_vouch_creates_edge(fresh_db, fed_dir): + """Vouches received that name us as target → peer → self edge.""" + peer_fp, peer_pem = _make_peer_pubkey() + federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted") + # Insert a vouch where peer vouches FOR us, bypassing accept_vouch (which + # we don't need to exercise here — the question is render shape). + our_fp = federation.node_fingerprint() + now = datetime.now(timezone.utc) + db.upsert_vouch(dict( + voucher_fingerprint=peer_fp, + target_fingerprint=our_fp, + issued_at=now.isoformat(), + expires_at=(now + timedelta(days=30)).isoformat(), + signature="x" * 88, + )) + view = build_local_view() + vouch_edges = [e for e in view.edges if e.kind == "vouch"] + assert len(vouch_edges) == 1 + e = vouch_edges[0] + assert e.source_fingerprint == peer_fp + assert e.target_fingerprint == our_fp + + +def test_local_view_bidirectional_vouches_collapse(fresh_db, fed_dir): + peer_fp, peer_pem = _make_peer_pubkey() + federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted") + federation.issue_vouch(peer_fp, ttl_days=30) + # And peer vouches back at us. + our_fp = federation.node_fingerprint() + now = datetime.now(timezone.utc) + db.upsert_vouch(dict( + voucher_fingerprint=peer_fp, + target_fingerprint=our_fp, + issued_at=now.isoformat(), + expires_at=(now + timedelta(days=30)).isoformat(), + signature="x" * 88, + )) + view = build_local_view() + vouch_edges = [e for e in view.edges if e.kind == "vouch"] + assert len(vouch_edges) == 1 + assert vouch_edges[0].bidirectional is True + + +def test_local_view_signal_edge_weight_is_24h_count(fresh_db, fed_dir): + peer_fp, peer_pem = _make_peer_pubkey() + federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted") + now_iso = datetime.now(timezone.utc).isoformat() + # Three signals from this peer within the window. + for i in range(3): + db.record_signal(dict( + peer_fingerprint=peer_fp, + signal_type="ioc", + signal_id=f"1.2.3.{i}", + signal_hash=f"hash-{i}", + received_at=now_iso, + raw_json="{}", + )) + # One stale signal outside the window — must be ignored. + stale = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat() + db.record_signal(dict( + peer_fingerprint=peer_fp, + signal_type="ioc", + signal_id="9.9.9.9", + signal_hash="stale", + received_at=stale, + raw_json="{}", + )) + view = build_local_view() + sig_edges = [e for e in view.edges if e.kind == "signal"] + assert len(sig_edges) == 1 + assert sig_edges[0].weight == 3.0 + assert sig_edges[0].source_fingerprint == peer_fp + assert sig_edges[0].target_fingerprint == federation.node_fingerprint() + assert view.stats["signals_buffered_24h"] == 3 + assert view.stats["distinct_signal_hashes_24h"] == 3 + + +def test_local_view_blocked_peer_renders_with_blocked_status(fresh_db, fed_dir): + fp, pem = _make_peer_pubkey() + federation.register_peer("blocked.example", fp, pem, status="blocked") + view = build_local_view() + peer = next(n for n in view.nodes if not n.is_self) + assert peer.status == "blocked" + + +# ---------- public view + signature round-trip -------------------------- + +def test_public_view_excludes_unknown_and_blocked(fresh_db, fed_dir): + fp_t, pem_t = _make_peer_pubkey() + fp_u, pem_u = _make_peer_pubkey() + fp_b, pem_b = _make_peer_pubkey() + federation.register_peer("trusted.example", fp_t, pem_t, status="trusted") + federation.register_peer("unknown.example", fp_u, pem_u, status="unknown") + federation.register_peer("blocked.example", fp_b, pem_b, status="blocked") + + payload = build_public_view() + fps = {p["fingerprint"] for p in payload["peers"]} + assert fp_t in fps + assert fp_u not in fps + assert fp_b not in fps + + +def test_public_view_signature_round_trip(fresh_db, fed_dir): + fp, pem = _make_peer_pubkey() + federation.register_peer("trusted.example", fp, pem, status="trusted") + federation.issue_vouch(fp, ttl_days=30) + payload = build_public_view() + + assert "signature" in payload + assert payload["fingerprint"] == federation.node_fingerprint() + + sig = base64.b64decode(payload["signature"]) + unsigned = {k: v for k, v in payload.items() if k != "signature"} + assert federation.verify_payload( + federation.canonical_json(unsigned), + sig, + federation.public_key_pem(), + ) is True + + # Vouch we issued is in the payload. + targets = {v["target_fingerprint"] for v in payload["vouches"]} + assert fp in targets + + +def test_public_view_omits_signals(fresh_db, fed_dir): + """Public payload must not leak who's reporting what.""" + fp, pem = _make_peer_pubkey() + federation.register_peer("trusted.example", fp, pem, status="trusted") + db.record_signal(dict( + peer_fingerprint=fp, + signal_type="ioc", + signal_id="1.2.3.4", + signal_hash="secret-hash", + received_at=datetime.now(timezone.utc).isoformat(), + raw_json="{}", + )) + payload = build_public_view() + # No signal-shaped fields anywhere in the payload. + flat = str(payload) + assert "secret-hash" not in flat + assert "signals" not in payload + + +# ---------- transitive view --------------------------------------------- + +def test_transitive_view_adds_distance_2_nodes(fresh_db, fed_dir): + direct_fp, direct_pem = _make_peer_pubkey() + federation.register_peer("direct.example", direct_fp, direct_pem, status="trusted") + # The peer reports two peers of its own. + far_fp_a, _ = _make_peer_pubkey() + far_fp_b, _ = _make_peer_pubkey() + fake_payload: Dict[str, Any] = { + "fingerprint": direct_fp, + "peers": [ + {"fingerprint": far_fp_a, "domain": "far-a.example"}, + {"fingerprint": far_fp_b, "domain": "far-b.example"}, + ], + "vouches": [], + } + with patch.object(network_view, "_fetch_peer_network", return_value=fake_payload): + view = build_transitive_view(force_refresh=True) + + distances = sorted(n.distance for n in view.nodes) + assert 0 in distances and 1 in distances and 2 in distances + transitive_fps = {n.fingerprint for n in view.nodes if n.distance == 2} + assert far_fp_a in transitive_fps + assert far_fp_b in transitive_fps + # "knows" edges from direct peer to each transitive. + knows = [e for e in view.edges if e.kind == "knows"] + assert len(knows) == 2 + assert all(e.source_fingerprint == direct_fp for e in knows) + assert view.stats["transitive_nodes"] == 2 + + +def test_transitive_view_failed_fetch_does_not_abort(fresh_db, fed_dir): + fp_a, pem_a = _make_peer_pubkey() + fp_b, pem_b = _make_peer_pubkey() + federation.register_peer("peer-a.example", fp_a, pem_a, status="trusted") + federation.register_peer("peer-b.example", fp_b, pem_b, status="trusted") + + far_fp, _ = _make_peer_pubkey() + + def fake_fetch(domain, timeout=4.0): + if domain == "peer-a.example": + return None # simulate a fetch failure + return { + "fingerprint": fp_b, + "peers": [{"fingerprint": far_fp, "domain": "far.example"}], + "vouches": [], + } + + with patch.object(network_view, "_fetch_peer_network", side_effect=fake_fetch): + view = build_transitive_view(force_refresh=True) + # Direct nodes both present, transitive only from B. + assert any(n.fingerprint == fp_a for n in view.nodes) + assert any(n.fingerprint == fp_b for n in view.nodes) + assert any(n.fingerprint == far_fp and n.distance == 2 for n in view.nodes) + assert view.stats["transitive_nodes"] == 1 + + +def test_transitive_view_skips_only_unknown_peers(fresh_db, fed_dir): + """Unknown peers shouldn't be queried — fetcher only called for trusted.""" + fp_unknown, pem_u = _make_peer_pubkey() + fp_trusted, pem_t = _make_peer_pubkey() + federation.register_peer("unknown.example", fp_unknown, pem_u, status="unknown") + federation.register_peer("trusted.example", fp_trusted, pem_t, status="trusted") + + calls = [] + + def fake_fetch(domain, timeout=4.0): + calls.append(domain) + return None + + with patch.object(network_view, "_fetch_peer_network", side_effect=fake_fetch): + build_transitive_view(force_refresh=True) + + assert "trusted.example" in calls + assert "unknown.example" not in calls