226 lines
7.2 KiB
Python
226 lines
7.2 KiB
Python
"""Federation endpoints — peer handshake, trust tiers, event relay."""
|
|
|
|
import json
|
|
import uuid
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from spore_node.db.connection import get_pool
|
|
from spore_node.federation.crypto import (
|
|
generate_keypair, public_key_to_b64, b64_to_public_key, derive_shared_key,
|
|
)
|
|
|
|
router = APIRouter(prefix="/federation", tags=["federation"])
|
|
|
|
VALID_TRUST_TIERS = ("trusted", "peer", "monitored")
|
|
VALID_HANDSHAKE_STATES = ("pending", "proposed", "approved", "rejected")
|
|
|
|
# Node's own keypair (generated on first handshake)
|
|
_node_keypair: tuple[bytes, bytes] | None = None
|
|
|
|
|
|
def _get_keypair() -> tuple[bytes, bytes]:
|
|
global _node_keypair
|
|
if _node_keypair is None:
|
|
_node_keypair = generate_keypair()
|
|
return _node_keypair
|
|
|
|
|
|
class HandshakeRequest(BaseModel):
|
|
peer_url: str
|
|
peer_rid: str = ""
|
|
peer_public_key: str = "" # base64-encoded X25519 public key
|
|
trust_tier: str = "monitored"
|
|
|
|
|
|
class PeerResponse(BaseModel):
|
|
id: str
|
|
node_rid: str
|
|
node_url: str
|
|
trust_tier: str
|
|
handshake_status: str
|
|
pending_events: int = 0
|
|
|
|
|
|
class HandshakeResponse(BaseModel):
|
|
status: str
|
|
peer_rid: str
|
|
our_public_key: str
|
|
message: str
|
|
|
|
|
|
@router.post("/handshake", response_model=HandshakeResponse)
|
|
async def initiate_handshake(data: HandshakeRequest):
|
|
"""Initiate or accept a federation handshake with a peer node."""
|
|
if data.trust_tier not in VALID_TRUST_TIERS:
|
|
raise HTTPException(422, f"trust_tier must be one of {VALID_TRUST_TIERS}")
|
|
|
|
pool = get_pool()
|
|
_, our_public = _get_keypair()
|
|
|
|
peer_rid = data.peer_rid or f"orn:koi-net.node:peer-{uuid.uuid4().hex[:8]}"
|
|
|
|
# Store peer encryption config
|
|
config = {}
|
|
if data.peer_public_key:
|
|
config["peer_public_key"] = data.peer_public_key
|
|
config["our_public_key"] = public_key_to_b64(our_public)
|
|
# Derive shared key for this peer
|
|
our_private, _ = _get_keypair()
|
|
shared = derive_shared_key(our_private, b64_to_public_key(data.peer_public_key))
|
|
config["encryption_ready"] = True
|
|
|
|
try:
|
|
await pool.execute(
|
|
"""
|
|
INSERT INTO koi_peers (node_rid, node_url, trust_tier, handshake_status, config)
|
|
VALUES ($1, $2, $3, 'proposed', $4::jsonb)
|
|
ON CONFLICT (node_rid) DO UPDATE SET
|
|
node_url = EXCLUDED.node_url,
|
|
trust_tier = EXCLUDED.trust_tier,
|
|
handshake_status = 'proposed',
|
|
config = EXCLUDED.config,
|
|
updated_at = now()
|
|
""",
|
|
peer_rid, data.peer_url, data.trust_tier, json.dumps(config),
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(500, f"Failed to store peer: {e}")
|
|
|
|
await _log_event(pool, peer_rid, "federation.handshake.proposed",
|
|
{"peer_url": data.peer_url, "trust_tier": data.trust_tier})
|
|
|
|
return HandshakeResponse(
|
|
status="proposed",
|
|
peer_rid=peer_rid,
|
|
our_public_key=public_key_to_b64(our_public),
|
|
message=f"Handshake proposed with {data.peer_url}. Awaiting bilateral approval.",
|
|
)
|
|
|
|
|
|
@router.patch("/peers/{peer_rid}/approve")
|
|
async def approve_peer(peer_rid: str):
|
|
"""Approve a pending handshake — transitions to approved."""
|
|
pool = get_pool()
|
|
row = await pool.fetchrow(
|
|
"SELECT handshake_status FROM koi_peers WHERE node_rid = $1", peer_rid
|
|
)
|
|
if not row:
|
|
raise HTTPException(404, "Peer not found")
|
|
if row["handshake_status"] == "approved":
|
|
return {"status": "already_approved"}
|
|
if row["handshake_status"] == "rejected":
|
|
raise HTTPException(422, "Peer was rejected. Re-initiate handshake.")
|
|
|
|
await pool.execute(
|
|
"UPDATE koi_peers SET handshake_status = 'approved', updated_at = now() WHERE node_rid = $1",
|
|
peer_rid,
|
|
)
|
|
await _log_event(pool, peer_rid, "federation.handshake.approved", {})
|
|
return {"status": "approved", "peer_rid": peer_rid}
|
|
|
|
|
|
@router.patch("/peers/{peer_rid}/reject")
|
|
async def reject_peer(peer_rid: str):
|
|
"""Reject a pending handshake."""
|
|
pool = get_pool()
|
|
row = await pool.fetchrow(
|
|
"SELECT handshake_status FROM koi_peers WHERE node_rid = $1", peer_rid
|
|
)
|
|
if not row:
|
|
raise HTTPException(404, "Peer not found")
|
|
|
|
await pool.execute(
|
|
"UPDATE koi_peers SET handshake_status = 'rejected', updated_at = now() WHERE node_rid = $1",
|
|
peer_rid,
|
|
)
|
|
await _log_event(pool, peer_rid, "federation.handshake.rejected", {})
|
|
return {"status": "rejected", "peer_rid": peer_rid}
|
|
|
|
|
|
@router.patch("/peers/{peer_rid}/trust")
|
|
async def update_trust_tier(peer_rid: str, trust_tier: str):
|
|
"""Update a peer's trust tier."""
|
|
if trust_tier not in VALID_TRUST_TIERS:
|
|
raise HTTPException(422, f"trust_tier must be one of {VALID_TRUST_TIERS}")
|
|
|
|
pool = get_pool()
|
|
row = await pool.fetchrow(
|
|
"SELECT node_rid FROM koi_peers WHERE node_rid = $1", peer_rid
|
|
)
|
|
if not row:
|
|
raise HTTPException(404, "Peer not found")
|
|
|
|
await pool.execute(
|
|
"UPDATE koi_peers SET trust_tier = $1, updated_at = now() WHERE node_rid = $2",
|
|
trust_tier, peer_rid,
|
|
)
|
|
await _log_event(pool, peer_rid, "federation.trust_tier.changed",
|
|
{"trust_tier": trust_tier})
|
|
return {"peer_rid": peer_rid, "trust_tier": trust_tier}
|
|
|
|
|
|
@router.get("/peers", response_model=list[PeerResponse])
|
|
async def list_peers(trust_tier: str | None = None):
|
|
"""List all federation peers."""
|
|
pool = get_pool()
|
|
if trust_tier:
|
|
rows = await pool.fetch(
|
|
"SELECT * FROM koi_peers WHERE trust_tier = $1 ORDER BY created_at",
|
|
trust_tier,
|
|
)
|
|
else:
|
|
rows = await pool.fetch("SELECT * FROM koi_peers ORDER BY created_at")
|
|
return [_peer_dict(r) for r in rows]
|
|
|
|
|
|
@router.get("/peers/{peer_rid}", response_model=PeerResponse)
|
|
async def get_peer(peer_rid: str):
|
|
"""Get a single peer by RID."""
|
|
pool = get_pool()
|
|
row = await pool.fetchrow(
|
|
"SELECT * FROM koi_peers WHERE node_rid = $1", peer_rid
|
|
)
|
|
if not row:
|
|
raise HTTPException(404, "Peer not found")
|
|
return _peer_dict(row)
|
|
|
|
|
|
@router.get("/events/pending")
|
|
async def pending_events():
|
|
"""Get count of pending relay events per peer."""
|
|
pool = get_pool()
|
|
peers = await pool.fetch(
|
|
"SELECT node_rid, node_url FROM koi_peers WHERE handshake_status = 'approved'"
|
|
)
|
|
# Import relay lazily to avoid circular deps
|
|
from spore_node.federation.relay import FederationRelay
|
|
from spore_node.config import SporeConfig
|
|
cfg = SporeConfig()
|
|
relay = FederationRelay(cfg.redis_url)
|
|
|
|
result = {}
|
|
for peer in peers:
|
|
count = await relay.pending_count(peer["node_rid"])
|
|
if count > 0:
|
|
result[peer["node_rid"]] = count
|
|
return result
|
|
|
|
|
|
def _peer_dict(row) -> dict:
|
|
d = dict(row)
|
|
d["id"] = str(d["id"])
|
|
d["pending_events"] = 0
|
|
d.pop("config", None)
|
|
d.pop("last_seen_at", None)
|
|
d.pop("created_at", None)
|
|
d.pop("updated_at", None)
|
|
return d
|
|
|
|
|
|
async def _log_event(pool, entity_rid: str, event_kind: str, payload: dict):
|
|
await pool.execute(
|
|
"INSERT INTO events (entity_rid, event_kind, payload) VALUES ($1, $2, $3::jsonb)",
|
|
entity_rid, event_kind, json.dumps(payload),
|
|
)
|