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
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
|
"""
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__)
# **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 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}
|