From 17b94acf6bca965654acd83f59c9c7a0da3d90be Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Sat, 6 Jun 2026 16:10:19 +0200 Subject: [PATCH] stage-fed-e federation: cockpit admin page + public feed routes --- src/psyc/cockpit/federation_routes.py | 125 +++++++++++++++++ .../cockpit/templates/admin_federation.html | 132 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 src/psyc/cockpit/federation_routes.py create mode 100644 src/psyc/cockpit/templates/admin_federation.html diff --git a/src/psyc/cockpit/federation_routes.py b/src/psyc/cockpit/federation_routes.py new file mode 100644 index 0000000..d402f38 --- /dev/null +++ b/src/psyc/cockpit/federation_routes.py @@ -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") diff --git a/src/psyc/cockpit/templates/admin_federation.html b/src/psyc/cockpit/templates/admin_federation.html new file mode 100644 index 0000000..b9493bd --- /dev/null +++ b/src/psyc/cockpit/templates/admin_federation.html @@ -0,0 +1,132 @@ +{% extends "base.html" %} +{% block title %}Federation — psyc admin{% endblock %} +{% block content %} + +
+
+

Federation Identity

+ {{ peers|length }} peer{{ '' if peers|length == 1 else 's' }} +
+

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

+

← back to admin

+ +
+
node fingerprint
+
{{ fingerprint }}
+
+ public key (PEM) +
{{ pubkey_pem }}
+
+
+
+ +
+
+

Publish via DNS

+ SRV + TXT records +
+

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.

+ +
+ + +
+ +
{{ dns.human_instructions }}
+
+ +
+
+

Known Peers

+ {{ peers|length }} registered +
+

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.

+ + {% if peers %} + + + + {% for p in peers %} + + + + + + + + + {% endfor %} + +
DomainFingerprintStatusDiscoveredLast seen
{{ p.domain }}{{ p.fingerprint[:8] }}…{{ p.fingerprint[-8:] }} + {% if p.status == 'trusted' %} + trusted + {% elif p.status == 'blocked' %} + blocked + {% else %} + unknown + {% endif %} + {{ (p.discovered_at or '')[:16] | replace('T', ' ') }}{{ ((p.last_seen or '')[:16] | replace('T', ' ')) or '—' }} +
+ + +
+
+ + +
+
+ +
+
+ {% else %} +

(no peers yet — add one below)

+ {% endif %} +
+ +
+
+

Add Peer

+
+

Pin a peer's identity manually: their domain, their fingerprint (from their DNS TXT record), and the public key they publish at /federation/key.

+
+ + + + + +
+
+ +
+
+

Recent Signals

+ last {{ signals|length }} of buffer +
+

Verified federation signals from peers — case + IOC reports awaiting quorum. The signal buffer is what later quorum logic will count over.

+ + {% if signals %} + + + + {% for s in signals %} + + + + + + + + {% endfor %} + +
ReceivedPeerTypeIdHash
{{ (s.received_at or '')[:19] | replace('T', ' ') }}{{ s.peer_fingerprint[:8] }}…{{ s.signal_type }}{{ s.signal_id[:48] }}{{ s.signal_hash[:16] }}…
+ {% else %} +

(no signals received yet — quorum stage will populate this)

+ {% endif %} +
+ +{% endblock %}