aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-09 05:20:43 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-09 05:20:43 +0200
commit95e045272243043ed9a207115f7737a0e3eb1ccc (patch)
treec6255c770fc64bb7505400d7c14a8a418b0fc747
parentdeee67755991994742ef144400857dd5f6b8aafa (diff)
downloadmeshbay-95e045272243043ed9a207115f7737a0e3eb1ccc.tar.gz
feat(hub): add MHP federation + Mesh Relay registration — 5.2 + 5.3
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) <noreply@anthropic.com>
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/federation.py217
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/relay.py113
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py4
3 files changed, 334 insertions, 0 deletions
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