Compare commits
7 Commits
77533eccb1
...
e33c5b41f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e33c5b41f5 | ||
|
|
865be2e239 | ||
|
|
ff44e9e450 | ||
|
|
5950d34deb | ||
|
|
5ff6d80333 | ||
|
|
6dcaae39c3 | ||
|
|
fbad78a611 |
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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); }
|
||||
|
||||
510
src/psyc/cockpit/static/federation_network.js
Normal file
510
src/psyc/cockpit/static/federation_network.js
Normal 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 =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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;
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -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",
|
||||
|
||||
@@ -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> · <a href="/admin/federation/discovery">discovery</a> · <a href="/admin/federation/vouches">vouches</a> · <a href="/admin/federation/quorum">quorum config</a> · <a href="/admin/federation/log">transparency log</a></p>
|
||||
<p class="back"><a href="/admin">← back to admin</a> · <a href="/admin/federation/discovery">discovery</a> · <a href="/admin/federation/vouches">vouches</a> · <a href="/admin/federation/quorum">quorum config</a> · <a href="/admin/federation/log">transparency log</a> · <a href="/admin/federation/network">network</a></p>
|
||||
|
||||
<div class="card" style="margin-bottom:14px;">
|
||||
<div class="lg-sub">node fingerprint</div>
|
||||
|
||||
51
src/psyc/cockpit/templates/admin_federation_network.html
Normal file
51
src/psyc/cockpit/templates/admin_federation_network.html
Normal 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> · <a href="/admin/federation/discovery">discovery</a> · <a href="/admin/federation/vouches">vouches</a> · <a href="/admin/federation/quorum">quorum</a> · <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 %}
|
||||
423
src/psyc/lines/network_view.py
Normal file
423
src/psyc/lines/network_view.py
Normal 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
334
tests/test_network_view.py
Normal 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
|
||||
Reference in New Issue
Block a user