stage-vouch-d federation: cockpit pages + CLI + public endpoints
Public JSON endpoints (no auth):
- GET /federation/vouches — our_vouches() so peers can pull our trust
- GET /federation/log — last 100 transparency-log entries
- GET /federation/log/verify — re-walks the chain, returns
{verified, head_hash} or 409 with {error, head_hash}
Admin pages (TOTP-gated):
- /admin/federation/vouches — issued list, issue form, revoke
buttons, per-peer quorum-met table
- /admin/federation/log — chain verification status + last 200 entries
- /admin/federation/quorum — config form + per-peer eligibility +
per-signal-hash distinct-eligible-peer counts
CLI: fed-vouch / fed-unvouch / fed-vouches / fed-quorum-set / fed-log /
fed-log-verify, plus existing fed-* commands untouched.
The base /admin/federation page now links to the three new sub-pages.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ import httpx
|
||||
import typer
|
||||
|
||||
from psyc import db, log
|
||||
from psyc.lines import federation
|
||||
from psyc.lines import federation, translog
|
||||
from psyc.result import Err
|
||||
|
||||
|
||||
@@ -133,3 +133,80 @@ def register(typer_app: typer.Typer) -> None:
|
||||
db.init_db()
|
||||
federation.remove_peer(domain)
|
||||
typer.echo(f"removed {domain}")
|
||||
|
||||
# ---------- vouching + quorum --------------------------------------
|
||||
|
||||
@typer_app.command("fed-vouch")
|
||||
def fed_vouch(
|
||||
target_fp: str = typer.Argument(..., help="target peer fingerprint (32 hex)"),
|
||||
ttl_days: int = typer.Option(90, "--ttl-days", help="vouch lifetime in days"),
|
||||
) -> None:
|
||||
"""Issue a signed vouch for `target_fp`. Persists locally + rides our feed."""
|
||||
db.init_db()
|
||||
v = federation.issue_vouch(target_fp.strip(), ttl_days=ttl_days)
|
||||
typer.echo(f"vouched: {v.target_fingerprint} (expires {v.expires_at})")
|
||||
|
||||
@typer_app.command("fed-unvouch")
|
||||
def fed_unvouch(target_fp: str = typer.Argument(...)) -> None:
|
||||
"""Revoke OUR vouch for `target_fp`."""
|
||||
db.init_db()
|
||||
federation.revoke_vouch(target_fp.strip())
|
||||
typer.echo(f"revoked vouch for {target_fp}")
|
||||
|
||||
@typer_app.command("fed-vouches")
|
||||
def fed_vouches() -> None:
|
||||
"""List vouches WE have issued."""
|
||||
db.init_db()
|
||||
rows = federation.our_vouches()
|
||||
if not rows:
|
||||
typer.echo("(no vouches issued)")
|
||||
return
|
||||
for v in rows:
|
||||
exp = v.expires_at.isoformat() if v.expires_at else "—"
|
||||
typer.echo(f" {v.target_fingerprint} issued={v.issued_at.isoformat()[:16]} expires={exp[:16]}")
|
||||
|
||||
@typer_app.command("fed-quorum-set")
|
||||
def fed_quorum_set(
|
||||
trust: Optional[int] = typer.Option(None, "--trust", help="trust_min_vouchers"),
|
||||
k: Optional[int] = typer.Option(None, "--k", help="signal_quorum_k"),
|
||||
) -> None:
|
||||
"""Update quorum thresholds. Either flag is optional — only changed values overwrite."""
|
||||
db.init_db()
|
||||
cfg = federation.quorum_config()
|
||||
if trust is not None:
|
||||
cfg.trust_min_vouchers = max(1, int(trust))
|
||||
if k is not None:
|
||||
cfg.signal_quorum_k = max(1, int(k))
|
||||
federation.set_quorum_config(cfg)
|
||||
typer.echo(f"quorum: trust_min_vouchers={cfg.trust_min_vouchers} signal_quorum_k={cfg.signal_quorum_k}")
|
||||
|
||||
# ---------- transparency log --------------------------------------
|
||||
|
||||
@typer_app.command("fed-log")
|
||||
def fed_log(
|
||||
limit: int = typer.Option(20, "--limit", help="number of entries to show"),
|
||||
) -> None:
|
||||
"""Print recent transparency-log entries (newest first)."""
|
||||
db.init_db()
|
||||
rows = translog.recent(limit=limit)
|
||||
if not rows:
|
||||
typer.echo("(transparency log empty)")
|
||||
return
|
||||
for e in rows:
|
||||
typer.echo(
|
||||
f" id={e.id:5d} {e.entry_type:6s} {e.timestamp[:19]} hash={e.entry_hash[:16]}…"
|
||||
)
|
||||
|
||||
@typer_app.command("fed-log-verify")
|
||||
def fed_log_verify() -> None:
|
||||
"""Re-walk the chain locally and report verification status."""
|
||||
db.init_db()
|
||||
result = translog.verify_chain()
|
||||
head = translog.head()
|
||||
head_hash = head.entry_hash if head else "(empty)"
|
||||
if isinstance(result, Err):
|
||||
typer.echo(f" ✗ broken: {result.reason}", err=True)
|
||||
typer.echo(f" head_hash: {head_hash}")
|
||||
raise typer.Exit(1)
|
||||
typer.echo(f" ✓ verified {result.value} entries")
|
||||
typer.echo(f" head_hash: {head_hash}")
|
||||
|
||||
@@ -15,7 +15,8 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Red
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from psyc import db, log
|
||||
from psyc.lines import federation
|
||||
from psyc.lines import federation, translog
|
||||
from psyc.result import Err
|
||||
|
||||
|
||||
_log = log.get(__name__)
|
||||
@@ -122,4 +123,183 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
|
||||
def federation_feed() -> JSONResponse:
|
||||
return JSONResponse(_cached_feed())
|
||||
|
||||
# ---------- 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({
|
||||
"fingerprint": federation.node_fingerprint(),
|
||||
"vouches": [v.model_dump(mode="json") for v in federation.our_vouches()],
|
||||
})
|
||||
|
||||
@app.get("/federation/log")
|
||||
def federation_log() -> JSONResponse:
|
||||
"""Last 100 transparency-log entries, newest first."""
|
||||
entries = translog.recent(limit=100)
|
||||
return JSONResponse({
|
||||
"count": len(entries),
|
||||
"entries": [e.model_dump(mode="json") for e in entries],
|
||||
})
|
||||
|
||||
@app.get("/federation/log/verify")
|
||||
def federation_log_verify() -> JSONResponse:
|
||||
"""Re-walk the chain locally and report status. Auditors poll this."""
|
||||
result = translog.verify_chain()
|
||||
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})
|
||||
|
||||
# ---------- admin: vouches page ---------------------------------
|
||||
|
||||
@app.get("/admin/federation/vouches", response_class=HTMLResponse)
|
||||
def admin_federation_vouches(request: Request) -> HTMLResponse:
|
||||
if not _admin_ok(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
peers = federation.list_peers()
|
||||
cfg = federation.quorum_config()
|
||||
ours = federation.our_vouches()
|
||||
# Per-peer view: vouches naming each peer and whether quorum is met.
|
||||
peer_rows = []
|
||||
for p in peers:
|
||||
vouches = federation.vouches_for(p.fingerprint)
|
||||
peer_rows.append({
|
||||
"peer": p,
|
||||
"vouches": vouches,
|
||||
"vouched": federation.is_vouched(p.fingerprint),
|
||||
"eligible": federation.peer_is_listening_eligible(p.fingerprint),
|
||||
})
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"admin_federation_vouches.html",
|
||||
{
|
||||
"fingerprint": federation.node_fingerprint(),
|
||||
"our_vouches": ours,
|
||||
"peer_rows": peer_rows,
|
||||
"cfg": cfg,
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/admin/federation/vouches/issue")
|
||||
def admin_federation_vouch_issue(
|
||||
request: Request,
|
||||
target_fingerprint: str = Form(...),
|
||||
ttl_days: int = Form(90),
|
||||
) -> RedirectResponse:
|
||||
if not _admin_ok(request):
|
||||
raise HTTPException(status_code=403, detail="admin session required")
|
||||
try:
|
||||
federation.issue_vouch(target_fingerprint.strip(), ttl_days=ttl_days)
|
||||
except Exception as exc:
|
||||
_log.warning("federation.vouch.issue.error", error=str(exc))
|
||||
return RedirectResponse("/admin/federation/vouches", status_code=303)
|
||||
|
||||
@app.post("/admin/federation/vouches/revoke")
|
||||
def admin_federation_vouch_revoke(
|
||||
request: Request,
|
||||
target_fingerprint: str = Form(...),
|
||||
) -> RedirectResponse:
|
||||
if not _admin_ok(request):
|
||||
raise HTTPException(status_code=403, detail="admin session required")
|
||||
federation.revoke_vouch(target_fingerprint.strip())
|
||||
return RedirectResponse("/admin/federation/vouches", status_code=303)
|
||||
|
||||
# ---------- admin: transparency log page ------------------------
|
||||
|
||||
@app.get("/admin/federation/log", response_class=HTMLResponse)
|
||||
def admin_federation_log(request: Request) -> HTMLResponse:
|
||||
if not _admin_ok(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
result = translog.verify_chain()
|
||||
head = translog.head()
|
||||
entries = translog.recent(limit=200)
|
||||
verify_status: Dict[str, Any]
|
||||
if isinstance(result, Err):
|
||||
verify_status = {"ok": False, "reason": result.reason}
|
||||
else:
|
||||
verify_status = {"ok": True, "verified": result.value}
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"admin_federation_log.html",
|
||||
{
|
||||
"verify_status": verify_status,
|
||||
"head_hash": head.entry_hash if head else "",
|
||||
"head_id": head.id if head else 0,
|
||||
"entries": entries,
|
||||
},
|
||||
)
|
||||
|
||||
# ---------- admin: quorum config + per-peer/per-hash view -------
|
||||
|
||||
@app.get("/admin/federation/quorum", response_class=HTMLResponse)
|
||||
def admin_federation_quorum(request: Request) -> HTMLResponse:
|
||||
if not _admin_ok(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
cfg = federation.quorum_config()
|
||||
peers = federation.list_peers()
|
||||
peer_rows = [
|
||||
{
|
||||
"peer": p,
|
||||
"vouched": federation.is_vouched(p.fingerprint),
|
||||
"eligible": federation.peer_is_listening_eligible(p.fingerprint),
|
||||
}
|
||||
for p in peers
|
||||
]
|
||||
# Group buffered signals by signal_hash and count distinct eligible peers.
|
||||
signal_rows: Dict[str, Dict[str, Any]] = {}
|
||||
for s in db.recent_signals(limit=500):
|
||||
h = s.get("signal_hash") or ""
|
||||
entry = signal_rows.setdefault(h, {
|
||||
"signal_hash": h,
|
||||
"signal_type": s.get("signal_type") or "",
|
||||
"signal_id": s.get("signal_id") or "",
|
||||
"peers": set(),
|
||||
"latest": s.get("received_at") or "",
|
||||
})
|
||||
entry["peers"].add(s.get("peer_fingerprint") or "")
|
||||
hash_summary = []
|
||||
for h, row in signal_rows.items():
|
||||
distinct_eligible = sum(
|
||||
1 for fp in row["peers"] if federation.peer_is_listening_eligible(fp)
|
||||
)
|
||||
hash_summary.append({
|
||||
"signal_hash": h,
|
||||
"signal_type": row["signal_type"],
|
||||
"signal_id": row["signal_id"],
|
||||
"distinct_peers": len(row["peers"]),
|
||||
"distinct_eligible": distinct_eligible,
|
||||
"quorum_met": distinct_eligible >= cfg.signal_quorum_k,
|
||||
"latest": row["latest"],
|
||||
})
|
||||
hash_summary.sort(key=lambda r: r["latest"], reverse=True)
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"admin_federation_quorum.html",
|
||||
{
|
||||
"cfg": cfg,
|
||||
"peer_rows": peer_rows,
|
||||
"hash_summary": hash_summary,
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/admin/federation/quorum/save")
|
||||
def admin_federation_quorum_save(
|
||||
request: Request,
|
||||
trust_min_vouchers: int = Form(...),
|
||||
signal_quorum_k: int = Form(...),
|
||||
) -> RedirectResponse:
|
||||
if not _admin_ok(request):
|
||||
raise HTTPException(status_code=403, detail="admin session required")
|
||||
try:
|
||||
cfg = federation.QuorumConfig(
|
||||
trust_min_vouchers=max(1, int(trust_min_vouchers)),
|
||||
signal_quorum_k=max(1, int(signal_quorum_k)),
|
||||
)
|
||||
federation.set_quorum_config(cfg)
|
||||
except Exception as exc:
|
||||
_log.warning("federation.quorum.save.error", error=str(exc))
|
||||
return RedirectResponse("/admin/federation/quorum", status_code=303)
|
||||
|
||||
_log.info("federation.routes.registered")
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<span class="count">{{ peers|length }} peer{{ '' if peers|length == 1 else 's' }}</span>
|
||||
</div>
|
||||
<p class="page-intro">This node's Ed25519 identity. The fingerprint goes into a DNS TXT record so other psyc nodes can discover this one. The public key lets them verify any feed we publish — the private key never leaves this box.</p>
|
||||
<p class="back"><a href="/admin">← back to admin</a></p>
|
||||
<p class="back"><a href="/admin">← back to admin</a> · <a href="/admin/federation/vouches">vouches</a> · <a href="/admin/federation/quorum">quorum config</a> · <a href="/admin/federation/log">transparency log</a></p>
|
||||
|
||||
<div class="card" style="margin-bottom:14px;">
|
||||
<div class="lg-sub">node fingerprint</div>
|
||||
|
||||
63
src/psyc/cockpit/templates/admin_federation_log.html
Normal file
63
src/psyc/cockpit/templates/admin_federation_log.html
Normal file
@@ -0,0 +1,63 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Transparency Log — psyc admin{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h1>Transparency Log</h1>
|
||||
<span class="count">id @ {{ head_id }}</span>
|
||||
</div>
|
||||
<p class="page-intro">Every signal we accept from a peer is appended to a signed merkle chain. Each entry references the previous entry's hash, so tampering with any historical row invalidates every entry after. Auditors can re-walk and detect a bad peer historically — even one we trusted at the time.</p>
|
||||
<p class="back"><a href="/admin/federation">← back to federation</a> · <a href="/admin/federation/vouches">vouches</a> · <a href="/admin/federation/quorum">quorum config</a> · <a href="/federation/log/verify">public verify endpoint</a></p>
|
||||
|
||||
<div class="card" style="margin-bottom:14px;">
|
||||
<div class="lg-sub">chain verification</div>
|
||||
{% if verify_status.ok %}
|
||||
<div style="margin:6px 0;"><span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">verified</span> {{ verify_status.verified }} entries walked, no breaks</div>
|
||||
{% else %}
|
||||
<div style="margin:6px 0;"><span class="sev-badge" style="background:rgba(248,113,113,0.10); color:var(--red); border-color:var(--red);">BROKEN</span> {{ verify_status.reason }}</div>
|
||||
{% endif %}
|
||||
{% if head_hash %}
|
||||
<div class="lg-sub" style="margin-top:8px;">head hash</div>
|
||||
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px; word-break:break-all; color:var(--accent);">{{ head_hash }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Recent Entries</h2>
|
||||
<span class="count">{{ entries|length }} of last 200</span>
|
||||
</div>
|
||||
<p class="page-intro">Newest first. Hashes are truncated for display — full values are at <code>/federation/log</code>.</p>
|
||||
|
||||
{% if entries %}
|
||||
<table class="ledger">
|
||||
<thead><tr><th>id</th><th>When</th><th>Type</th><th>Peer / target</th><th>Signal id</th><th>Hash</th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
<tr class="ledger-row">
|
||||
<td>{{ e.id }}</td>
|
||||
<td class="lg-ts">{{ (e.timestamp or '')[:19] | replace('T', ' ') }}</td>
|
||||
<td><span class="sev-badge">{{ e.entry_type }}</span></td>
|
||||
<td class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">
|
||||
{% if e.entry_type == 'signal' %}
|
||||
{{ (e.entry_data.peer_fingerprint or '')[:8] }}…
|
||||
{% elif e.entry_type == 'vouch' %}
|
||||
{{ (e.entry_data.voucher_fingerprint or '')[:8] }}…→{{ (e.entry_data.target_fingerprint or '')[:8] }}…
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">{{ ((e.entry_data.signal_id or e.entry_data.target_fingerprint or '') | string)[:32] }}</td>
|
||||
<td class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">{{ e.entry_hash[:16] }}…</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="page-intro">(chain empty — no signals appended yet)</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
92
src/psyc/cockpit/templates/admin_federation_quorum.html
Normal file
92
src/psyc/cockpit/templates/admin_federation_quorum.html
Normal file
@@ -0,0 +1,92 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Quorum Config — psyc admin{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h1>Quorum Configuration</h1>
|
||||
<span class="count">trust={{ cfg.trust_min_vouchers }} k={{ cfg.signal_quorum_k }}</span>
|
||||
</div>
|
||||
<p class="page-intro"><strong>trust_min_vouchers</strong> — distinct trusted vouchers required to make a new peer listening-eligible. <strong>signal_quorum_k</strong> — distinct listening-eligible peers required to consider a signal_hash quorum-met. Both gates live in pulse_settings; raising them tightens trust, lowering them relaxes it.</p>
|
||||
<p class="back"><a href="/admin/federation">← back to federation</a> · <a href="/admin/federation/vouches">vouches</a> · <a href="/admin/federation/log">transparency log</a></p>
|
||||
|
||||
<form method="post" action="/admin/federation/quorum/save" style="display:grid; gap:10px; max-width:520px;">
|
||||
<label class="lg-sub">trust_min_vouchers</label>
|
||||
<input type="number" name="trust_min_vouchers" value="{{ cfg.trust_min_vouchers }}" min="1" max="50" class="lookup-input" required>
|
||||
<label class="lg-sub">signal_quorum_k</label>
|
||||
<input type="number" name="signal_quorum_k" value="{{ cfg.signal_quorum_k }}" min="1" max="50" class="lookup-input" required>
|
||||
<button type="submit" class="btn btn-enforce">save config</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Per-Peer Listening Eligibility</h2>
|
||||
<span class="count">{{ peer_rows|length }}</span>
|
||||
</div>
|
||||
<p class="page-intro">A peer's feed gets ingested only when its fingerprint is eligible (directly trusted or vouched into trust).</p>
|
||||
|
||||
{% if peer_rows %}
|
||||
<table class="ledger">
|
||||
<thead><tr><th>Domain</th><th>Fingerprint</th><th>Status</th><th>Vouched</th><th>Eligible</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in peer_rows %}
|
||||
<tr class="ledger-row">
|
||||
<td><strong>{{ row.peer.domain }}</strong></td>
|
||||
<td class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">{{ row.peer.fingerprint[:8] }}…{{ row.peer.fingerprint[-8:] }}</td>
|
||||
<td>
|
||||
{% if row.peer.status == 'trusted' %}
|
||||
<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">trusted</span>
|
||||
{% elif row.peer.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">{{ row.peer.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% if row.vouched %}<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">yes</span>{% else %}<span class="sev-badge">no</span>{% endif %}</td>
|
||||
<td>{% if row.eligible %}<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">listening</span>{% else %}<span class="sev-badge" style="background:rgba(248,113,113,0.10); color:var(--red); border-color:var(--red);">muted</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="page-intro">(no peers registered yet)</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Signal Hashes in Buffer</h2>
|
||||
<span class="count">{{ hash_summary|length }} hashes</span>
|
||||
</div>
|
||||
<p class="page-intro">Distinct eligible-peer counts per signal hash. Quorum is met when count ≥ {{ cfg.signal_quorum_k }}.</p>
|
||||
|
||||
{% if hash_summary %}
|
||||
<table class="ledger">
|
||||
<thead><tr><th>Latest</th><th>Type</th><th>Signal id</th><th>Hash</th><th>Distinct peers</th><th>Eligible</th><th>Quorum</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in hash_summary %}
|
||||
<tr class="ledger-row">
|
||||
<td class="lg-ts">{{ (r.latest or '')[:19] | replace('T', ' ') }}</td>
|
||||
<td><span class="sev-badge">{{ r.signal_type }}</span></td>
|
||||
<td style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">{{ r.signal_id[:48] }}</td>
|
||||
<td class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">{{ r.signal_hash[:16] }}…</td>
|
||||
<td>{{ r.distinct_peers }}</td>
|
||||
<td>{{ r.distinct_eligible }}</td>
|
||||
<td>
|
||||
{% if r.quorum_met %}
|
||||
<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">met</span>
|
||||
{% else %}
|
||||
<span class="sev-badge">below</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="page-intro">(no signals in buffer yet)</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
110
src/psyc/cockpit/templates/admin_federation_vouches.html
Normal file
110
src/psyc/cockpit/templates/admin_federation_vouches.html
Normal file
@@ -0,0 +1,110 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Federation Vouches — psyc admin{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h1>Web of Trust</h1>
|
||||
<span class="count">{{ our_vouches|length }} issued</span>
|
||||
</div>
|
||||
<p class="page-intro">A vouch is an Ed25519-signed assertion that we trust another node's fingerprint. Peers gossip our vouches with their feeds, so trust accumulates: once {{ cfg.trust_min_vouchers }} of our trusted peers vouches for a new fingerprint, it becomes <em>listening-eligible</em> — its signed feeds get ingested.</p>
|
||||
<p class="back"><a href="/admin/federation">← back to federation</a> · <a href="/admin/federation/quorum">quorum config</a> · <a href="/admin/federation/log">transparency log</a></p>
|
||||
|
||||
<div class="card" style="margin-bottom:14px;">
|
||||
<div class="lg-sub">our fingerprint</div>
|
||||
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:14px; word-break:break-all; color:var(--accent);">{{ fingerprint }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Vouches We've Issued</h2>
|
||||
<span class="count">{{ our_vouches|length }}</span>
|
||||
</div>
|
||||
<p class="page-intro">We've signed these — peers that fetch our feed will see them and may extend trust accordingly.</p>
|
||||
|
||||
{% if our_vouches %}
|
||||
<table class="ledger">
|
||||
<thead><tr><th>Target fingerprint</th><th>Issued</th><th>Expires</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for v in our_vouches %}
|
||||
<tr class="ledger-row">
|
||||
<td style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px;">{{ v.target_fingerprint }}</td>
|
||||
<td class="lg-ts">{{ v.issued_at.isoformat()[:16] | replace('T', ' ') }}</td>
|
||||
<td class="lg-ts">{{ v.expires_at.isoformat()[:16] | replace('T', ' ') if v.expires_at else '—' }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/federation/vouches/revoke" class="queue-action"
|
||||
onsubmit="return confirm('Revoke vouch for {{ v.target_fingerprint[:8] }}…?');">
|
||||
<input type="hidden" name="target_fingerprint" value="{{ v.target_fingerprint }}">
|
||||
<button type="submit" class="btn btn-reject">revoke</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="page-intro">(no vouches issued yet)</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Issue a Vouch</h2>
|
||||
</div>
|
||||
<p class="page-intro">Vouch for a peer's fingerprint. Trusted peers see this and may treat the target as listening-eligible.</p>
|
||||
<form method="post" action="/admin/federation/vouches/issue" style="display:grid; gap:10px; max-width:680px;">
|
||||
<input type="text" name="target_fingerprint" placeholder="target fingerprint (32 hex chars)" class="lookup-input" maxlength="64" required style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">
|
||||
<input type="number" name="ttl_days" value="90" min="1" max="3650" class="lookup-input" placeholder="ttl days">
|
||||
<button type="submit" class="btn btn-enforce">+ issue vouch</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Per-Peer Quorum Status</h2>
|
||||
<span class="count">{{ peer_rows|length }} peers</span>
|
||||
</div>
|
||||
<p class="page-intro">Threshold: {{ cfg.trust_min_vouchers }} distinct trusted vouchers required to make a non-trusted peer listening-eligible.</p>
|
||||
|
||||
{% if peer_rows %}
|
||||
<table class="ledger">
|
||||
<thead><tr><th>Peer</th><th>Status</th><th>Vouches</th><th>Quorum met</th><th>Eligible</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in peer_rows %}
|
||||
<tr class="ledger-row">
|
||||
<td><strong>{{ row.peer.domain }}</strong><br><span class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">{{ row.peer.fingerprint[:8] }}…{{ row.peer.fingerprint[-8:] }}</span></td>
|
||||
<td>
|
||||
{% if row.peer.status == 'trusted' %}
|
||||
<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">trusted</span>
|
||||
{% elif row.peer.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">{{ row.peer.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ row.vouches|length }}</td>
|
||||
<td>
|
||||
{% if row.vouched %}
|
||||
<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">yes</span>
|
||||
{% else %}
|
||||
<span class="sev-badge">no</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if row.eligible %}
|
||||
<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">listening</span>
|
||||
{% else %}
|
||||
<span class="sev-badge" style="background:rgba(248,113,113,0.10); color:var(--red); border-color:var(--red);">muted</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="page-intro">(no peers registered yet)</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user