""" 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. An admin approves the public key, and every register call carries an Ed25519 signature over "meshbay:relay_register:::" — the same proof-of-possession shape as /v1/nodes/announce. Relay is responsible for E2E encrypted QUIC traffic only (it cannot read the application-layer content, only forward UDP packets). """ import base64 import logging import time from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.api.deps import require_admin from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import User log = logging.getLogger(__name__) # **Closed, the same way and for a similar reason as federation.** Nothing in the # tree calls these routes — no node asks for a relay, no client offers one — and # §11.1 measured two ISPs with no TURN relay needed. Two of the three take no # account and answer anyone who can reach the hub, so a registry nothing uses # was an unauthenticated surface kept for its own sake. A constant, not a # setting: re-opening it means building the node side first, then flipping this. RELAYS_ENABLED = False def _relays_open() -> None: """Refuse every route on this router while the registry is closed. On the router rather than in each handler, so a route added later is closed before anybody remembers to write the check (C6). """ if not RELAYS_ENABLED: raise HTTPException(status_code=503, detail="The relay registry is not enabled on this hub") router = APIRouter(prefix="/v1/relays", tags=["relay"], dependencies=[Depends(_relays_open)]) # 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, proving possession of its approved key.""" relay_id: str endpoint: str # "ip:port" (UDP) pk_relay: str # base64 Ed25519 public key capacity: int = 100 # max concurrent connections timestamp: int | None = None # unix seconds signature: str | None = None # base64 Ed25519 over the register message class RelayAdminApproveRequest(BaseModel): relay_id: str pk_relay: str # admin approves by registering the relay's public key # ── Relay endpoints ─────────────────────────────────────────────────────────── REGISTER_TIMESTAMP_WINDOW = 300 # seconds either side, as /v1/nodes/announce @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, and must prove it holds the private key that approval registered. This endpoint has no `Depends` on an account on purpose — a relay is not a user — but it had no proof of anything either: it compared `pk_relay` against the approved value, which is a **public** key, so anyone who could read it could rewrite where the hub tells nodes to send relayed traffic. The module docstring said "signs keepalive JWTs" and nothing verified a signature; `jwt` was imported and never used. A key is not a password, and the fix is the proof-of-possession pattern already used by /v1/nodes/announce and /v1/nodes/auth. """ 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 the hub admin to " "run POST /v1/relays/approve") if body.timestamp is None or not body.signature: raise HTTPException( status_code=400, detail="register requires timestamp and signature (proof of possession)") if abs(int(time.time()) - body.timestamp) > REGISTER_TIMESTAMP_WINDOW: raise HTTPException(status_code=401, detail="Timestamp too old or too far ahead") message = (f"meshbay:relay_register:{body.relay_id}:" f"{body.endpoint}:{body.timestamp}").encode() try: pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(body.pk_relay)) pk.verify(base64.b64decode(body.signature), message) except Exception: log.warning("Relay %s failed proof of possession", body.relay_id[:8]) raise HTTPException(status_code=401, detail="Invalid relay key proof of possession") _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(require_admin), ): """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}