Compare commits

...

7 Commits

9 changed files with 1506 additions and 4 deletions

View File

@@ -13,7 +13,7 @@ import httpx
import typer
from psyc import db, log
from psyc.lines import discovery, federation, pulse, translog
from psyc.lines import discovery, federation, network_view, pulse, 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."""

View File

@@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Red
from fastapi.templating import Jinja2Templates
from psyc import db, log
from psyc.lines import discovery, federation, pulse, translog
from psyc.lines import discovery, federation, network_view, pulse, 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")
@@ -300,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)

View File

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

View File

@@ -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 =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 = '<p class="topo-detail-empty">Click any node in the graph above to inspect it.</p>';
}
function countEdges(fp) {
let vouchOut = 0, vouchIn = 0, signalsIn = 0, knows = 0;
for (const e of edges) {
if (e.kind === "vouch" && e.source === fp) vouchOut++;
else if (e.kind === "vouch" && e.target === fp) vouchIn++;
else if (e.kind === "signal" && e.target === fp) signalsIn += e.weight;
else if (e.kind === "signal" && e.source === fp) signalsIn += e.weight;
else if (e.kind === "knows" && (e.source === fp || e.target === fp)) knows++;
}
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 = `<span class="state-badge fn-status-badge-${esc(n.status)}">${esc(n.status)}</span>`;
const jumpBack = (!n.is_self && n.domain)
? `<p style="margin-top:10px;"><a href="/admin/federation" class="td-jump">→ open peer in /admin/federation</a></p>`
: "";
const html = `
<div class="td-head">
<span class="td-kind ${kindCls}">${esc(kindLabel)}</span>
<h3 class="td-title">${esc(n.domain || n.label || n.fp.slice(0, 12))}</h3>
${statusBadge}
<button type="button" class="td-close" aria-label="close">×</button>
</div>
<dl class="td-kv">
<dt>Fingerprint</dt><dd><code>${esc(n.fp)}</code></dd>
<dt>Domain</dt><dd>${n.domain ? esc(n.domain) : "—"}</dd>
<dt>Distance</dt><dd>${esc(n.distance)} hop${n.distance === 1 ? "" : "s"}</dd>
<dt>Vouches</dt><dd>out: ${stats.vouchOut} · in: ${stats.vouchIn}</dd>
<dt>Signals (24h)</dt><dd>${stats.signalsIn ? stats.signalsIn.toFixed(0) : "0"}</dd>
<dt>Knows-edges</dt><dd>${stats.knows}</dd>
</dl>
${jumpBack}
`;
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;
});
}
})();

View File

@@ -5,7 +5,7 @@
// This makes the cockpit installable as a PWA and survives flaky connections,
// without serving stale operational data behind the operator's back.
const CACHE_VERSION = "psyc-v5";
const CACHE_VERSION = "psyc-v6";
const STATIC_ASSETS = [
"/static/cockpit.css",
"/static/psyc-tokens.css",

View File

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

View File

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

View File

@@ -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(),
}

334
tests/test_network_view.py Normal file
View File

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