diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 05:20:43 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 05:20:43 +0200 |
| commit | 95e045272243043ed9a207115f7737a0e3eb1ccc (patch) | |
| tree | c6255c770fc64bb7505400d7c14a8a418b0fc747 /packages/meshbay-hub/src/meshbay_hub/api/relay.py | |
| parent | deee67755991994742ef144400857dd5f6b8aafa (diff) | |
| download | meshbay-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>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/relay.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/relay.py | 113 |
1 files changed, 113 insertions, 0 deletions
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} |