stage-fed-e federation: cockpit admin page + public feed routes

This commit is contained in:
m17hr1l
2026-06-06 16:10:19 +02:00
parent 55ffd9da3d
commit 17b94acf6b
2 changed files with 257 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
"""Federation cockpit routes — admin page, public feed/key/info endpoints.
Wired into the FastAPI app by app.py via a single `register(app, TEMPLATES)`
call so the federation surface stays self-contained.
"""
from __future__ import annotations
import json
import time
from typing import Any, Dict, Optional, Tuple
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from psyc import db, log
from psyc.lines import federation
_log = log.get(__name__)
# Tiny in-memory cache for the signed feed — peers may poll, recomputing
# canonical JSON + signature on every hit would be wasteful.
_FEED_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None}
_FEED_TTL = 60.0
def _admin_ok(request: Request) -> bool:
return bool(request.session.get("admin_ok"))
def _cached_feed() -> Dict[str, Any]:
now = time.time()
if _FEED_CACHE["payload"] is None or (now - _FEED_CACHE["ts"]) > _FEED_TTL:
_FEED_CACHE["payload"] = federation.build_signed_feed()
_FEED_CACHE["ts"] = now
return _FEED_CACHE["payload"]
def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
"""Mount all federation routes onto `app`."""
@app.get("/admin/federation", response_class=HTMLResponse)
def admin_federation(request: Request) -> HTMLResponse:
if not _admin_ok(request):
return RedirectResponse("/admin", status_code=303)
host = request.url.hostname or "your-node.example"
suggested = request.query_params.get("domain", host)
rec = federation.dns_record(suggested)
peers = federation.list_peers()
signals = db.recent_signals(limit=20)
return TEMPLATES.TemplateResponse(
request,
"admin_federation.html",
{
"fingerprint": federation.node_fingerprint(),
"pubkey_pem": federation.public_key_pem(),
"suggested_domain": suggested,
"dns": rec,
"peers": peers,
"signals": signals,
},
)
@app.post("/admin/federation/peers/add")
def admin_federation_add_peer(
request: Request,
domain: str = Form(...),
fingerprint: str = Form(...),
pubkey_pem: str = Form(...),
status: str = Form("unknown"),
) -> RedirectResponse:
if not _admin_ok(request):
raise HTTPException(status_code=403, detail="admin session required")
try:
federation.register_peer(domain.strip(), fingerprint.strip(), pubkey_pem.strip(), status=status)
except Exception as exc:
_log.warning("federation.peer.add.error", domain=domain, error=str(exc))
return RedirectResponse("/admin/federation", status_code=303)
@app.post("/admin/federation/peers/{domain}/status")
def admin_federation_set_status(
request: Request,
domain: str,
status: str = Form(...),
) -> RedirectResponse:
if not _admin_ok(request):
raise HTTPException(status_code=403, detail="admin session required")
try:
federation.set_peer_status(domain, status)
except ValueError as exc:
_log.warning("federation.peer.status.bad", domain=domain, status=status, error=str(exc))
return RedirectResponse("/admin/federation", status_code=303)
@app.post("/admin/federation/peers/{domain}/remove")
def admin_federation_remove(
request: Request,
domain: str,
) -> RedirectResponse:
if not _admin_ok(request):
raise HTTPException(status_code=403, detail="admin session required")
federation.remove_peer(domain)
return RedirectResponse("/admin/federation", status_code=303)
# ---------- public endpoints --------------------------------------
@app.get("/federation/info")
def federation_info() -> JSONResponse:
return JSONResponse({
"fingerprint": federation.node_fingerprint(),
"version": federation.FEED_VERSION,
"feed": federation.FEED_PATH,
"key": "/federation/key",
})
@app.get("/federation/key", response_class=PlainTextResponse)
def federation_key() -> PlainTextResponse:
return PlainTextResponse(federation.public_key_pem(), media_type="text/plain")
@app.get("/federation/feed")
def federation_feed() -> JSONResponse:
return JSONResponse(_cached_feed())
_log.info("federation.routes.registered")

View File

@@ -0,0 +1,132 @@
{% extends "base.html" %}
{% block title %}Federation — psyc admin{% endblock %}
{% block content %}
<section class="panel">
<div class="panel-head">
<h1>Federation Identity</h1>
<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></p>
<div class="card" style="margin-bottom:14px;">
<div class="lg-sub">node fingerprint</div>
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:18px; word-break:break-all; margin:6px 0 12px; color:var(--accent); text-shadow:0 0 12px var(--accent-glow);">{{ fingerprint }}</div>
<details>
<summary class="lg-sub" style="cursor:pointer;">public key (PEM)</summary>
<pre style="background:var(--panel-2); border:1px solid var(--border); border-radius:6px; padding:10px; margin-top:8px; overflow-x:auto; font-size:11.5px;">{{ pubkey_pem }}</pre>
</details>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Publish via DNS</h2>
<span class="count">SRV + TXT records</span>
</div>
<p class="page-intro">Paste these into your zone file. Once they're live, any peer that knows your domain can discover the node and pin the right key without out-of-band coordination.</p>
<form method="get" action="/admin/federation" class="lookup-form" style="margin-bottom:12px;">
<input type="text" name="domain" value="{{ suggested_domain }}" class="lookup-input" placeholder="domain to publish on (e.g. psyc.example.com)">
<button type="submit" class="btn btn-enforce">regenerate</button>
</form>
<pre style="background:var(--panel-2); border:1px solid var(--border); border-radius:6px; padding:12px; overflow-x:auto; font-size:12px; line-height:1.5;">{{ dns.human_instructions }}</pre>
</section>
<section class="panel">
<div class="panel-head">
<h2>Known Peers</h2>
<span class="count">{{ peers|length }} registered</span>
</div>
<p class="page-intro">Trusted peers' feeds are signature-verified on every poll. Blocked peers are recorded but ignored. Unknown peers are kept for review — nothing flows from them until you set them trusted.</p>
{% if peers %}
<table class="ledger">
<thead><tr><th>Domain</th><th>Fingerprint</th><th>Status</th><th>Discovered</th><th>Last seen</th><th></th></tr></thead>
<tbody>
{% for p in peers %}
<tr class="ledger-row">
<td><strong>{{ p.domain }}</strong></td>
<td class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">{{ p.fingerprint[:8] }}…{{ p.fingerprint[-8:] }}</td>
<td>
{% if p.status == 'trusted' %}
<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">trusted</span>
{% elif p.status == 'blocked' %}
<span class="sev-badge" style="background:rgba(248,113,113,0.10); color:var(--red); border-color:var(--red);">blocked</span>
{% else %}
<span class="sev-badge">unknown</span>
{% endif %}
</td>
<td class="lg-ts">{{ (p.discovered_at or '')[:16] | replace('T', ' ') }}</td>
<td class="lg-ts">{{ ((p.last_seen or '')[:16] | replace('T', ' ')) or '—' }}</td>
<td>
<form method="post" action="/admin/federation/peers/{{ p.domain }}/status" class="queue-action">
<input type="hidden" name="status" value="trusted">
<button type="submit" class="btn btn-enforce" {% if p.status == 'trusted' %}disabled{% endif %}>trust</button>
</form>
<form method="post" action="/admin/federation/peers/{{ p.domain }}/status" class="queue-action">
<input type="hidden" name="status" value="blocked">
<button type="submit" class="btn btn-reject" {% if p.status == 'blocked' %}disabled{% endif %}>block</button>
</form>
<form method="post" action="/admin/federation/peers/{{ p.domain }}/remove" class="queue-action"
onsubmit="return confirm('Remove {{ p.domain }}? Their signals will no longer count toward quorum.');">
<button type="submit" class="btn">remove</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="page-intro">(no peers yet — add one below)</p>
{% endif %}
</section>
<section class="panel">
<div class="panel-head">
<h2>Add Peer</h2>
</div>
<p class="page-intro">Pin a peer's identity manually: their domain, their fingerprint (from their DNS TXT record), and the public key they publish at <code>/federation/key</code>.</p>
<form method="post" action="/admin/federation/peers/add" style="display:grid; gap:10px; max-width:680px;">
<input type="text" name="domain" placeholder="peer domain (e.g. peer.example.com)" class="lookup-input" required>
<input type="text" name="fingerprint" placeholder="fingerprint (32 hex chars)" class="lookup-input" maxlength="64" required style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">
<textarea name="pubkey_pem" placeholder="-----BEGIN PUBLIC KEY-----&#10;…&#10;-----END PUBLIC KEY-----" rows="6" class="lookup-input" required style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;"></textarea>
<select name="status" class="lookup-input">
<option value="unknown">unknown — record only, don't trust yet</option>
<option value="trusted">trusted — count toward quorum</option>
<option value="blocked">blocked — ignore</option>
</select>
<button type="submit" class="btn btn-enforce">+ register peer</button>
</form>
</section>
<section class="panel">
<div class="panel-head">
<h2>Recent Signals</h2>
<span class="count">last {{ signals|length }} of buffer</span>
</div>
<p class="page-intro">Verified federation signals from peers — case + IOC reports awaiting quorum. The signal buffer is what later quorum logic will count over.</p>
{% if signals %}
<table class="ledger">
<thead><tr><th>Received</th><th>Peer</th><th>Type</th><th>Id</th><th>Hash</th></tr></thead>
<tbody>
{% for s in signals %}
<tr class="ledger-row">
<td class="lg-ts">{{ (s.received_at or '')[:19] | replace('T', ' ') }}</td>
<td class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">{{ s.peer_fingerprint[:8] }}…</td>
<td><span class="sev-badge">{{ s.signal_type }}</span></td>
<td style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">{{ s.signal_id[:48] }}</td>
<td class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">{{ s.signal_hash[:16] }}…</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="page-intro">(no signals received yet — quorum stage will populate this)</p>
{% endif %}
</section>
{% endblock %}