Implement live gossip federation server (5 phases)
Phase 1: RFC 6962 consistency proofs in merkle.py - Implemented _build_consistency_proof() with recursive subtree decomposition algorithm following RFC 6962 Section 2.1.2 - Added _subproof() recursive helper and _compute_root_of() - Added standalone verify_consistency_proof() function Phase 2: Federation API endpoints on FastAPI server - GET /federation/status — merkle root + log size for gossip probes - GET /federation/records?start=N&count=M — record fetch (cap 100) - GET /federation/consistency-proof?old_size=N — Merkle proof - POST /federation/records — accept records with trust filtering and SHA-256 deduplication - Cached storage singleton for concurrent safety - Added FEDERATION_DIR to paths.py Phase 3: HttpTransport implementation - Replaced stub with real aiohttp client (lazy import for optional dep) - Reusable ClientSession with configurable timeout - All 4 PeerTransport methods: get_status, get_records, get_consistency_proof, push_records - FederationError wrapping for all network failures - Added record_filter callback to GossipNode for trust-store filtering Phase 4: Peer persistence (SQLite) - New peer_store.py: SQLite-backed peer database + sync history - Tables: peers (url, fingerprint, health, last_seen) and sync_history (timestamp, records_received, success/error) - PeerStore follows dropbox.py SQLite pattern Phase 5: CLI commands + Web UI dashboard - CLI: federation status, peer-add, peer-remove, peer-list, sync-now (asyncio), history - Flask blueprint at /federation/ with peer table, sync history, add/remove peer forms, local node info cards - CSRF tokens on all forms Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -116,8 +116,10 @@ def create_app(config: SoosefConfig | None = None) -> Flask:
|
||||
app.register_blueprint(keys_bp)
|
||||
|
||||
from frontends.web.blueprints.dropbox import bp as dropbox_bp
|
||||
from frontends.web.blueprints.federation import bp as federation_bp
|
||||
|
||||
app.register_blueprint(dropbox_bp)
|
||||
app.register_blueprint(federation_bp)
|
||||
|
||||
# Exempt drop box upload from CSRF (sources don't have sessions)
|
||||
csrf.exempt(dropbox_bp)
|
||||
|
||||
78
frontends/web/blueprints/federation.py
Normal file
78
frontends/web/blueprints/federation.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Federation blueprint — peer status dashboard and management.
|
||||
"""
|
||||
|
||||
from auth import admin_required, login_required
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
|
||||
bp = Blueprint("federation", __name__, url_prefix="/federation")
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
@login_required
|
||||
def status():
|
||||
"""Federation status dashboard."""
|
||||
from soosef.verisoo.peer_store import PeerStore
|
||||
|
||||
store = PeerStore()
|
||||
peers = store.list_peers()
|
||||
history = store.get_sync_history(limit=20)
|
||||
|
||||
# Get local node info
|
||||
node_info = {"root": None, "size": 0}
|
||||
try:
|
||||
from soosef.verisoo.storage import LocalStorage
|
||||
|
||||
import soosef.paths as _paths
|
||||
|
||||
storage = LocalStorage(_paths.ATTESTATIONS_DIR)
|
||||
stats = storage.get_stats()
|
||||
merkle_log = storage.load_merkle_log()
|
||||
node_info = {
|
||||
"root": merkle_log.root_hash[:16] + "..." if merkle_log.root_hash else "empty",
|
||||
"size": merkle_log.size,
|
||||
"record_count": stats.record_count,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return render_template(
|
||||
"federation/status.html",
|
||||
peers=peers,
|
||||
history=history,
|
||||
node_info=node_info,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/peer/add", methods=["POST"])
|
||||
@admin_required
|
||||
def peer_add():
|
||||
"""Add a federation peer."""
|
||||
from soosef.verisoo.peer_store import PeerStore
|
||||
|
||||
url = request.form.get("url", "").strip()
|
||||
fingerprint = request.form.get("fingerprint", "").strip()
|
||||
|
||||
if not url or not fingerprint:
|
||||
flash("URL and fingerprint are required.", "error")
|
||||
return redirect(url_for("federation.status"))
|
||||
|
||||
store = PeerStore()
|
||||
store.add_peer(url, fingerprint)
|
||||
flash(f"Peer added: {url}", "success")
|
||||
return redirect(url_for("federation.status"))
|
||||
|
||||
|
||||
@bp.route("/peer/remove", methods=["POST"])
|
||||
@admin_required
|
||||
def peer_remove():
|
||||
"""Remove a federation peer."""
|
||||
from soosef.verisoo.peer_store import PeerStore
|
||||
|
||||
url = request.form.get("url", "").strip()
|
||||
store = PeerStore()
|
||||
if store.remove_peer(url):
|
||||
flash(f"Peer removed: {url}", "success")
|
||||
else:
|
||||
flash(f"Peer not found: {url}", "error")
|
||||
return redirect(url_for("federation.status"))
|
||||
108
frontends/web/templates/federation/status.html
Normal file
108
frontends/web/templates/federation/status.html
Normal file
@@ -0,0 +1,108 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Federation — SooSeF{% endblock %}
|
||||
{% block content %}
|
||||
<h2><i class="bi bi-diagram-3 me-2"></i>Federation</h2>
|
||||
<p class="text-muted">Gossip-based attestation sync between SooSeF instances.</p>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-dark">
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle text-muted">Local Node</h6>
|
||||
<p class="mb-1">Records: <strong>{{ node_info.size }}</strong></p>
|
||||
<p class="mb-0 text-muted small">Root: {{ node_info.root }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-dark">
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle text-muted">Peers</h6>
|
||||
<p class="mb-0"><strong>{{ peers|length }}</strong> configured</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-dark">
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle text-muted">Last Sync</h6>
|
||||
{% if history %}
|
||||
<p class="mb-0">{{ history[0].synced_at[:16] }}</p>
|
||||
{% else %}
|
||||
<p class="mb-0 text-muted">Never</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>Peers</h5>
|
||||
{% if peers %}
|
||||
<table class="table table-dark table-sm">
|
||||
<thead>
|
||||
<tr><th>URL</th><th>Fingerprint</th><th>Records</th><th>Health</th><th>Last Seen</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in peers %}
|
||||
<tr>
|
||||
<td><code>{{ p.url }}</code></td>
|
||||
<td><code>{{ p.fingerprint[:16] }}...</code></td>
|
||||
<td>{{ p.last_size }}</td>
|
||||
<td>{% if p.healthy %}<span class="text-success">OK</span>{% else %}<span class="text-danger">DOWN</span>{% endif %}</td>
|
||||
<td>{{ p.last_seen.strftime('%Y-%m-%d %H:%M') if p.last_seen else 'Never' }}</td>
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('federation.peer_remove') }}" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="url" value="{{ p.url }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Remove">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted">No peers configured. Add one below.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="card bg-dark mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Add Peer</h6>
|
||||
<form method="POST" action="{{ url_for('federation.peer_add') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="row g-2">
|
||||
<div class="col-md-5">
|
||||
<input type="url" name="url" class="form-control bg-dark text-light" placeholder="https://peer:8000" required>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<input type="text" name="fingerprint" class="form-control bg-dark text-light" placeholder="Ed25519 fingerprint" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary w-100">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if history %}
|
||||
<h5>Sync History</h5>
|
||||
<table class="table table-dark table-sm">
|
||||
<thead>
|
||||
<tr><th>Time</th><th>Peer</th><th>Records</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for h in history %}
|
||||
<tr>
|
||||
<td>{{ h.synced_at[:19] }}</td>
|
||||
<td><code>{{ h.peer_url[:30] }}</code></td>
|
||||
<td>+{{ h.records_received }}</td>
|
||||
<td>{% if h.success %}<span class="text-success">OK</span>{% else %}<span class="text-danger">{{ h.error[:40] if h.error else 'Failed' }}</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user