aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/relay.py
blob: c6ef26ef0afc5b97c530f4c1d035dfc5859af5b2 (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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
"""
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:<relay_id>:<endpoint>:<timestamp>" —
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__)

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, 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 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}