merge explore: public transparent federation explorer with cross-jump
This commit is contained in:
@@ -34,6 +34,20 @@ _PUBLIC_PEERS_TTL = 60.0
|
||||
_PUBLIC_NETWORK_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None}
|
||||
_PUBLIC_NETWORK_TTL = 60.0
|
||||
|
||||
# Explore-view cache. The builder fans out to trusted peers' explore feeds
|
||||
# for the distance-2 snapshot, so a polled hit must NEVER trigger that walk.
|
||||
_EXPLORE_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None, "domain": None}
|
||||
_EXPLORE_TTL = 60.0
|
||||
|
||||
|
||||
# Headers we slap on every public endpoint so other psyc nodes' explore
|
||||
# pages can fetch them cross-origin from the browser.
|
||||
_CORS_HEADERS: Dict[str, str] = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
}
|
||||
|
||||
|
||||
def _admin_ok(request: Request) -> bool:
|
||||
return bool(request.session.get("admin_ok"))
|
||||
@@ -63,6 +77,30 @@ def _cached_public_network() -> Dict[str, Any]:
|
||||
return _PUBLIC_NETWORK_CACHE["payload"]
|
||||
|
||||
|
||||
def _cached_explore(domain: Optional[str]) -> Dict[str, Any]:
|
||||
"""Cached explore payload. Re-uses the cache when the host domain matches.
|
||||
|
||||
Domain is recorded into the payload's `node.domain` field, so a fresh
|
||||
cache slot per host avoids serving the wrong reflected name.
|
||||
"""
|
||||
now = time.time()
|
||||
cached_domain = _EXPLORE_CACHE.get("domain")
|
||||
if (
|
||||
_EXPLORE_CACHE["payload"] is None
|
||||
or (now - _EXPLORE_CACHE["ts"]) > _EXPLORE_TTL
|
||||
or cached_domain != domain
|
||||
):
|
||||
_EXPLORE_CACHE["payload"] = network_view.build_explore_view(node_domain=domain)
|
||||
_EXPLORE_CACHE["ts"] = now
|
||||
_EXPLORE_CACHE["domain"] = domain
|
||||
return _EXPLORE_CACHE["payload"]
|
||||
|
||||
|
||||
def _public_json(payload: Any) -> JSONResponse:
|
||||
"""JSONResponse with the public-CORS header set."""
|
||||
return JSONResponse(payload, headers=_CORS_HEADERS)
|
||||
|
||||
|
||||
def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
|
||||
"""Mount all federation routes onto `app`."""
|
||||
|
||||
@@ -180,20 +218,25 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
|
||||
|
||||
@app.get("/federation/info")
|
||||
def federation_info() -> JSONResponse:
|
||||
return JSONResponse({
|
||||
return _public_json({
|
||||
"fingerprint": federation.node_fingerprint(),
|
||||
"version": federation.FEED_VERSION,
|
||||
"feed": federation.FEED_PATH,
|
||||
"key": "/federation/key",
|
||||
"explore": "/federation/explore",
|
||||
})
|
||||
|
||||
@app.get("/federation/key", response_class=PlainTextResponse)
|
||||
def federation_key() -> PlainTextResponse:
|
||||
return PlainTextResponse(federation.public_key_pem(), media_type="text/plain")
|
||||
return PlainTextResponse(
|
||||
federation.public_key_pem(),
|
||||
media_type="text/plain",
|
||||
headers=_CORS_HEADERS,
|
||||
)
|
||||
|
||||
@app.get("/federation/feed")
|
||||
def federation_feed() -> JSONResponse:
|
||||
return JSONResponse(_cached_feed())
|
||||
return _public_json(_cached_feed())
|
||||
|
||||
@app.get("/federation/peers/public")
|
||||
def federation_peers_public() -> JSONResponse:
|
||||
@@ -202,7 +245,7 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
|
||||
Only trusted peers leak; unknown + blocked are internal state and must
|
||||
never appear here.
|
||||
"""
|
||||
return JSONResponse(_cached_public_peers())
|
||||
return _public_json(_cached_public_peers())
|
||||
|
||||
@app.get("/federation/network")
|
||||
def federation_network_public() -> JSONResponse:
|
||||
@@ -212,14 +255,14 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
|
||||
so a fetcher can reconstruct the local web-of-trust shape. Trusted peers
|
||||
only — never unknown or blocked. Signal hashes are deliberately omitted.
|
||||
"""
|
||||
return JSONResponse(_cached_public_network())
|
||||
return _public_json(_cached_public_network())
|
||||
|
||||
# ---------- public vouches + transparency log --------------------
|
||||
|
||||
@app.get("/federation/vouches")
|
||||
def federation_vouches() -> JSONResponse:
|
||||
"""Vouches WE have issued. Peers fetch this to learn who we trust."""
|
||||
return JSONResponse({
|
||||
return _public_json({
|
||||
"fingerprint": federation.node_fingerprint(),
|
||||
"vouches": [v.model_dump(mode="json") for v in federation.our_vouches()],
|
||||
})
|
||||
@@ -228,7 +271,7 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
|
||||
def federation_log() -> JSONResponse:
|
||||
"""Last 100 transparency-log entries, newest first."""
|
||||
entries = translog.recent(limit=100)
|
||||
return JSONResponse({
|
||||
return _public_json({
|
||||
"count": len(entries),
|
||||
"entries": [e.model_dump(mode="json") for e in entries],
|
||||
})
|
||||
@@ -240,8 +283,41 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
|
||||
head = translog.head()
|
||||
head_hash = head.entry_hash if head else None
|
||||
if isinstance(result, Err):
|
||||
return JSONResponse({"error": result.reason, "head_hash": head_hash}, status_code=409)
|
||||
return JSONResponse({"verified": result.value, "head_hash": head_hash})
|
||||
return JSONResponse(
|
||||
{"error": result.reason, "head_hash": head_hash},
|
||||
status_code=409,
|
||||
headers=_CORS_HEADERS,
|
||||
)
|
||||
return _public_json({"verified": result.value, "head_hash": head_hash})
|
||||
|
||||
# ---------- public explore page + data ---------------------------
|
||||
|
||||
@app.get("/federation/explore", response_class=HTMLResponse)
|
||||
def federation_explore_page(request: Request) -> HTMLResponse:
|
||||
"""Public transparency view — anyone can verify this network."""
|
||||
host = request.url.hostname or ""
|
||||
peer_param = request.query_params.get("peer", "").strip()
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"federation_explore.html",
|
||||
{
|
||||
"fingerprint": federation.node_fingerprint(),
|
||||
"domain": host,
|
||||
"peer": peer_param,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/federation/explore/data")
|
||||
def federation_explore_data(request: Request) -> JSONResponse:
|
||||
"""Signed public explorer payload — peer counts, vouches, transitives.
|
||||
|
||||
Public, no auth, CORS-enabled. Cached so a polled hit never triggers
|
||||
the distance-2 fan-out. See `network_view.build_explore_view` for the
|
||||
no-leak contract.
|
||||
"""
|
||||
host = request.url.hostname or None
|
||||
payload = _cached_explore(host)
|
||||
return _public_json(payload)
|
||||
|
||||
# ---------- admin: vouches page ---------------------------------
|
||||
|
||||
|
||||
@@ -748,6 +748,18 @@ body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr
|
||||
text-shadow: 0 0 22px var(--accent-glow);
|
||||
}
|
||||
.hero-sub { margin: 6px 0 0; color: var(--muted); font-size: 13px; }
|
||||
.hero-meta { margin: 10px 0 0; font-size: 12px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.hero-explore {
|
||||
color: var(--accent);
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--border);
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.hero-explore:hover { border-color: var(--accent); box-shadow: 0 0 10px var(--accent-glow); }
|
||||
.hero-explore-sub { color: var(--muted); font-size: 11px; font-family: ui-monospace, Menlo, Consolas, monospace; }
|
||||
.hero-cta {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 9px 16px; border: 1px solid var(--accent); border-radius: 8px;
|
||||
@@ -1554,3 +1566,235 @@ body.wide #federation-network-graph { height: 720px; }
|
||||
color: var(--muted); font-size: 12px; font-style: italic;
|
||||
text-align: center; padding: 22px 0;
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
* federation explorer — public transparency page
|
||||
* Public-facing variant of the admin federation network UI. Reuses the
|
||||
* fn-* graph classes; fe-* is just the chrome around it.
|
||||
* =================================================================== */
|
||||
|
||||
.fe-page { background: var(--bg); }
|
||||
.fe-topbar { gap: 18px; }
|
||||
.fe-topbar .nav-toggle, .fe-topbar .nav { display: none; }
|
||||
.fe-badge {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 4px 12px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 10.5px;
|
||||
color: var(--accent);
|
||||
background: rgba(30,200,255,0.08);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.10em;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
margin-left: auto;
|
||||
}
|
||||
.fe-badge-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent);
|
||||
animation: fe-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes fe-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(1.4); }
|
||||
}
|
||||
|
||||
.fe-hero { padding: 28px 32px; }
|
||||
.fe-hero-head { margin-bottom: 14px; }
|
||||
.fe-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
.fe-title::before {
|
||||
content: "⌖ ";
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
.fe-sub {
|
||||
margin: 6px 0 0;
|
||||
color: var(--accent);
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
text-shadow: 0 0 8px var(--accent-glow);
|
||||
}
|
||||
.fe-intro {
|
||||
margin: 14px 0 0;
|
||||
max-width: 920px;
|
||||
color: var(--text);
|
||||
line-height: 1.55;
|
||||
font-size: 14px;
|
||||
}
|
||||
.fe-intro strong { color: var(--accent); font-weight: 600; }
|
||||
.fe-intro-sub { color: var(--muted); font-size: 12px; margin-top: 10px; }
|
||||
.fe-fp {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
color: var(--accent);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.fe-kpi-panel { padding: 18px 22px; }
|
||||
.fe-kpis { gap: 14px; }
|
||||
.fe-kpis .fn-stat { min-width: 130px; }
|
||||
.fe-kpi-verify .fn-stat-value { font-size: 18px; }
|
||||
.fe-kpi-verify .fn-stat-value.fe-verify-ok { color: var(--green); text-shadow: 0 0 10px rgba(74,222,128,0.45); }
|
||||
.fe-kpi-verify .fn-stat-value.fe-verify-bad { color: var(--red); text-shadow: 0 0 10px rgba(248,113,113,0.45); }
|
||||
|
||||
.fe-verify-row {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
.fe-verify-btn {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.fe-verify-result {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.fe-verify-result.fe-verify-ok { color: var(--green); }
|
||||
.fe-verify-result.fe-verify-bad { color: var(--red); }
|
||||
.fe-verify-link {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--border);
|
||||
}
|
||||
.fe-verify-link:hover { color: var(--accent); border-color: var(--accent); }
|
||||
|
||||
.fe-stage { margin-top: 8px; }
|
||||
|
||||
.fe-walk {
|
||||
margin-top: 16px;
|
||||
padding: 14px 18px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
min-height: 56px;
|
||||
}
|
||||
.fe-walk-empty {
|
||||
margin: 0; color: var(--muted);
|
||||
font-style: italic; font-size: 13px;
|
||||
}
|
||||
.fe-walk-card {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.fe-walk-card-body { min-width: 0; }
|
||||
.fe-walk-card-title {
|
||||
margin: 0 0 4px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
word-break: break-all;
|
||||
}
|
||||
.fe-walk-card-fp {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
.fe-walk-card-stats {
|
||||
display: flex; flex-wrap: wrap; gap: 8px;
|
||||
margin-top: 10px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
.fe-walk-card-stats .k { color: var(--muted); }
|
||||
.fe-walk-card-stats .v { color: var(--accent); }
|
||||
.fe-walk-card-stats > span {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--panel);
|
||||
}
|
||||
.fe-walk-cta {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 10px 18px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bg);
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 0 14px var(--accent-glow);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fe-walk-cta:hover {
|
||||
background: #66daff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.fe-walk-cta-disabled {
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
border-color: var(--border);
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fe-vouches-panel .fe-vouches-in-list {
|
||||
list-style: none;
|
||||
margin: 0; padding: 0;
|
||||
}
|
||||
.fe-vouches-in-list li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.fe-vouches-in-list li:last-child { border-bottom: 0; }
|
||||
.fe-vouches-in-list .fp { color: var(--accent); word-break: break-all; }
|
||||
.fe-vouches-in-list .ts { color: var(--muted); font-size: 11px; }
|
||||
.fe-vouches-in-empty {
|
||||
color: var(--muted); font-style: italic;
|
||||
display: block !important;
|
||||
text-align: center;
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
.fe-footer {
|
||||
margin-top: 36px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.fe-hero { padding: 18px 16px; }
|
||||
.fe-title { font-size: 24px; }
|
||||
.fe-walk-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.fe-walk-cta { width: 100%; justify-content: center; }
|
||||
}
|
||||
|
||||
780
src/psyc/cockpit/static/federation_explore.js
Normal file
780
src/psyc/cockpit/static/federation_explore.js
Normal file
@@ -0,0 +1,780 @@
|
||||
/* psyc — federation explorer (public, transparency view).
|
||||
*
|
||||
* Forked from federation_network.js, adapted for the public surface:
|
||||
* • data source is /federation/explore/data (signed, CORS-enabled)
|
||||
* • clicking a peer opens a walk-to-peer card with a primary CTA
|
||||
* that full-page-navigates to that peer's own /federation/explore
|
||||
* • the transparency log can be re-verified live from the page
|
||||
* • inbound vouches (who vouches for THIS node) get their own section
|
||||
* • severity/IOC-type breakdowns are intentionally NOT surfaced —
|
||||
* those stay admin-only to avoid sector-leaking via the public page
|
||||
*
|
||||
* Pure vanilla JS, no deps. Mirrors topology.js's proven force-sim loop.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const svg = document.getElementById("federation-network-graph");
|
||||
const loadingEl = document.getElementById("fn-loading");
|
||||
const errorEl = document.getElementById("fn-error");
|
||||
const tooltipEl = document.getElementById("fn-tooltip");
|
||||
const walkEl = document.getElementById("fe-walk");
|
||||
const directCountEl = document.getElementById("fe-direct-count");
|
||||
const transitiveCountEl = document.getElementById("fe-transitive-count");
|
||||
const kpiPeers = document.getElementById("fe-kpi-peers");
|
||||
const kpiVouchesOut = document.getElementById("fe-kpi-vouches-out");
|
||||
const kpiVouchesIn = document.getElementById("fe-kpi-vouches-in");
|
||||
const kpiSignals = document.getElementById("fe-kpi-signals");
|
||||
const kpiCorroboration = document.getElementById("fe-kpi-corroboration");
|
||||
const kpiTranslog = document.getElementById("fe-kpi-translog");
|
||||
const kpiVerify = document.getElementById("fe-kpi-verify");
|
||||
const verifyBtn = document.getElementById("fe-verify-btn");
|
||||
const verifyResult = document.getElementById("fe-verify-result");
|
||||
const vouchesInList = document.getElementById("fe-vouches-in-list");
|
||||
const vouchesInCountEl = document.getElementById("fe-vouches-in-count");
|
||||
const settings = window.PSYC_EXPLORE || {};
|
||||
|
||||
if (!svg) return;
|
||||
|
||||
// ---------- shared escape -----------------------------------------------
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
function shortFp(fp) {
|
||||
if (!fp) return "—";
|
||||
if (fp.length >= 16) return fp.slice(0, 8) + "…" + fp.slice(-8);
|
||||
return fp;
|
||||
}
|
||||
function fmtAge(iso) {
|
||||
if (!iso) return "—";
|
||||
const ts = new Date(iso);
|
||||
if (isNaN(ts.getTime())) return "—";
|
||||
const secs = Math.floor((Date.now() - ts.getTime()) / 1000);
|
||||
if (secs < 0) return "just now";
|
||||
if (secs < 60) return secs + "s ago";
|
||||
if (secs < 3600) return Math.floor(secs / 60) + "m ago";
|
||||
if (secs < 86400) return Math.floor(secs / 3600) + "h ago";
|
||||
return Math.floor(secs / 86400) + "d ago";
|
||||
}
|
||||
|
||||
fetch("/federation/explore/data", { credentials: "omit" })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (loadingEl) loadingEl.style.display = "none";
|
||||
render(data);
|
||||
})
|
||||
.catch(err => {
|
||||
if (loadingEl) loadingEl.style.display = "none";
|
||||
if (errorEl) {
|
||||
errorEl.style.display = "block";
|
||||
errorEl.textContent = "✗ failed to load explore payload: " + err.message;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- verify button — fetch /federation/log/verify ---------------
|
||||
if (verifyBtn) {
|
||||
verifyBtn.addEventListener("click", () => {
|
||||
verifyBtn.disabled = true;
|
||||
verifyResult.textContent = "verifying…";
|
||||
verifyResult.classList.remove("fe-verify-ok", "fe-verify-bad");
|
||||
fetch("/federation/log/verify", { credentials: "omit" })
|
||||
.then(r => r.json().then(b => ({ status: r.status, body: b })))
|
||||
.then(({ status, body }) => {
|
||||
if (status === 200 && body.verified != null) {
|
||||
verifyResult.textContent = "✓ verified " + body.verified + " entries · head " + (body.head_hash || "").slice(0, 12) + "…";
|
||||
verifyResult.classList.add("fe-verify-ok");
|
||||
if (kpiVerify) {
|
||||
kpiVerify.textContent = "✓ ok";
|
||||
kpiVerify.classList.add("fe-verify-ok");
|
||||
kpiVerify.classList.remove("fe-verify-bad");
|
||||
}
|
||||
} else {
|
||||
verifyResult.textContent = "✗ " + (body.error || "chain invalid");
|
||||
verifyResult.classList.add("fe-verify-bad");
|
||||
if (kpiVerify) {
|
||||
kpiVerify.textContent = "✗ broken";
|
||||
kpiVerify.classList.add("fe-verify-bad");
|
||||
kpiVerify.classList.remove("fe-verify-ok");
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
verifyResult.textContent = "✗ fetch failed: " + err.message;
|
||||
verifyResult.classList.add("fe-verify-bad");
|
||||
})
|
||||
.finally(() => { verifyBtn.disabled = false; });
|
||||
});
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
const node = data.node || {};
|
||||
const selfFp = data.fingerprint || node.fingerprint || "";
|
||||
const peersData = data.peers || [];
|
||||
const transitiveData = data.transitive_peers || [];
|
||||
const vouchesIn = data.vouches_in || [];
|
||||
const vouchesOut = data.vouches_out || data.vouches || [];
|
||||
|
||||
// ---------- KPI strip ------------------------------------------------
|
||||
if (kpiPeers) kpiPeers.textContent = String(node.peer_count != null ? node.peer_count : peersData.length);
|
||||
if (kpiVouchesOut) kpiVouchesOut.textContent = String(node.vouches_out_count != null ? node.vouches_out_count : vouchesOut.length);
|
||||
if (kpiVouchesIn) kpiVouchesIn.textContent = String(node.vouches_in_count != null ? node.vouches_in_count : vouchesIn.length);
|
||||
if (kpiSignals) kpiSignals.textContent = String(node.signals_count_24h || 0);
|
||||
if (kpiCorroboration) kpiCorroboration.textContent = String(node.corroboration_count_24h || 0);
|
||||
if (kpiTranslog) kpiTranslog.textContent = String(node.translog_entry_count || 0);
|
||||
if (kpiVerify) kpiVerify.textContent = "unverified";
|
||||
|
||||
if (directCountEl) directCountEl.textContent = String(peersData.length);
|
||||
if (transitiveCountEl) transitiveCountEl.textContent = String(transitiveData.length);
|
||||
|
||||
// ---------- node + edge model ----------------------------------------
|
||||
// The explore payload doesn't ship edges directly; we derive them from
|
||||
// the vouches + per-peer signal counts so the graph reads the same way
|
||||
// the admin view does.
|
||||
const peerByFp = Object.create(null);
|
||||
const nodes = [];
|
||||
|
||||
// Self at the center.
|
||||
const selfNode = {
|
||||
id: selfFp, fp: selfFp,
|
||||
domain: settings.selfDomain || node.domain || "",
|
||||
label: settings.selfDomain || (node.domain || "self"),
|
||||
status: "self",
|
||||
is_self: true,
|
||||
distance: 0,
|
||||
stats: null,
|
||||
r: 38,
|
||||
intensity: 1,
|
||||
x: 0, y: 0, vx: 0, vy: 0, fixed: false,
|
||||
};
|
||||
nodes.push(selfNode);
|
||||
peerByFp[selfFp] = selfNode;
|
||||
|
||||
// Max signal count for log-intensity normalization.
|
||||
let maxSig = 0;
|
||||
for (const p of peersData) {
|
||||
if ((p.signal_count_24h || 0) > maxSig) maxSig = p.signal_count_24h || 0;
|
||||
}
|
||||
|
||||
for (const p of peersData) {
|
||||
const fp = p.fingerprint;
|
||||
if (!fp || fp === selfFp) continue;
|
||||
const sig = p.signal_count_24h || 0;
|
||||
let intensity = 1;
|
||||
if (maxSig > 0) {
|
||||
const num = Math.log2(sig + 1);
|
||||
const den = Math.log2(maxSig + 1) || 1;
|
||||
intensity = 0.20 + 0.80 * (num / den);
|
||||
}
|
||||
const n = {
|
||||
id: fp, fp,
|
||||
domain: p.domain || "",
|
||||
label: p.domain || shortFp(fp),
|
||||
status: "trusted",
|
||||
is_self: false,
|
||||
distance: 1,
|
||||
stats: p,
|
||||
r: 16,
|
||||
intensity,
|
||||
x: 0, y: 0, vx: 0, vy: 0, fixed: false,
|
||||
};
|
||||
nodes.push(n);
|
||||
peerByFp[fp] = n;
|
||||
}
|
||||
|
||||
for (const t of transitiveData) {
|
||||
const fp = t.fingerprint;
|
||||
if (!fp || peerByFp[fp]) continue;
|
||||
const n = {
|
||||
id: fp, fp,
|
||||
domain: t.domain || "",
|
||||
label: t.domain || shortFp(fp),
|
||||
status: "unknown",
|
||||
is_self: false,
|
||||
distance: 2,
|
||||
stats: null,
|
||||
via: t.via_peer_fingerprint || "",
|
||||
r: 9,
|
||||
intensity: 0.7,
|
||||
x: 0, y: 0, vx: 0, vy: 0, fixed: false,
|
||||
};
|
||||
nodes.push(n);
|
||||
peerByFp[fp] = n;
|
||||
}
|
||||
|
||||
// Edges. Per-peer signal counts → signal edges; outbound vouches →
|
||||
// vouch edges; vouches_in → bidirectional vouch edges; transitive
|
||||
// "via" → knows edges.
|
||||
const edges = [];
|
||||
for (const p of peersData) {
|
||||
const fp = p.fingerprint;
|
||||
if (!fp || fp === selfFp) continue;
|
||||
if ((p.signal_count_24h || 0) > 0) {
|
||||
edges.push({
|
||||
source: fp, target: selfFp, kind: "signal",
|
||||
weight: p.signal_count_24h, label: p.signal_count_24h + " signals/24h",
|
||||
bidirectional: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Outbound vouches.
|
||||
const outbound = new Set();
|
||||
for (const v of vouchesOut) {
|
||||
const tgt = v.target_fingerprint;
|
||||
if (!tgt || !peerByFp[tgt]) continue;
|
||||
outbound.add(tgt);
|
||||
edges.push({
|
||||
source: selfFp, target: tgt, kind: "vouch",
|
||||
weight: 1, label: "vouched", bidirectional: false,
|
||||
});
|
||||
}
|
||||
// Inbound vouches — collapse onto existing outbound where possible.
|
||||
for (const v of vouchesIn) {
|
||||
const src = v.voucher_fingerprint;
|
||||
if (!src || !peerByFp[src]) continue;
|
||||
if (outbound.has(src)) {
|
||||
const existing = edges.find(e => e.kind === "vouch"
|
||||
&& e.source === selfFp && e.target === src);
|
||||
if (existing) {
|
||||
existing.bidirectional = true;
|
||||
existing.label = "vouched ↔";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
edges.push({
|
||||
source: src, target: selfFp, kind: "vouch",
|
||||
weight: 1, label: "vouches us", bidirectional: false,
|
||||
});
|
||||
}
|
||||
// Transitive "knows" edges.
|
||||
for (const t of transitiveData) {
|
||||
const parent = t.via_peer_fingerprint;
|
||||
if (!parent || !peerByFp[parent] || !peerByFp[t.fingerprint]) continue;
|
||||
edges.push({
|
||||
source: parent, target: t.fingerprint, kind: "knows",
|
||||
weight: 0.5, label: "knows", bidirectional: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- viewport + seeding ---------------------------------------
|
||||
function viewport() {
|
||||
const W = svg.clientWidth || 900;
|
||||
const H = parseInt(getComputedStyle(svg).height, 10) || 560;
|
||||
return { W, H };
|
||||
}
|
||||
let { W, H } = viewport();
|
||||
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
||||
|
||||
(function seed() {
|
||||
const cx = W / 2, cy = H / 2;
|
||||
nodes.forEach((n, i) => {
|
||||
if (n.is_self) { n.x = cx; n.y = cy; return; }
|
||||
const ring = n.distance >= 2 ? Math.min(W, H) * 0.40 : Math.min(W, H) * 0.22;
|
||||
const ang = (i / Math.max(1, nodes.length)) * Math.PI * 2;
|
||||
n.x = cx + ring * Math.cos(ang) + (Math.random() - 0.5) * 20;
|
||||
n.y = cy + ring * Math.sin(ang) + (Math.random() - 0.5) * 20;
|
||||
});
|
||||
})();
|
||||
|
||||
const REPULSION = 1500;
|
||||
const SPRING_K = 0.035;
|
||||
const SPRING_REST_BASE = 110;
|
||||
const DAMP = 0.82;
|
||||
const CENTER_PULL = 0.005;
|
||||
|
||||
function tick() {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const a = nodes[i];
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const b = nodes[j];
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
const d2 = dx * dx + dy * dy + 0.1;
|
||||
const d = Math.sqrt(d2);
|
||||
const f = REPULSION / d2;
|
||||
const fx = (dx / d) * f, fy = (dy / d) * f;
|
||||
if (!a.fixed) { a.vx -= fx; a.vy -= fy; }
|
||||
if (!b.fixed) { b.vx += fx; b.vy += fy; }
|
||||
}
|
||||
}
|
||||
for (const e of edges) {
|
||||
const a = peerByFp[e.source], b = peerByFp[e.target];
|
||||
if (!a || !b) continue;
|
||||
const rest = e.kind === "knows" ? SPRING_REST_BASE + 60 : SPRING_REST_BASE;
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
const d = Math.sqrt(dx * dx + dy * dy) + 0.1;
|
||||
const f = (d - rest) * SPRING_K;
|
||||
const fx = (dx / d) * f, fy = (dy / d) * f;
|
||||
if (!a.fixed) { a.vx += fx; a.vy += fy; }
|
||||
if (!b.fixed) { b.vx -= fx; b.vy -= fy; }
|
||||
}
|
||||
for (const n of nodes) {
|
||||
if (n.fixed) { n.vx = 0; n.vy = 0; continue; }
|
||||
n.vx += (W / 2 - n.x) * CENTER_PULL;
|
||||
n.vy += (H / 2 - n.y) * CENTER_PULL;
|
||||
n.vx *= DAMP; n.vy *= DAMP;
|
||||
n.x += n.vx; n.y += n.vy;
|
||||
n.x = Math.max(n.r, Math.min(W - n.r, n.x));
|
||||
n.y = Math.max(n.r, Math.min(H - n.r, n.y));
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 280; i++) tick();
|
||||
|
||||
// ---------- render SVG groups ----------------------------------------
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
const edgesG = document.createElementNS(ns, "g");
|
||||
const nodesG = document.createElementNS(ns, "g");
|
||||
edgesG.setAttribute("class", "fn-edges");
|
||||
nodesG.setAttribute("class", "fn-nodes");
|
||||
svg.appendChild(edgesG);
|
||||
svg.appendChild(nodesG);
|
||||
|
||||
const edgeEls = edges.map(e => {
|
||||
const grp = document.createElementNS(ns, "g");
|
||||
grp.setAttribute("class", "fn-edge-grp fn-kind-" + e.kind);
|
||||
grp.dataset.source = e.source;
|
||||
grp.dataset.target = e.target;
|
||||
const ln = document.createElementNS(ns, "line");
|
||||
ln.setAttribute("class", "fn-edge");
|
||||
if (e.kind === "signal") {
|
||||
const w = Math.min(5, 1 + Math.log2(e.weight + 1) * 0.8);
|
||||
ln.setAttribute("stroke-width", w.toFixed(2));
|
||||
}
|
||||
grp.appendChild(ln);
|
||||
if (e.label) {
|
||||
const lbl = document.createElementNS(ns, "text");
|
||||
lbl.setAttribute("class", "fn-edge-label");
|
||||
lbl.textContent = e.label;
|
||||
grp.appendChild(lbl);
|
||||
}
|
||||
edgesG.appendChild(grp);
|
||||
return { line: ln, label: grp.querySelector("text"), grp };
|
||||
});
|
||||
|
||||
function _classFor(n) {
|
||||
if (n.is_self) return "fn-node fn-self";
|
||||
const dist = n.distance >= 2 ? " fn-distance-2" : " fn-distance-1";
|
||||
return "fn-node fn-status-" + n.status + dist;
|
||||
}
|
||||
|
||||
const nodeEls = nodes.map(n => {
|
||||
const g = document.createElementNS(ns, "g");
|
||||
g.setAttribute("class", _classFor(n));
|
||||
g.dataset.fp = n.fp;
|
||||
|
||||
let shape;
|
||||
if (n.is_self) {
|
||||
const sz = n.r;
|
||||
shape = document.createElementNS(ns, "rect");
|
||||
shape.setAttribute("x", -sz); shape.setAttribute("y", -sz);
|
||||
shape.setAttribute("width", sz * 2); shape.setAttribute("height", sz * 2);
|
||||
shape.setAttribute("rx", 10); shape.setAttribute("ry", 10);
|
||||
g.appendChild(shape);
|
||||
} else {
|
||||
shape = document.createElementNS(ns, "circle");
|
||||
shape.setAttribute("r", n.r);
|
||||
shape.setAttribute("fill-opacity", n.intensity.toFixed(2));
|
||||
g.appendChild(shape);
|
||||
}
|
||||
|
||||
const text = document.createElementNS(ns, "text");
|
||||
text.setAttribute("class", "fn-label");
|
||||
text.setAttribute("dy", n.r + 13);
|
||||
text.textContent = n.label;
|
||||
g.appendChild(text);
|
||||
|
||||
if (!n.is_self) {
|
||||
const sub = document.createElementNS(ns, "text");
|
||||
sub.setAttribute("class", "fn-sublabel");
|
||||
sub.setAttribute("dy", n.r + 24);
|
||||
sub.textContent = n.fp.slice(0, 8) + "…";
|
||||
g.appendChild(sub);
|
||||
|
||||
if (n.stats) {
|
||||
const badge = document.createElementNS(ns, "text");
|
||||
badge.setAttribute("class", "fn-stat-badge");
|
||||
badge.setAttribute("dy", n.r + 36);
|
||||
badge.textContent =
|
||||
"↓ " + (n.stats.signal_count_24h || 0) +
|
||||
" · ⚡ " + (n.stats.quorum_contribution_24h || 0);
|
||||
g.appendChild(badge);
|
||||
}
|
||||
}
|
||||
|
||||
const title = document.createElementNS(ns, "title");
|
||||
title.textContent = (n.is_self ? "self · " : "") + (n.domain || n.label || n.fp);
|
||||
g.appendChild(title);
|
||||
|
||||
nodesG.appendChild(g);
|
||||
return g;
|
||||
});
|
||||
|
||||
function paint() {
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
const e = edges[i];
|
||||
const a = peerByFp[e.source], b = peerByFp[e.target];
|
||||
if (!a || !b) continue;
|
||||
const els = edgeEls[i];
|
||||
els.line.setAttribute("x1", a.x); els.line.setAttribute("y1", a.y);
|
||||
els.line.setAttribute("x2", b.x); els.line.setAttribute("y2", b.y);
|
||||
if (els.label) {
|
||||
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
|
||||
els.label.setAttribute("x", mx); els.label.setAttribute("y", my - 3);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
nodeEls[i].setAttribute("transform", `translate(${nodes[i].x},${nodes[i].y})`);
|
||||
}
|
||||
}
|
||||
paint();
|
||||
|
||||
// ---------- tooltip --------------------------------------------------
|
||||
function showTooltip(n, clientX, clientY) {
|
||||
if (!tooltipEl) return;
|
||||
const rows = [];
|
||||
rows.push(`<div class="fn-tooltip-title">${esc(n.domain || n.label || n.fp.slice(0,12))}</div>`);
|
||||
if (n.is_self) {
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">role</span><span class="v">self</span></div>`);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">peers</span><span class="v">${node.peer_count || 0}</span></div>`);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">signals 24h</span><span class="v">${node.signals_count_24h || 0}</span></div>`);
|
||||
} else if (n.distance >= 2) {
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">distance</span><span class="v">2 hops (transitive)</span></div>`);
|
||||
if (n.via) {
|
||||
const parent = peerByFp[n.via];
|
||||
const via = parent ? (parent.domain || shortFp(parent.fp)) : shortFp(n.via);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">via</span><span class="v">${esc(via)}</span></div>`);
|
||||
}
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">tip</span><span class="v">click to walk →</span></div>`);
|
||||
} else {
|
||||
const s = n.stats || {};
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">status</span><span class="v">trusted</span></div>`);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">signals 24h</span><span class="v">${s.signal_count_24h || 0}</span></div>`);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">quorum hits</span><span class="v">${s.quorum_contribution_24h || 0}</span></div>`);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">last seen</span><span class="v">${esc(fmtAge(s.last_seen))}</span></div>`);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">tip</span><span class="v">click to walk →</span></div>`);
|
||||
}
|
||||
tooltipEl.innerHTML = rows.join("");
|
||||
tooltipEl.classList.add("is-visible");
|
||||
positionTooltip(clientX, clientY);
|
||||
}
|
||||
function positionTooltip(clientX, clientY) {
|
||||
if (!tooltipEl) return;
|
||||
const parent = svg.parentElement;
|
||||
if (!parent) return;
|
||||
const rect = parent.getBoundingClientRect();
|
||||
let x = clientX - rect.left + 14;
|
||||
let y = clientY - rect.top + 14;
|
||||
const tw = tooltipEl.offsetWidth || 240;
|
||||
const th = tooltipEl.offsetHeight || 100;
|
||||
if (x + tw > parent.clientWidth - 4) x = clientX - rect.left - tw - 14;
|
||||
if (y + th > parent.clientHeight - 4) y = clientY - rect.top - th - 14;
|
||||
tooltipEl.style.left = x + "px";
|
||||
tooltipEl.style.top = y + "px";
|
||||
}
|
||||
function hideTooltip() {
|
||||
if (tooltipEl) tooltipEl.classList.remove("is-visible");
|
||||
}
|
||||
|
||||
// ---------- drag + click + hover ------------------------------------
|
||||
let dragging = null, dragOffset = { x: 0, y: 0 };
|
||||
let pressedNode = null, pressedAt = null, moved = false;
|
||||
let energyBudget = 40;
|
||||
function svgPoint(clientX, clientY) {
|
||||
const pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY;
|
||||
return pt.matrixTransform(svg.getScreenCTM().inverse());
|
||||
}
|
||||
nodeEls.forEach((g, i) => {
|
||||
const n = nodes[i];
|
||||
g.addEventListener("mousedown", ev => {
|
||||
ev.preventDefault();
|
||||
pressedNode = n;
|
||||
pressedAt = { x: ev.clientX, y: ev.clientY };
|
||||
moved = false;
|
||||
dragging = n;
|
||||
const p = svgPoint(ev.clientX, ev.clientY);
|
||||
dragOffset.x = p.x - dragging.x; dragOffset.y = p.y - dragging.y;
|
||||
if (currentLayout === "force") dragging.fixed = true;
|
||||
g.classList.add("dragging");
|
||||
});
|
||||
g.addEventListener("mouseenter", ev => showTooltip(n, ev.clientX, ev.clientY));
|
||||
g.addEventListener("mousemove", ev => positionTooltip(ev.clientX, ev.clientY));
|
||||
g.addEventListener("mouseleave", hideTooltip);
|
||||
});
|
||||
document.addEventListener("mousemove", ev => {
|
||||
if (pressedAt) {
|
||||
const dx = ev.clientX - pressedAt.x, dy = ev.clientY - pressedAt.y;
|
||||
if (dx * dx + dy * dy > 16) moved = true;
|
||||
}
|
||||
if (!dragging) return;
|
||||
const p = svgPoint(ev.clientX, ev.clientY);
|
||||
dragging.x = p.x - dragOffset.x; dragging.y = p.y - dragOffset.y;
|
||||
dragging.vx = 0; dragging.vy = 0;
|
||||
energyBudget = 80;
|
||||
});
|
||||
document.addEventListener("mouseup", () => {
|
||||
if (dragging) {
|
||||
const g = nodesG.querySelector(`[data-fp="${CSS.escape(dragging.fp)}"]`);
|
||||
if (g) g.classList.remove("dragging");
|
||||
if (currentLayout === "force") dragging.fixed = false;
|
||||
dragging = null;
|
||||
}
|
||||
if (pressedNode && !moved) selectNode(pressedNode);
|
||||
pressedNode = null; pressedAt = null;
|
||||
});
|
||||
|
||||
// ---------- walk-to-peer card ---------------------------------------
|
||||
function selectNode(n) {
|
||||
nodeEls.forEach(el => el.classList.remove("selected"));
|
||||
const me = nodesG.querySelector(`[data-fp="${CSS.escape(n.fp)}"]`);
|
||||
if (me) me.classList.add("selected");
|
||||
renderWalk(n);
|
||||
}
|
||||
function jumpToFp(fp) {
|
||||
const t = peerByFp[fp];
|
||||
if (!t) return;
|
||||
selectNode(t);
|
||||
// Scroll the graph stage into view so the user sees the highlight.
|
||||
svg.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
function vouchersFor(fp) {
|
||||
// Inbound vouches naming `fp`. Right now we only have inbound vouches
|
||||
// for SELF in the public payload; for any other peer we don't see
|
||||
// who-vouches-for-them from this page.
|
||||
if (fp === selfFp) return vouchesIn.map(v => v.voucher_fingerprint);
|
||||
return [];
|
||||
}
|
||||
|
||||
function renderWalk(n) {
|
||||
if (!walkEl) return;
|
||||
const isSelf = n.is_self;
|
||||
const isTransitive = n.distance >= 2;
|
||||
const stats = n.stats || {};
|
||||
|
||||
const targetDomain = n.domain || (isSelf ? settings.selfDomain : "");
|
||||
const peerHref = targetDomain
|
||||
? `https://${targetDomain}/federation/explore`
|
||||
: "";
|
||||
|
||||
const statsHtml = [];
|
||||
if (isSelf) {
|
||||
statsHtml.push(`<span><span class="k">peers</span> <span class="v">${node.peer_count || 0}</span></span>`);
|
||||
statsHtml.push(`<span><span class="k">signals 24h</span> <span class="v">${node.signals_count_24h || 0}</span></span>`);
|
||||
statsHtml.push(`<span><span class="k">corroborations</span> <span class="v">${node.corroboration_count_24h || 0}</span></span>`);
|
||||
statsHtml.push(`<span><span class="k">translog</span> <span class="v">${node.translog_entry_count || 0} entries</span></span>`);
|
||||
} else if (isTransitive) {
|
||||
const parent = peerByFp[n.via];
|
||||
const via = parent ? (parent.domain || shortFp(parent.fp)) : shortFp(n.via);
|
||||
statsHtml.push(`<span><span class="k">distance</span> <span class="v">2 hops</span></span>`);
|
||||
statsHtml.push(`<span><span class="k">learned via</span> <span class="v">${esc(via)}</span></span>`);
|
||||
statsHtml.push(`<span><span class="k">stats</span> <span class="v">— (peer-side only)</span></span>`);
|
||||
} else {
|
||||
statsHtml.push(`<span><span class="k">status</span> <span class="v">trusted</span></span>`);
|
||||
statsHtml.push(`<span><span class="k">signals 24h</span> <span class="v">${stats.signal_count_24h || 0}</span></span>`);
|
||||
statsHtml.push(`<span><span class="k">cases / iocs 24h</span> <span class="v">${stats.cases_24h || 0} / ${stats.iocs_24h || 0}</span></span>`);
|
||||
statsHtml.push(`<span><span class="k">quorum hits</span> <span class="v">${stats.quorum_contribution_24h || 0}</span></span>`);
|
||||
statsHtml.push(`<span><span class="k">last seen</span> <span class="v">${esc(fmtAge(stats.last_seen))}</span></span>`);
|
||||
}
|
||||
|
||||
const cta = peerHref
|
||||
? `<a class="fe-walk-cta" href="${esc(peerHref)}">View this peer's federation <span aria-hidden="true">→</span></a>`
|
||||
: `<span class="fe-walk-cta fe-walk-cta-disabled" title="no public domain on file for this peer">no public address known</span>`;
|
||||
|
||||
walkEl.innerHTML = `
|
||||
<div class="fe-walk-card">
|
||||
<div class="fe-walk-card-body">
|
||||
<h3 class="fe-walk-card-title">${esc(n.domain || n.label || shortFp(n.fp))}</h3>
|
||||
<div class="fe-walk-card-fp">${esc(n.fp)}</div>
|
||||
<div class="fe-walk-card-stats">${statsHtml.join("")}</div>
|
||||
</div>
|
||||
${cta}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---------- inbound vouches list ------------------------------------
|
||||
function renderVouchesIn() {
|
||||
if (!vouchesInList) return;
|
||||
if (vouchesInCountEl) vouchesInCountEl.textContent = String(vouchesIn.length);
|
||||
if (!vouchesIn.length) {
|
||||
vouchesInList.innerHTML = `<li class="fe-vouches-in-empty">no inbound vouches yet</li>`;
|
||||
return;
|
||||
}
|
||||
const items = vouchesIn.map(v => {
|
||||
const fp = v.voucher_fingerprint || "";
|
||||
const peer = peerByFp[fp];
|
||||
const label = peer ? (peer.domain || shortFp(fp)) : shortFp(fp);
|
||||
const clickable = peer ? `data-jump="${esc(fp)}"` : `data-jump=""`;
|
||||
return `<li>
|
||||
<span class="fp">
|
||||
<button type="button" class="fn-fp-jump" ${clickable}>${esc(label)}</button>
|
||||
<code style="margin-left:8px;color:var(--muted);font-size:11px;">${esc(fp)}</code>
|
||||
</span>
|
||||
<span class="ts">${esc(v.issued_at || "")}</span>
|
||||
</li>`;
|
||||
}).join("");
|
||||
vouchesInList.innerHTML = items;
|
||||
vouchesInList.querySelectorAll(".fn-fp-jump").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const fp = btn.getAttribute("data-jump") || "";
|
||||
if (fp) jumpToFp(fp);
|
||||
});
|
||||
});
|
||||
}
|
||||
renderVouchesIn();
|
||||
|
||||
// ---------- copy buttons on the static page -------------------------
|
||||
document.querySelectorAll(".fn-copy-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const v = btn.getAttribute("data-copy") || "";
|
||||
if (!v) return;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(v).catch(() => {});
|
||||
}
|
||||
const t = btn.textContent;
|
||||
btn.textContent = "copied";
|
||||
setTimeout(() => { btn.textContent = t; }, 1100);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- idle animation ------------------------------------------
|
||||
function loop() {
|
||||
let moving = false;
|
||||
for (const n of nodes) {
|
||||
if (Math.abs(n.vx) > 0.04 || Math.abs(n.vy) > 0.04) { moving = true; break; }
|
||||
}
|
||||
if (moving || energyBudget > 0 || dragging) {
|
||||
tick(); paint();
|
||||
if (energyBudget > 0) energyBudget--;
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
loop();
|
||||
|
||||
// ---------- edge liveness + flow toggle -----------------------------
|
||||
edges.forEach((e, i) => {
|
||||
const ln = edgeEls[i].line;
|
||||
if (e.kind === "signal") ln.classList.add("alive");
|
||||
if (e.kind === "knows") ln.classList.add("dim");
|
||||
});
|
||||
const flowToggle = document.getElementById("fn-flow");
|
||||
function applyFlowToggle() {
|
||||
svg.classList.toggle("flow-off", !(flowToggle && flowToggle.checked));
|
||||
}
|
||||
applyFlowToggle();
|
||||
if (flowToggle) flowToggle.addEventListener("change", applyFlowToggle);
|
||||
|
||||
// ---------- layout modes --------------------------------------------
|
||||
function unfix() { for (const n of nodes) n.fixed = false; }
|
||||
function clearVel() { for (const n of nodes) { n.vx = 0; n.vy = 0; } }
|
||||
|
||||
function applyForce() {
|
||||
unfix();
|
||||
for (const n of nodes) {
|
||||
if (n.is_self) { n.x = W / 2; n.y = H / 2; n.fixed = true; continue; }
|
||||
n.vx = (Math.random() - 0.5) * 5;
|
||||
n.vy = (Math.random() - 0.5) * 5;
|
||||
}
|
||||
energyBudget = 300;
|
||||
}
|
||||
|
||||
function applyHierarchical() {
|
||||
const self = nodes.find(n => n.is_self);
|
||||
const direct = nodes.filter(n => !n.is_self && n.distance === 1);
|
||||
const transitive = nodes.filter(n => n.distance >= 2);
|
||||
if (self) { self.x = W / 2; self.y = 70; self.fixed = true; }
|
||||
direct.forEach((n, i) => {
|
||||
n.x = W * (i + 1) / (direct.length + 1);
|
||||
n.y = H * 0.42;
|
||||
n.fixed = true;
|
||||
});
|
||||
const tCount = transitive.length || 1;
|
||||
transitive.forEach((n, i) => {
|
||||
n.x = W * (i + 1) / (tCount + 1);
|
||||
n.y = H * 0.78;
|
||||
n.fixed = true;
|
||||
});
|
||||
clearVel(); paint();
|
||||
}
|
||||
|
||||
function applyRadial() {
|
||||
const self = nodes.find(n => n.is_self);
|
||||
const direct = nodes.filter(n => !n.is_self && n.distance === 1);
|
||||
const transitive = nodes.filter(n => n.distance >= 2);
|
||||
const R1 = Math.min(W, H) * 0.22;
|
||||
const R2 = Math.min(W, H) * 0.40;
|
||||
if (self) { self.x = W / 2; self.y = H / 2; self.fixed = true; }
|
||||
const dCount = direct.length || 1;
|
||||
direct.forEach((n, i) => {
|
||||
const a = (i / dCount) * Math.PI * 2 - Math.PI / 2;
|
||||
n.x = W / 2 + R1 * Math.cos(a);
|
||||
n.y = H / 2 + R1 * Math.sin(a);
|
||||
n.fixed = true;
|
||||
});
|
||||
const tCount = transitive.length || 1;
|
||||
transitive.forEach((n, i) => {
|
||||
const a = (i / tCount) * Math.PI * 2 - Math.PI / 2;
|
||||
n.x = W / 2 + R2 * Math.cos(a);
|
||||
n.y = H / 2 + R2 * Math.sin(a);
|
||||
n.fixed = true;
|
||||
});
|
||||
clearVel(); paint();
|
||||
}
|
||||
|
||||
const LAYOUTS = { force: applyForce, hier: applyHierarchical, radial: applyRadial };
|
||||
let currentLayout = "force";
|
||||
const selfNodeRef = nodes.find(n => n.is_self);
|
||||
if (selfNodeRef) { selfNodeRef.x = W / 2; selfNodeRef.y = H / 2; selfNodeRef.fixed = true; }
|
||||
|
||||
document.querySelectorAll(".topo-layout").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const mode = btn.dataset.layout;
|
||||
if (!LAYOUTS[mode] || mode === currentLayout) return;
|
||||
document.querySelectorAll(".topo-layout").forEach(b => b.classList.toggle("is-active", b === btn));
|
||||
currentLayout = mode;
|
||||
LAYOUTS[mode]();
|
||||
});
|
||||
});
|
||||
|
||||
const resetBtn = document.getElementById("fn-reset");
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener("click", () => {
|
||||
if (currentLayout === "force") {
|
||||
for (const n of nodes) {
|
||||
if (n.is_self) continue;
|
||||
n.vx = (Math.random() - 0.5) * 6;
|
||||
n.vy = (Math.random() - 0.5) * 6;
|
||||
}
|
||||
energyBudget = 200;
|
||||
} else {
|
||||
LAYOUTS[currentLayout]();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- wheel zoom + resize -------------------------------------
|
||||
let zoom = 1, panX = 0, panY = 0;
|
||||
svg.addEventListener("wheel", ev => {
|
||||
ev.preventDefault();
|
||||
const delta = ev.deltaY > 0 ? 1.1 : 0.9;
|
||||
zoom = Math.max(0.3, Math.min(2.5, zoom * delta));
|
||||
const vw = W / zoom, vh = H / zoom;
|
||||
svg.setAttribute("viewBox", `${(W - vw) / 2 + panX} ${(H - vh) / 2 + panY} ${vw} ${vh}`);
|
||||
}, { passive: false });
|
||||
window.addEventListener("resize", () => {
|
||||
const v = viewport();
|
||||
W = v.W; H = v.H;
|
||||
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
||||
energyBudget = 60;
|
||||
});
|
||||
|
||||
// ---------- focus-peer query param ----------------------------------
|
||||
// ?peer=<domain> auto-selects that peer in the graph so deep links work.
|
||||
if (settings.focusPeer) {
|
||||
const target = nodes.find(n => n.domain && n.domain === settings.focusPeer);
|
||||
if (target) selectNode(target);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -5,7 +5,7 @@
|
||||
// This makes the cockpit installable as a PWA and survives flaky connections,
|
||||
// without serving stale operational data behind the operator's back.
|
||||
|
||||
const CACHE_VERSION = "psyc-v7";
|
||||
const CACHE_VERSION = "psyc-v8";
|
||||
const STATIC_ASSETS = [
|
||||
"/static/cockpit.css",
|
||||
"/static/psyc-tokens.css",
|
||||
|
||||
145
src/psyc/cockpit/templates/federation_explore.html
Normal file
145
src/psyc/cockpit/templates/federation_explore.html
Normal file
@@ -0,0 +1,145 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0f1115">
|
||||
<meta name="apple-mobile-web-app-title" content="psyc · federation explorer">
|
||||
<title>Federation Explorer — psyc</title>
|
||||
<link rel="icon" type="image/png" href="/static/psyc-logo.png">
|
||||
<link rel="apple-touch-icon" href="/static/psyc-logo.png">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/cockpit.css">
|
||||
<link rel="stylesheet" href="/static/psyc-tokens.css">
|
||||
<script defer src="https://analytics.neuronetz.ai/script.js" data-website-id="34c354e5-780e-4c42-a5ce-49b13ff3f088"></script>
|
||||
<script>
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(() => {});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="wide fe-page">
|
||||
<header class="topbar fe-topbar">
|
||||
<a class="brand" href="/federation/explore">
|
||||
<img class="brand-icon" src="/static/psyc-logo.png" alt="psyc">
|
||||
<span class="brand-sub">operations cockpit</span>
|
||||
</a>
|
||||
<span class="fe-badge" title="Public, no auth — this view is auditable by anyone">
|
||||
<span class="fe-badge-dot"></span>PUBLIC · TRANSPARENT
|
||||
</span>
|
||||
<div class="family">
|
||||
<img class="family-icon" src="/static/nn-sc-icon.png" alt="NN-sc — Security/Control" title="NN-sc · Security">
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<section class="panel fe-hero">
|
||||
<div class="fe-hero-head">
|
||||
<h1 class="fe-title">Federation Explorer</h1>
|
||||
<p class="fe-sub" id="fe-node-domain">{{ domain or fingerprint }}</p>
|
||||
</div>
|
||||
<p class="fe-intro">
|
||||
This is a <strong>public transparency view</strong> of the psyc federation this node sits inside.
|
||||
Anyone on the internet can verify the trust network — who has vouched for whom,
|
||||
which signals are corroborated, and that the transparency log hasn't been rewritten.
|
||||
<strong>Click any peer in the graph to inspect it, then jump to that peer's own explorer.</strong>
|
||||
</p>
|
||||
<p class="fe-intro fe-intro-sub">
|
||||
Self fingerprint:
|
||||
<code class="fe-fp">{{ fingerprint }}</code>
|
||||
<button type="button" class="fn-copy-btn" data-copy="{{ fingerprint }}">copy</button>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="panel fe-kpi-panel">
|
||||
<div class="fn-stats fe-kpis">
|
||||
<div class="fn-stat"><div class="fn-stat-label">direct peers</div><div class="fn-stat-value" id="fe-kpi-peers">…</div></div>
|
||||
<div class="fn-stat"><div class="fn-stat-label">vouches out</div><div class="fn-stat-value" id="fe-kpi-vouches-out">…</div></div>
|
||||
<div class="fn-stat"><div class="fn-stat-label">vouches in</div><div class="fn-stat-value" id="fe-kpi-vouches-in">…</div></div>
|
||||
<div class="fn-stat"><div class="fn-stat-label">signals (24h)</div><div class="fn-stat-value" id="fe-kpi-signals">…</div></div>
|
||||
<div class="fn-stat"><div class="fn-stat-label">corroborations (24h)</div><div class="fn-stat-value" id="fe-kpi-corroboration">…</div></div>
|
||||
<div class="fn-stat"><div class="fn-stat-label">translog entries</div><div class="fn-stat-value" id="fe-kpi-translog">…</div></div>
|
||||
<div class="fn-stat fe-kpi-verify"><div class="fn-stat-label">log integrity</div><div class="fn-stat-value" id="fe-kpi-verify">…</div></div>
|
||||
</div>
|
||||
<div class="fe-verify-row">
|
||||
<button type="button" id="fe-verify-btn" class="btn fe-verify-btn">Verify this log →</button>
|
||||
<span id="fe-verify-result" class="fe-verify-result"></span>
|
||||
<a class="fe-verify-link" href="/federation/log" target="_blank" rel="noopener">raw log JSON</a>
|
||||
<a class="fe-verify-link" href="/federation/explore/data" target="_blank" rel="noopener">signed payload</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Trust network</h2>
|
||||
<span class="count"><span id="fe-direct-count">…</span> direct · <span id="fe-transitive-count">…</span> transitive</span>
|
||||
</div>
|
||||
<p class="page-intro">
|
||||
Self at the center, directly-trusted peers around it,
|
||||
peers-of-peers (learned from each trusted peer's signed feed) on the outer ring.
|
||||
Edges: <em>vouches</em> (solid), <em>signals</em> (dashed, thickness ∝ 24h volume),
|
||||
<em>knows</em> (dotted grey), <em>corroborate</em> (faint pulse — two peers reporting the same signal).
|
||||
</p>
|
||||
|
||||
<div class="topo-stage fe-stage">
|
||||
<div class="topo-toolbar">
|
||||
<div class="topo-layouts" role="tablist">
|
||||
<button type="button" class="topo-layout is-active" data-layout="force" title="organic force-directed">Force</button>
|
||||
<button type="button" class="topo-layout" data-layout="hier" title="self on top, direct peers in a row, transitives below">Hierarchical</button>
|
||||
<button type="button" class="topo-layout" data-layout="radial" title="self at center, peers on a ring, transitives outside">Radial</button>
|
||||
</div>
|
||||
<label class="topo-toggle"><input type="checkbox" id="fn-flow" checked> signal flow</label>
|
||||
<span class="topo-legend"><span class="lg-swatch fn-lg-self"></span>self</span>
|
||||
<span class="topo-legend"><span class="lg-swatch fn-lg-trusted"></span>trusted</span>
|
||||
<span class="topo-legend"><span class="lg-swatch fn-lg-unknown"></span>transitive</span>
|
||||
<button type="button" id="fn-reset" class="btn">re-settle</button>
|
||||
<span class="topo-hint">drag · scroll to zoom · click a peer to walk to its explorer</span>
|
||||
</div>
|
||||
<svg id="federation-network-graph" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div id="fn-tooltip" class="fn-tooltip"></div>
|
||||
<div id="fn-loading" class="page-intro" style="text-align:center; margin-top:10px;">loading federation network…</div>
|
||||
<div id="fn-error" class="gate-error" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div id="fe-walk" class="fe-walk">
|
||||
<p class="fe-walk-empty">Click any peer in the graph above to inspect it and walk to its federation view.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel fe-vouches-panel">
|
||||
<div class="panel-head">
|
||||
<h2>Who vouches for this node</h2>
|
||||
<span class="count"><span id="fe-vouches-in-count">…</span> inbound</span>
|
||||
</div>
|
||||
<p class="page-intro">
|
||||
Each entry is a signed vouch naming this node as target, issued by a peer we currently trust.
|
||||
Click a fingerprint to highlight that peer in the graph above.
|
||||
</p>
|
||||
<ul id="fe-vouches-in-list" class="fe-vouches-in-list">
|
||||
<li class="fe-vouches-in-empty">no inbound vouches yet</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="footer fe-footer">
|
||||
psyc · defensive CTI · transparent by design ·
|
||||
powered by <a href="https://neuronetz.ai" target="_blank" rel="noopener">neuronetz.ai</a>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// The walk-to-peer param ("?peer=domain") tells the JS to focus that peer
|
||||
// in the graph as soon as the data lands.
|
||||
window.PSYC_EXPLORE = {
|
||||
selfFingerprint: "{{ fingerprint }}",
|
||||
selfDomain: "{{ domain }}",
|
||||
focusPeer: "{{ peer }}"
|
||||
};
|
||||
</script>
|
||||
<script src="/static/federation_explore.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,6 +6,7 @@
|
||||
<div class="hero-text">
|
||||
<h1 class="hero-title">Defensive CTI in motion</h1>
|
||||
<p class="hero-sub">What psyc has seen and done — at a glance.</p>
|
||||
<p class="hero-meta"><a class="hero-explore" href="/federation/explore">Federation Explorer →</a> <span class="hero-explore-sub">public · auditable trust network</span></p>
|
||||
</div>
|
||||
<a class="hero-cta" href="/cases">All cases →</a>
|
||||
</section>
|
||||
|
||||
@@ -38,6 +38,7 @@ _log = log.get(__name__)
|
||||
SIGNAL_WINDOW_HOURS = 24
|
||||
TRANSITIVE_CACHE_TTL = 300.0 # 5 minutes
|
||||
TRANSITIVE_FETCH_TIMEOUT = 4.0
|
||||
EXPLORE_FETCH_TIMEOUT = 4.0
|
||||
|
||||
|
||||
# ---------- data model --------------------------------------------------
|
||||
@@ -773,3 +774,256 @@ def build_admin_view(include_transitive: bool = True) -> Dict[str, Any]:
|
||||
"stats": view.stats,
|
||||
"generated_at": view.generated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ---------- public explore payload --------------------------------------
|
||||
#
|
||||
# "Transparent security" view: the same shape a peer would see at
|
||||
# /federation/network, plus per-peer counts (NEVER values), inbound vouches,
|
||||
# and a thin distance-2 snapshot — enough for a public visitor to draw the
|
||||
# mesh and walk to any peer's own explore page. Everything is signed.
|
||||
|
||||
EXPLORE_TRANSITIVE_CAP = 50 # cap on distinct distance-2 fps to keep payload bounded
|
||||
|
||||
|
||||
def _explore_peer_stats(
|
||||
peer_fp: str,
|
||||
now: datetime,
|
||||
signals_by_peer: Dict[str, List[Dict[str, Any]]],
|
||||
all_signal_counts: Dict[str, int],
|
||||
quorum_cache: Dict[str, bool],
|
||||
) -> Dict[str, Any]:
|
||||
"""Per-peer COUNTS only — no IOC values, no case summaries, no raw_json.
|
||||
|
||||
Counts split by signal_type (cases vs iocs) are safe to expose since
|
||||
the magnitude of "how chatty is this peer" is already implicit in the
|
||||
24h signal count. We deliberately omit severity + ioc_type breakdowns
|
||||
here — those could hint at the target sector.
|
||||
"""
|
||||
rows = signals_by_peer.get(peer_fp, [])
|
||||
cases_24h = 0
|
||||
iocs_24h = 0
|
||||
last_seen_iso = ""
|
||||
seen_hashes: set = set()
|
||||
quorum_contribution_24h = 0
|
||||
for row in rows:
|
||||
st = row.get("signal_type") or ""
|
||||
if st == "case":
|
||||
cases_24h += 1
|
||||
elif st == "ioc":
|
||||
iocs_24h += 1
|
||||
h = row.get("signal_hash") or ""
|
||||
if h and h not in seen_hashes:
|
||||
seen_hashes.add(h)
|
||||
if h not in quorum_cache:
|
||||
quorum_cache[h] = federation.is_quorum_met(h)
|
||||
if quorum_cache[h]:
|
||||
quorum_contribution_24h += 1
|
||||
if rows:
|
||||
# recent_signals returns newest-first → first row is latest.
|
||||
last_seen_iso = str(rows[0].get("received_at") or "")
|
||||
return {
|
||||
"signal_count_24h": len(rows),
|
||||
"signal_count_total": all_signal_counts.get(peer_fp, 0),
|
||||
"cases_24h": cases_24h,
|
||||
"iocs_24h": iocs_24h,
|
||||
"quorum_contribution_24h": quorum_contribution_24h,
|
||||
"last_seen": last_seen_iso or None,
|
||||
}
|
||||
|
||||
|
||||
def _fetch_peer_explore(domain: str, timeout: float = EXPLORE_FETCH_TIMEOUT) -> Optional[Dict[str, Any]]:
|
||||
"""GET /federation/explore/data on a peer. Returns dict on success.
|
||||
|
||||
Mirrors `_fetch_peer_network`'s failure semantics: one slow/broken peer
|
||||
must never abort the explore walk.
|
||||
"""
|
||||
if not domain:
|
||||
return None
|
||||
url = f"https://{domain}/federation/explore/data"
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
r = client.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_log.info("network_view.explore.transitive.skip", domain=domain, reason=str(exc)[:120])
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _explore_transitive_peers(
|
||||
trusted_peers: List[Tuple[str, Optional[str]]],
|
||||
own_fp: str,
|
||||
own_peer_fps: set,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Distance-2 fps learned from trusted peers' explore/data feeds.
|
||||
|
||||
Returns [{fingerprint, via_peer_fingerprint, domain}] entries. Capped at
|
||||
`EXPLORE_TRANSITIVE_CAP` to keep the public payload bounded — first peer
|
||||
to introduce a fingerprint wins so the via attribution stays stable.
|
||||
"""
|
||||
seen: set = set(own_peer_fps)
|
||||
seen.add(own_fp)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for parent_fp, parent_domain in trusted_peers:
|
||||
if not parent_domain or len(out) >= EXPLORE_TRANSITIVE_CAP:
|
||||
continue
|
||||
data = _fetch_peer_explore(parent_domain)
|
||||
if not data:
|
||||
# Fall back to the older /federation/network endpoint — older
|
||||
# psyc nodes won't have /federation/explore/data yet.
|
||||
data = _fetch_peer_network(parent_domain)
|
||||
if not data:
|
||||
continue
|
||||
their_peers = data.get("peers") or []
|
||||
for pp in their_peers:
|
||||
if not isinstance(pp, dict):
|
||||
continue
|
||||
fp = str(pp.get("fingerprint") or "")
|
||||
if not fp or fp in seen:
|
||||
continue
|
||||
seen.add(fp)
|
||||
out.append({
|
||||
"fingerprint": fp,
|
||||
"domain": pp.get("domain") or None,
|
||||
"via_peer_fingerprint": parent_fp,
|
||||
})
|
||||
if len(out) >= EXPLORE_TRANSITIVE_CAP:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def build_explore_view(node_domain: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Signed public explorer payload for /federation/explore/data.
|
||||
|
||||
Extends `build_public_view` with:
|
||||
* `node` — headline stats about THIS node (counts only)
|
||||
* `peers[].*_count_24h` — per-peer chatter levels (no values leak)
|
||||
* `vouches_in` — who has vouched for us (we only include vouchers
|
||||
whose peer we currently trust, so signatures don't
|
||||
leak unknown identities)
|
||||
* `transitive_peers` — distance-2 fingerprints learned from each
|
||||
trusted peer's public explore/network feed.
|
||||
Cached aggressively (mirrors transitive cache).
|
||||
* `corroboration_count_24h` — # distinct signal_hashes seen from ≥2
|
||||
peers in the 24h window.
|
||||
|
||||
The whole payload (sans signature) is Ed25519-signed over canonical JSON.
|
||||
No IOC values, case_ids, raw_json, severity or ioc-type breakdowns are
|
||||
included — anything that could leak the target sector or who reported
|
||||
what stays inside `build_admin_view`.
|
||||
"""
|
||||
our_fp = federation.node_fingerprint()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Reuse the 24h signal bucket scan + all-time count + quorum cache.
|
||||
signals_by_peer, fresh_signals = _index_signals_24h(now)
|
||||
all_signal_counts = _all_signals_by_peer_count()
|
||||
quorum_cache: Dict[str, bool] = {}
|
||||
|
||||
# Build the trusted-peer rows (the only ones we expose), with public-safe
|
||||
# stats. Unknown + blocked never leak — see `build_public_view`.
|
||||
peer_rows: List[Dict[str, Any]] = []
|
||||
trusted_peers_for_walk: List[Tuple[str, Optional[str]]] = []
|
||||
trusted_fps: set = set()
|
||||
for p in federation.list_peers():
|
||||
if p.status != "trusted":
|
||||
continue
|
||||
trusted_fps.add(p.fingerprint)
|
||||
trusted_peers_for_walk.append((p.fingerprint, p.domain))
|
||||
stats = _explore_peer_stats(
|
||||
peer_fp=p.fingerprint,
|
||||
now=now,
|
||||
signals_by_peer=signals_by_peer,
|
||||
all_signal_counts=all_signal_counts,
|
||||
quorum_cache=quorum_cache,
|
||||
)
|
||||
peer_rows.append({
|
||||
"domain": p.domain,
|
||||
"fingerprint": p.fingerprint,
|
||||
"first_seen": p.discovered_at,
|
||||
**stats,
|
||||
})
|
||||
|
||||
# Vouches WE've issued — same shape as build_public_view + signature.
|
||||
vouches_out: List[Dict[str, Any]] = []
|
||||
for v in federation.our_vouches():
|
||||
vouches_out.append({
|
||||
"voucher_fingerprint": v.voucher_fingerprint,
|
||||
"target_fingerprint": v.target_fingerprint,
|
||||
"issued_at": v.issued_at.isoformat(),
|
||||
"expires_at": v.expires_at.isoformat() if v.expires_at else None,
|
||||
"signature": v.signature,
|
||||
})
|
||||
|
||||
# Vouches IN — only those naming us as target where we trust the voucher.
|
||||
# We don't surface vouches from unknown identities: doing so would let any
|
||||
# stranger forge an inbound vouch and show up here.
|
||||
vouches_in: List[Dict[str, Any]] = []
|
||||
for row in db.list_vouches():
|
||||
if (row.get("target_fingerprint") or "") != our_fp:
|
||||
continue
|
||||
voucher_fp = row.get("voucher_fingerprint") or ""
|
||||
if voucher_fp == our_fp:
|
||||
continue
|
||||
if voucher_fp not in trusted_fps:
|
||||
continue
|
||||
vouches_in.append({
|
||||
"voucher_fingerprint": voucher_fp,
|
||||
"target_fingerprint": our_fp,
|
||||
"issued_at": row.get("issued_at") or "",
|
||||
"expires_at": row.get("expires_at") or None,
|
||||
"signature": row.get("signature") or "",
|
||||
})
|
||||
|
||||
# Transitive snapshot. The aim is "one fetch surfaces N hops" — distance-2
|
||||
# fingerprints learned from each trusted peer's own explore/network feed.
|
||||
transitive_peers = _explore_transitive_peers(
|
||||
trusted_peers_for_walk, our_fp, trusted_fps,
|
||||
)
|
||||
|
||||
# Corroboration: # distinct hashes seen from ≥2 distinct peers in 24h.
|
||||
by_hash: Dict[str, set] = {}
|
||||
for row in fresh_signals:
|
||||
h = row.get("signal_hash") or ""
|
||||
if not h:
|
||||
continue
|
||||
by_hash.setdefault(h, set()).add(row.get("peer_fingerprint") or "")
|
||||
corroboration_count_24h = sum(1 for fps in by_hash.values() if len(fps) >= 2)
|
||||
|
||||
# Transparency log headline numbers — chain head + length, never bodies.
|
||||
head_entry = translog.head()
|
||||
translog_head_hash = head_entry.entry_hash if head_entry else None
|
||||
translog_entry_count = int(head_entry.id) if head_entry else 0
|
||||
|
||||
node_block: Dict[str, Any] = {
|
||||
"fingerprint": our_fp,
|
||||
"domain": node_domain,
|
||||
"generated_at": now.isoformat(),
|
||||
"transparency_log_head_hash": translog_head_hash,
|
||||
"translog_entry_count": translog_entry_count,
|
||||
"peer_count": len(peer_rows),
|
||||
"vouches_out_count": len(vouches_out),
|
||||
"vouches_in_count": len(vouches_in),
|
||||
"corroboration_count_24h": corroboration_count_24h,
|
||||
"signals_count_24h": sum(p["signal_count_24h"] for p in peer_rows),
|
||||
}
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"version": federation.FEED_VERSION,
|
||||
"fingerprint": our_fp,
|
||||
"generated_at": now.isoformat(),
|
||||
"node": node_block,
|
||||
"peers": peer_rows,
|
||||
"vouches": vouches_out, # kept for shape-compat with /federation/network
|
||||
"vouches_out": vouches_out,
|
||||
"vouches_in": vouches_in,
|
||||
"transitive_peers": transitive_peers,
|
||||
"corroboration_count_24h": corroboration_count_24h,
|
||||
}
|
||||
sig = federation.sign_payload(federation.canonical_json(payload))
|
||||
payload["signature"] = base64.b64encode(sig).decode("ascii")
|
||||
return payload
|
||||
|
||||
427
tests/test_explore_view.py
Normal file
427
tests/test_explore_view.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""Federation explore view — public transparency payload tests.
|
||||
|
||||
Sibling to `test_network_view.py`; focused on the explore-only shape:
|
||||
shape contract, signature round-trip, no-leak invariants, transitive walk,
|
||||
inbound vouches filter, and the corroboration counter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from psyc import db
|
||||
from psyc.cockpit import federation_routes
|
||||
from psyc.lines import federation, network_view, translog
|
||||
from psyc.lines.network_view import build_explore_view
|
||||
|
||||
|
||||
# ---------- fixtures ----------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
test_db = tmp_path / "test.db"
|
||||
eng = create_engine(f"sqlite:///{test_db}", future=True)
|
||||
db._metadata.create_all(eng, checkfirst=True)
|
||||
monkeypatch.setattr(db, "_engine", eng)
|
||||
monkeypatch.setattr(db, "DB_PATH", test_db)
|
||||
yield test_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fed_dir(tmp_path, monkeypatch):
|
||||
d = tmp_path / "federation"
|
||||
monkeypatch.setattr(federation, "FED_DIR", d)
|
||||
monkeypatch.setattr(federation, "PRIVATE_KEY_PATH", d / "node.key")
|
||||
monkeypatch.setattr(federation, "PUBLIC_KEY_PATH", d / "node.pub")
|
||||
yield d
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_explore_caches(monkeypatch):
|
||||
"""Prevent route-level cache bleed between tests."""
|
||||
monkeypatch.setattr(network_view, "_TRANSITIVE_CACHE", {"ts": 0.0, "view": None})
|
||||
federation_routes._FEED_CACHE["payload"] = None
|
||||
federation_routes._PUBLIC_PEERS_CACHE["payload"] = None
|
||||
federation_routes._PUBLIC_NETWORK_CACHE["payload"] = None
|
||||
if hasattr(federation_routes, "_EXPLORE_CACHE"):
|
||||
federation_routes._EXPLORE_CACHE["payload"] = None
|
||||
yield
|
||||
|
||||
|
||||
def _make_peer_pubkey() -> tuple[str, str]:
|
||||
"""Return (fingerprint, pubkey_pem) for a synthetic peer keypair."""
|
||||
import hashlib
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
priv = ed25519.Ed25519PrivateKey.generate()
|
||||
pub = priv.public_key()
|
||||
pem = pub.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("ascii")
|
||||
raw = pub.public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
fp = hashlib.sha256(raw).digest()[:16].hex()
|
||||
return fp, pem
|
||||
|
||||
|
||||
def _silence_explore_fetch():
|
||||
return patch.object(network_view, "_fetch_peer_explore", return_value=None)
|
||||
|
||||
|
||||
def _silence_network_fetch():
|
||||
return patch.object(network_view, "_fetch_peer_network", return_value=None)
|
||||
|
||||
|
||||
# ---------- schema ------------------------------------------------------
|
||||
|
||||
def test_explore_view_top_level_shape(fresh_db, fed_dir):
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
payload = build_explore_view(node_domain="me.example")
|
||||
for key in (
|
||||
"version", "fingerprint", "generated_at",
|
||||
"node", "peers", "vouches", "vouches_out", "vouches_in",
|
||||
"transitive_peers", "corroboration_count_24h", "signature",
|
||||
):
|
||||
assert key in payload, f"missing {key}"
|
||||
node = payload["node"]
|
||||
for key in (
|
||||
"fingerprint", "domain", "generated_at",
|
||||
"transparency_log_head_hash", "translog_entry_count",
|
||||
"peer_count", "vouches_out_count", "vouches_in_count",
|
||||
"corroboration_count_24h", "signals_count_24h",
|
||||
):
|
||||
assert key in node, f"missing node.{key}"
|
||||
assert node["domain"] == "me.example"
|
||||
assert node["fingerprint"] == federation.node_fingerprint()
|
||||
|
||||
|
||||
def test_explore_peer_carries_public_safe_stats(fresh_db, fed_dir):
|
||||
fp, pem = _make_peer_pubkey()
|
||||
federation.register_peer("peer.example", fp, pem, status="trusted")
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
for i in range(3):
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="ioc",
|
||||
signal_id=f"1.2.3.{i}",
|
||||
signal_hash=f"hash-{i}",
|
||||
received_at=now_iso,
|
||||
raw_json=json.dumps({"type": "ip", "value": f"1.2.3.{i}"}),
|
||||
))
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
payload = build_explore_view()
|
||||
assert len(payload["peers"]) == 1
|
||||
p = payload["peers"][0]
|
||||
# Public-safe stats present.
|
||||
for key in (
|
||||
"signal_count_24h", "signal_count_total",
|
||||
"cases_24h", "iocs_24h",
|
||||
"quorum_contribution_24h", "last_seen",
|
||||
):
|
||||
assert key in p
|
||||
assert p["signal_count_24h"] == 3
|
||||
assert p["iocs_24h"] == 3
|
||||
assert p["cases_24h"] == 0
|
||||
# Sensitive fields are not surfaced per-peer.
|
||||
assert "severity_breakdown" not in p
|
||||
assert "ioc_type_breakdown" not in p
|
||||
assert "recent_translog" not in p
|
||||
|
||||
|
||||
# ---------- signature round-trip ---------------------------------------
|
||||
|
||||
def test_explore_view_signature_round_trip(fresh_db, fed_dir):
|
||||
fp, pem = _make_peer_pubkey()
|
||||
federation.register_peer("trusted.example", fp, pem, status="trusted")
|
||||
federation.issue_vouch(fp, ttl_days=30)
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
payload = build_explore_view()
|
||||
assert "signature" in payload
|
||||
sig = base64.b64decode(payload["signature"])
|
||||
unsigned = {k: v for k, v in payload.items() if k != "signature"}
|
||||
assert federation.verify_payload(
|
||||
federation.canonical_json(unsigned),
|
||||
sig,
|
||||
federation.public_key_pem(),
|
||||
) is True
|
||||
|
||||
|
||||
# ---------- no-leak invariants -----------------------------------------
|
||||
|
||||
def test_explore_view_has_no_ioc_values_or_case_ids_or_raw_json(fresh_db, fed_dir):
|
||||
"""Public payload must not expose IOC values, case_ids in raw form, or raw_json.
|
||||
|
||||
This is the core transparency-vs-leakage contract: anyone can see who's
|
||||
talking to whom and how much, but never what they're saying.
|
||||
"""
|
||||
fp, pem = _make_peer_pubkey()
|
||||
federation.register_peer("trusted.example", fp, pem, status="trusted")
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="ioc",
|
||||
signal_id="evil-domain-do-not-leak.com",
|
||||
signal_hash="ioc-hash-leak",
|
||||
received_at=now_iso,
|
||||
raw_json=json.dumps({"type": "domain", "value": "evil-domain-do-not-leak.com"}),
|
||||
))
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="case",
|
||||
signal_id="CASE-SECRET-42",
|
||||
signal_hash="case-hash-leak",
|
||||
received_at=now_iso,
|
||||
raw_json=json.dumps({"severity": "critical", "case_id": "CASE-SECRET-42"}),
|
||||
))
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
payload = build_explore_view()
|
||||
flat = json.dumps(payload, default=str)
|
||||
# IOC values.
|
||||
assert "evil-domain-do-not-leak.com" not in flat
|
||||
# Case ids (raw).
|
||||
assert "CASE-SECRET-42" not in flat
|
||||
# raw_json shape never serialized.
|
||||
assert "raw_json" not in flat
|
||||
# Sector-leaking breakdowns.
|
||||
assert "severity_breakdown" not in flat
|
||||
assert "ioc_type_breakdown" not in flat
|
||||
|
||||
|
||||
# ---------- transitive peers --------------------------------------------
|
||||
|
||||
def test_explore_transitive_peers_populated_from_peer_responses(fresh_db, fed_dir):
|
||||
direct_fp, direct_pem = _make_peer_pubkey()
|
||||
federation.register_peer("direct.example", direct_fp, direct_pem, status="trusted")
|
||||
far_a, _ = _make_peer_pubkey()
|
||||
far_b, _ = _make_peer_pubkey()
|
||||
fake_payload: Dict[str, Any] = {
|
||||
"fingerprint": direct_fp,
|
||||
"peers": [
|
||||
{"fingerprint": far_a, "domain": "far-a.example"},
|
||||
{"fingerprint": far_b, "domain": "far-b.example"},
|
||||
],
|
||||
"vouches": [],
|
||||
}
|
||||
with patch.object(network_view, "_fetch_peer_explore", return_value=fake_payload):
|
||||
payload = build_explore_view()
|
||||
tps = payload["transitive_peers"]
|
||||
fps = {t["fingerprint"] for t in tps}
|
||||
assert far_a in fps
|
||||
assert far_b in fps
|
||||
via_fps = {t["via_peer_fingerprint"] for t in tps}
|
||||
assert via_fps == {direct_fp}
|
||||
|
||||
|
||||
def test_explore_transitive_peers_falls_back_to_network_endpoint(fresh_db, fed_dir):
|
||||
"""If a peer doesn't have /federation/explore/data (older node), fall back
|
||||
to /federation/network — the public-view shape is the same {fingerprint, peers}."""
|
||||
direct_fp, direct_pem = _make_peer_pubkey()
|
||||
federation.register_peer("direct.example", direct_fp, direct_pem, status="trusted")
|
||||
far_fp, _ = _make_peer_pubkey()
|
||||
fallback_payload: Dict[str, Any] = {
|
||||
"fingerprint": direct_fp,
|
||||
"peers": [{"fingerprint": far_fp, "domain": "far.example"}],
|
||||
"vouches": [],
|
||||
}
|
||||
with patch.object(network_view, "_fetch_peer_explore", return_value=None), \
|
||||
patch.object(network_view, "_fetch_peer_network", return_value=fallback_payload):
|
||||
payload = build_explore_view()
|
||||
assert any(t["fingerprint"] == far_fp for t in payload["transitive_peers"])
|
||||
|
||||
|
||||
def test_explore_transitive_peers_dedupe_against_direct(fresh_db, fed_dir):
|
||||
"""If a transitive fp is already a direct peer, don't duplicate it."""
|
||||
direct_fp, direct_pem = _make_peer_pubkey()
|
||||
federation.register_peer("direct.example", direct_fp, direct_pem, status="trusted")
|
||||
fake_payload = {
|
||||
"fingerprint": direct_fp,
|
||||
# Direct peer's own fp echoed back — must be deduped.
|
||||
"peers": [{"fingerprint": direct_fp, "domain": "direct.example"}],
|
||||
"vouches": [],
|
||||
}
|
||||
with patch.object(network_view, "_fetch_peer_explore", return_value=fake_payload):
|
||||
payload = build_explore_view()
|
||||
assert payload["transitive_peers"] == []
|
||||
|
||||
|
||||
# ---------- vouches_in --------------------------------------------------
|
||||
|
||||
def test_explore_vouches_in_filters_to_target_self_and_trusted_vouchers(fresh_db, fed_dir):
|
||||
"""vouches_in includes ONLY entries naming us as target whose voucher we trust."""
|
||||
our_fp = federation.node_fingerprint()
|
||||
fp_trusted, pem_t = _make_peer_pubkey()
|
||||
fp_unknown, pem_u = _make_peer_pubkey()
|
||||
federation.register_peer("trusted.example", fp_trusted, pem_t, status="trusted")
|
||||
federation.register_peer("unknown.example", fp_unknown, pem_u, status="unknown")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
# Trusted peer vouches for us.
|
||||
db.upsert_vouch(dict(
|
||||
voucher_fingerprint=fp_trusted,
|
||||
target_fingerprint=our_fp,
|
||||
issued_at=now,
|
||||
expires_at=None,
|
||||
signature="trusted-sig",
|
||||
))
|
||||
# Unknown peer also "vouches" for us — must NOT leak.
|
||||
db.upsert_vouch(dict(
|
||||
voucher_fingerprint=fp_unknown,
|
||||
target_fingerprint=our_fp,
|
||||
issued_at=now,
|
||||
expires_at=None,
|
||||
signature="rogue-sig",
|
||||
))
|
||||
# Vouch naming someone else — must NOT appear in vouches_in.
|
||||
other_fp, _ = _make_peer_pubkey()
|
||||
db.upsert_vouch(dict(
|
||||
voucher_fingerprint=fp_trusted,
|
||||
target_fingerprint=other_fp,
|
||||
issued_at=now,
|
||||
expires_at=None,
|
||||
signature="other-sig",
|
||||
))
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
payload = build_explore_view()
|
||||
vouchers = {v["voucher_fingerprint"] for v in payload["vouches_in"]}
|
||||
assert vouchers == {fp_trusted}
|
||||
# And the rogue signature is not anywhere in the payload.
|
||||
assert "rogue-sig" not in json.dumps(payload, default=str)
|
||||
|
||||
|
||||
# ---------- corroboration counter --------------------------------------
|
||||
|
||||
def test_explore_corroboration_count_matches_distinct_shared_hashes(fresh_db, fed_dir):
|
||||
fp_a, pem_a = _make_peer_pubkey()
|
||||
fp_b, pem_b = _make_peer_pubkey()
|
||||
federation.register_peer("a.example", fp_a, pem_a, status="trusted")
|
||||
federation.register_peer("b.example", fp_b, pem_b, status="trusted")
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
# Two shared hashes between A and B.
|
||||
for h in ("shared-1", "shared-2"):
|
||||
for fp in (fp_a, fp_b):
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="ioc",
|
||||
signal_id="x",
|
||||
signal_hash=h,
|
||||
received_at=now_iso,
|
||||
raw_json="{}",
|
||||
))
|
||||
# One solo hash — must NOT count.
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp_a,
|
||||
signal_type="ioc",
|
||||
signal_id="solo",
|
||||
signal_hash="solo-hash",
|
||||
received_at=now_iso,
|
||||
raw_json="{}",
|
||||
))
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
payload = build_explore_view()
|
||||
assert payload["corroboration_count_24h"] == 2
|
||||
assert payload["node"]["corroboration_count_24h"] == 2
|
||||
|
||||
|
||||
# ---------- transparency log headline ----------------------------------
|
||||
|
||||
def test_explore_node_translog_headline_reflects_chain(fresh_db, fed_dir):
|
||||
translog.append("vouch", {"foo": "bar"})
|
||||
translog.append("signal", {"x": 1})
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
payload = build_explore_view()
|
||||
node = payload["node"]
|
||||
assert node["translog_entry_count"] == 2
|
||||
assert isinstance(node["transparency_log_head_hash"], str)
|
||||
assert len(node["transparency_log_head_hash"]) == 64 # hex sha256
|
||||
|
||||
|
||||
# ---------- HTTP routes -------------------------------------------------
|
||||
|
||||
def _mk_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret")
|
||||
import tempfile
|
||||
from pathlib import Path as _Path
|
||||
# We need real templates for /federation/explore HTML response.
|
||||
here = _Path(__file__).resolve().parent.parent / "src" / "psyc" / "cockpit" / "templates"
|
||||
templates = Jinja2Templates(directory=str(here))
|
||||
federation_routes.register(app, templates)
|
||||
return app
|
||||
|
||||
|
||||
def test_federation_explore_endpoint_returns_html(fresh_db, fed_dir):
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
client = TestClient(_mk_app())
|
||||
r = client.get("/federation/explore")
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers.get("content-type", "")
|
||||
# Banner + page title are present.
|
||||
body = r.text
|
||||
assert "Federation Explorer" in body
|
||||
|
||||
|
||||
def test_federation_explore_data_returns_signed_json(fresh_db, fed_dir):
|
||||
fp, pem = _make_peer_pubkey()
|
||||
federation.register_peer("trusted.example", fp, pem, status="trusted")
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
client = TestClient(_mk_app())
|
||||
r = client.get("/federation/explore/data")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "signature" in data
|
||||
assert "node" in data
|
||||
sig = base64.b64decode(data["signature"])
|
||||
unsigned = {k: v for k, v in data.items() if k != "signature"}
|
||||
assert federation.verify_payload(
|
||||
federation.canonical_json(unsigned),
|
||||
sig,
|
||||
federation.public_key_pem(),
|
||||
) is True
|
||||
|
||||
|
||||
def test_federation_explore_data_has_cors_header(fresh_db, fed_dir):
|
||||
"""Other psyc nodes' explore pages need to fetch this from the browser."""
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
client = TestClient(_mk_app())
|
||||
r = client.get("/federation/explore/data")
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get("access-control-allow-origin") == "*"
|
||||
|
||||
|
||||
def test_federation_info_has_explore_and_cors(fresh_db, fed_dir):
|
||||
client = TestClient(_mk_app())
|
||||
r = client.get("/federation/info")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("explore") == "/federation/explore"
|
||||
assert r.headers.get("access-control-allow-origin") == "*"
|
||||
|
||||
|
||||
def test_existing_public_endpoints_have_cors_header(fresh_db, fed_dir):
|
||||
"""All public endpoints must be cross-origin fetchable for the explorer."""
|
||||
client = TestClient(_mk_app())
|
||||
for path in (
|
||||
"/federation/key",
|
||||
"/federation/feed",
|
||||
"/federation/vouches",
|
||||
"/federation/log",
|
||||
"/federation/log/verify",
|
||||
"/federation/peers/public",
|
||||
"/federation/network",
|
||||
):
|
||||
r = client.get(path)
|
||||
assert r.status_code in (200, 409), f"{path} status {r.status_code}"
|
||||
assert r.headers.get("access-control-allow-origin") == "*", f"{path} missing CORS"
|
||||
@@ -18,6 +18,7 @@ from psyc.lines.network_view import (
|
||||
NetworkNode,
|
||||
NetworkView,
|
||||
build_admin_view,
|
||||
build_explore_view,
|
||||
build_local_view,
|
||||
build_public_view,
|
||||
build_transitive_view,
|
||||
@@ -623,6 +624,49 @@ def test_admin_view_recent_translog_per_peer(fresh_db, fed_dir):
|
||||
assert set(row.keys()) == {"id", "entry_type", "timestamp", "hash"}
|
||||
|
||||
|
||||
def test_explore_view_omits_ioc_values_case_ids_and_raw_json(fresh_db, fed_dir):
|
||||
"""The public explore payload must NEVER expose IOC values, case_ids, or raw_json.
|
||||
|
||||
This is the load-bearing transparency-vs-leakage contract that lives at
|
||||
the network-view layer — anyone can audit who's talking to whom and how
|
||||
much, but never *what* they're saying.
|
||||
"""
|
||||
fp, pem = _make_peer_pubkey()
|
||||
federation.register_peer("trusted.example", fp, pem, status="trusted")
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="ioc",
|
||||
signal_id="evil-domain-do-not-leak.com",
|
||||
signal_hash="ioc-hash-leak",
|
||||
received_at=now_iso,
|
||||
raw_json=json.dumps({"type": "domain", "value": "evil-domain-do-not-leak.com"}),
|
||||
))
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="case",
|
||||
signal_id="CASE-SECRET-42",
|
||||
signal_hash="case-hash-leak",
|
||||
received_at=now_iso,
|
||||
raw_json=json.dumps({"severity": "critical", "case_id": "CASE-SECRET-42"}),
|
||||
))
|
||||
with patch.object(network_view, "_fetch_peer_explore", return_value=None), \
|
||||
patch.object(network_view, "_fetch_peer_network", return_value=None):
|
||||
payload = build_explore_view()
|
||||
flat = json.dumps(payload, default=str)
|
||||
assert "evil-domain-do-not-leak.com" not in flat
|
||||
assert "CASE-SECRET-42" not in flat
|
||||
assert "raw_json" not in flat
|
||||
# Sector-leaking breakdowns must not appear either.
|
||||
assert "severity_breakdown" not in flat
|
||||
assert "ioc_type_breakdown" not in flat
|
||||
# And peer rows carry only public-safe counts.
|
||||
for p in payload.get("peers", []):
|
||||
assert "severity_breakdown" not in p
|
||||
assert "ioc_type_breakdown" not in p
|
||||
assert "recent_translog" not in p
|
||||
|
||||
|
||||
def test_public_view_still_has_no_stats(fresh_db, fed_dir):
|
||||
"""Public payload must not surface admin-only enrichments — sensitive.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user