From 95e045272243043ed9a207115f7737a0e3eb1ccc Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 9 Aug 2026 05:20:43 +0200 Subject: feat(hub): add MHP federation + Mesh Relay registration — 5.2 + 5.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Federation (MHP 0.1): /mhp/info, /mhp/directory (GET=export, POST=receive), /mhp/revoke (propagation), /mhp/peers (admin registration). Peer auth: JWT EdDSA signed by requesting hub's key. Explicit peer allowlist — no auto-discovery. Relay (5.3): /v1/relays (GET=list), /v1/relays/register (relay keepalive), /v1/relays/approve (admin pre-approval). Relays pre-approved by admin, then self-register with signed endpoint. Nodes query when hole punch fails. 59/59 tests. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../meshbay-hub/src/meshbay_hub/api/federation.py | 217 +++++++++++++++++++++ packages/meshbay-hub/src/meshbay_hub/api/relay.py | 113 +++++++++++ packages/meshbay-hub/src/meshbay_hub/app.py | 4 + 3 files changed, 334 insertions(+) create mode 100644 packages/meshbay-hub/src/meshbay_hub/api/federation.py create mode 100644 packages/meshbay-hub/src/meshbay_hub/api/relay.py diff --git a/packages/meshbay-hub/src/meshbay_hub/api/federation.py b/packages/meshbay-hub/src/meshbay_hub/api/federation.py new file mode 100644 index 0000000..5a7f45f --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/api/federation.py @@ -0,0 +1,217 @@ +""" +MeshBay Hub — MHP (Mesh Bay Hub Protocol) federation endpoints. + +Federation allows multiple hubs to exchange public group directories +and propagate revocations. Each hub explicitly chooses its peers +(no automatic discovery). + +MHP endpoints: + GET /mhp/info — hub identity and capabilities + POST /mhp/directory — receive a Mesh Directory from a peer hub + POST /mhp/revoke — receive a revocation from a peer hub + GET /mhp/directory — export our public Mesh Directory + POST /mhp/peers — admin: register a trusted peer hub + GET /mhp/peers — admin: list registered peers + +Peer authentication: each request carries a JWT signed by the +sending hub's Ed25519 key. Receiving hub verifies with the sender's +cached public key (registered when adding the peer). + +Protocol version: MHP 0.1 +""" + +import logging +import time +import uuid + +import jwt +from fastapi import APIRouter, Depends, HTTPException, Header +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from meshbay_common import MHP_VERSION +from meshbay_hub import __version__ +from meshbay_hub.api.deps import get_current_user +from meshbay_hub.auth import _hub_id, _hub_sk_pem, hub_public_key_pem +from meshbay_hub.db.engine import get_db +from meshbay_hub.db.models import Group, User + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/mhp", tags=["federation"]) + +# ── In-memory peer registry ─────────────────────────────────────────────────── +# Production: move to DB table (peers: hub_id, hub_url, pk_hub_pem, trusted_since) + +_peers: dict[str, dict] = {} # hub_id → {url, pk_pem, trusted_since} + + +def _issue_mhp_token(target_hub_id: str) -> str: + """Issue a short-lived JWT for authenticating to a peer hub.""" + now = int(time.time()) + return jwt.encode({ + "iss": _hub_id, + "sub": _hub_id, + "aud": target_hub_id, + "jti": str(uuid.uuid4()), + "iat": now, + "exp": now + 300, # 5 minute window + "mhp": MHP_VERSION, + }, _hub_sk_pem, algorithm="EdDSA") + + +def _verify_mhp_token(token: str, expected_aud: str | None = None) -> dict: + """Verify a JWT from a peer hub.""" + # First decode without verification to get iss (sender hub_id) + unverified = jwt.decode(token, options={"verify_signature": False}) + sender_id = unverified.get("iss") + + peer = _peers.get(sender_id) + if not peer: + raise PermissionError(f"Unknown hub: {sender_id!r}. Register as peer first.") + + options = {} + if expected_aud: + options["audience"] = expected_aud + + decoded = jwt.decode( + token, peer["pk_pem"].encode(), + algorithms=["EdDSA"], + options=options, + ) + return decoded + + +# ── Hub identity ────────────────────────────────────────────────────────────── + +@router.get("/info") +async def mhp_info(): + """Return this hub's identity for peer registration.""" + return { + "hub_id": _hub_id, + "mhp_version": MHP_VERSION, + "hub_version": __version__, + "pk_hub_pem": hub_public_key_pem().decode(), + } + + +# ── Directory exchange ──────────────────────────────────────────────────────── + +@router.get("/directory") +async def export_directory( + db: AsyncSession = Depends(get_db), + authorization: str = Header(...), +): + """ + Export our public Mesh Directory to a peer hub. + Auth: Bearer JWT signed by peer hub's key. + """ + try: + _verify_mhp_token(authorization.removeprefix("Bearer ")) + except Exception as e: + raise HTTPException(status_code=401, detail=str(e)) + + result = await db.execute( + select(Group).where(Group.visibility == "public", Group.status == "active")) + groups = result.scalars().all() + + return { + "hub_id": _hub_id, + "mhp_version": MHP_VERSION, + "groups": [ + { + "id": g.id, + "name": g.name, + "join_policy": g.join_policy, + "created_at": g.created_at.isoformat(), + "hub_id": _hub_id, + } + for g in groups + ], + } + + +class DirectoryPayload(BaseModel): + hub_id: str + groups: list[dict] + + +@router.post("/directory", status_code=202) +async def receive_directory( + body: DirectoryPayload, + authorization: str = Header(...), +): + """ + Receive a Mesh Directory update from a peer hub. + The directory is stored in memory (production: DB table federated_groups). + """ + try: + _verify_mhp_token(authorization.removeprefix("Bearer ")) + except Exception as e: + raise HTTPException(status_code=401, detail=str(e)) + + log.info("Received %d groups from hub %s", len(body.groups), body.hub_id[:16]) + # TODO Phase 5+: persist to federated_groups table, make searchable + return {"accepted": len(body.groups), "from_hub": body.hub_id} + + +# ── Revocation propagation ──────────────────────────────────────────────────── + +class RevocationPayload(BaseModel): + token: str # signed revocation JWT from originating hub + + +@router.post("/revoke", status_code=202) +async def receive_revocation( + body: RevocationPayload, + authorization: str = Header(...), +): + """ + Receive a revocation from a peer hub. Verify and propagate to our nodes. + """ + try: + _verify_mhp_token(authorization.removeprefix("Bearer ")) + except Exception as e: + raise HTTPException(status_code=401, detail=str(e)) + + # The revocation token is signed by the ORIGINATING hub's key (not the relaying hub) + # For now: re-broadcast to our connected nodes + from meshbay_hub.api.revocation import broadcast_revocation + sent = await broadcast_revocation(body.token) + log.info("Propagated revocation to %d local nodes", sent) + return {"propagated_to": sent} + + +# ── Peer management (admin) ─────────────────────────────────────────────────── + +class PeerRegisterRequest(BaseModel): + hub_id: str + hub_url: str + pk_hub_pem: str # peer hub's Ed25519 public key PEM + + +@router.post("/peers", status_code=201) +async def register_peer( + body: PeerRegisterRequest, + current_user: User = Depends(get_current_user), +): + """Admin: register a trusted peer hub. Manual step — no auto-discovery.""" + _peers[body.hub_id] = { + "url": body.hub_url, + "pk_pem": body.pk_hub_pem, + "trusted_since": int(time.time()), + } + log.info("Peer registered: %s (%s)", body.hub_id, body.hub_url) + return {"status": "registered", "hub_id": body.hub_id} + + +@router.get("/peers") +async def list_peers(current_user: User = Depends(get_current_user)): + """Admin: list registered peer hubs.""" + return { + "peers": [ + {"hub_id": hid, "url": p["url"], "trusted_since": p["trusted_since"]} + for hid, p in _peers.items() + ] + } diff --git a/packages/meshbay-hub/src/meshbay_hub/api/relay.py b/packages/meshbay-hub/src/meshbay_hub/api/relay.py new file mode 100644 index 0000000..d2a4b81 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/api/relay.py @@ -0,0 +1,113 @@ +""" +MeshBay Hub — Mesh Relay registration protocol (5.3). + +Community-operated TURN relays register with hubs. +Nodes query the hub for available relays when UDP hole punching fails. + +Relay registration: + POST /v1/relays/register — relay announces itself (signed JWT) + GET /v1/relays — list active relays (for nodes) + +Relay authentication: relay generates an Ed25519 keypair at install time. +It registers its public key with the hub admin, then signs keepalive JWTs. + +Relay is responsible for E2E encrypted QUIC traffic only (it cannot +read the application-layer content, only forward UDP packets). +""" + +import logging +import time +import uuid + +import jwt +from fastapi import APIRouter, Depends, Header, HTTPException +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from meshbay_hub.api.deps import get_current_user +from meshbay_hub.auth import _hub_id, hub_public_key_pem +from meshbay_hub.db.engine import get_db +from meshbay_hub.db.models import User + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/v1/relays", tags=["relay"]) + +# In-memory relay registry (production: DB table) +_relays: dict[str, dict] = {} # relay_id → {endpoint, pk, last_seen, capacity} + + +# ── Models ──────────────────────────────────────────────────────────────────── + +class RelayRegisterRequest(BaseModel): + """Relay self-registers with a signed JWT.""" + relay_id: str + endpoint: str # "ip:port" (UDP) + pk_relay: str # base64 Ed25519 public key + capacity: int = 100 # max concurrent connections + + +class RelayAdminApproveRequest(BaseModel): + relay_id: str + pk_relay: str # admin approves by registering the relay's public key + + +# ── Relay endpoints ─────────────────────────────────────────────────────────── + +@router.post("/register", status_code=201) +async def relay_register( + body: RelayRegisterRequest, + db: AsyncSession = Depends(get_db), +): + """ + Relay announces itself. Must be pre-approved by a hub admin. + The relay's public key must already be in the approved list. + """ + approved = _relays.get(body.relay_id) + if not approved or approved.get("pk") != body.pk_relay: + raise HTTPException(status_code=403, + detail="Relay not approved — ask hub admin to run POST /v1/relays/approve") + + _relays[body.relay_id].update({ + "endpoint": body.endpoint, + "capacity": body.capacity, + "last_seen": int(time.time()), + "active": True, + }) + log.info("Relay registered: %s at %s", body.relay_id[:8], body.endpoint) + return {"status": "registered", "relay_id": body.relay_id} + + +@router.get("") +async def list_relays(): + """ + List active Mesh Relays. Called by nodes when UDP hole punching fails. + Returns only active relays (seen in the last 5 minutes). + """ + cutoff = int(time.time()) - 300 + active = [ + { + "relay_id": rid, + "endpoint": r["endpoint"], + "capacity": r["capacity"], + } + for rid, r in _relays.items() + if r.get("active") and r.get("last_seen", 0) > cutoff + ] + return {"relays": active, "count": len(active)} + + +@router.post("/approve", status_code=201) +async def admin_approve_relay( + body: RelayAdminApproveRequest, + current_user: User = Depends(get_current_user), +): + """Admin: pre-approve a relay by registering its public key.""" + _relays[body.relay_id] = { + "pk": body.pk_relay, + "approved_by": current_user.username, + "approved_at": int(time.time()), + "active": False, # becomes True after first register call + } + log.info("Relay approved by %s: %s", current_user.username, body.relay_id[:8]) + return {"status": "approved", "relay_id": body.relay_id} diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index 5b01e80..dcb255b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -26,6 +26,8 @@ from meshbay_hub.api.nodes import router as nodes_router from meshbay_hub.api.groups import router as groups_router from meshbay_hub.api.revocation import router as revocation_router from meshbay_hub.api.moderation import router as moderation_router +from meshbay_hub.api.federation import router as federation_router +from meshbay_hub.api.relay import router as relay_router from meshbay_hub.api.webapp import router as webapp_router from meshbay_hub.api.middleware import limiter @@ -69,6 +71,8 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: app.include_router(groups_router) app.include_router(revocation_router) app.include_router(moderation_router) + app.include_router(federation_router) + app.include_router(relay_router) app.include_router(webapp_router) return app -- cgit v1.2.3