aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/relay.py
blob: f82495b4cc781d9657cd2c4439a9472ccab4355f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
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, require_admin
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(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}