stage-fed-e federation: cockpit admin page + public feed routes
This commit is contained in:
125
src/psyc/cockpit/federation_routes.py
Normal file
125
src/psyc/cockpit/federation_routes.py
Normal 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")
|
||||
132
src/psyc/cockpit/templates/admin_federation.html
Normal file
132
src/psyc/cockpit/templates/admin_federation.html
Normal 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----- … -----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 %}
|
||||
Reference in New Issue
Block a user