summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-10 22:12:59 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-10 22:12:59 +0200
commit60c4570e72e36c2a9720593c8baec74ee2ab52d6 (patch)
treee833e222a6a95e64e5b24a0813882636044198c2 /packages/meshbay-hub/src/meshbay_hub/api
parent1a53eb4cc404ec94658fde0ae04cfe2ccf1810dc (diff)
downloadmeshbay-60c4570e72e36c2a9720593c8baec74ee2ab52d6.tar.gz
feat: Phase 9.1–9.5 — WebRTC DataChannel transport for browser P2P
Browser clients can now connect P2P to nodes behind residential NAT via WebRTC DataChannel with ICE/STUN. Validated on SFR Port-Restricted Cone NAT + 4G CGNAT across three scenarios (WiFi LAN, 4G IPv6, 4G IPv4 STUN). No TURN relay needed. Hub serves only as signaling relay (<1 KB). New files: - webrtc_server.py: aiortc-based WebRTC transport (node side) - signaling.py: SDP/ICE relay endpoint (hub side) - transport.js: browser WebRTC client with msgpack framing - webrtc-test.html: spike test page for browser→NAT→node validation - test_webrtc_transport.py: 4 tests (handshake, file transfer, auth, guard) - meshbay-draft-v4.md: architecture spec updated for web client Modified: - hub_client.py: WebRTC offer handling via hub WebSocket - revocation.py: node_id from WS auth + webrtc_answer routing - pyproject.toml: aiortc>=1.9 dependency 123 tests passing (117 existing + 6 new). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/signaling.py102
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py15
3 files changed, 121 insertions, 1 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index bbd1bc2..8f30745 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -119,7 +119,7 @@ async def node_websocket(ws: WebSocket):
await ws.close(code=4001)
return
- node_id = decoded.get("sub", "unknown")
+ node_id = msg.get("node_id") or decoded.get("sub", "unknown")
_connected_nodes[node_id] = ws
log.info("Node WS connected: %s", node_id[:8])
await ws.send_text(json.dumps({"type": "auth_ok", "node_id": node_id}))
@@ -134,6 +134,9 @@ async def node_websocket(ws: WebSocket):
event = _punch_events.get(node_id)
if event:
event.set()
+ elif msg.get("type") == "webrtc_answer":
+ from meshbay_hub.api.signaling import handle_webrtc_answer
+ handle_webrtc_answer(msg)
except WebSocketDisconnect:
log.info("Node WS disconnected: %s", (node_id or "unknown")[:8])
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
new file mode 100644
index 0000000..bd343c9
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
@@ -0,0 +1,102 @@
+"""
+WebRTC signaling — relay SDP/ICE between browser and node.
+
+The hub NEVER touches content. This is pure signaling: < 1 KB per message,
+stateless relay. After the SDP exchange completes, the browser and node
+communicate P2P via WebRTC DataChannel — hub is out of the loop.
+
+Flow:
+ Browser → Hub : POST /v1/nodes/{node_id}/webrtc/offer {sdp, ice_candidates}
+ Hub → Node : WS push {type: "webrtc_offer", sdp, ice_candidates, peer_id}
+ Node → Hub : WS reply {type: "webrtc_answer", sdp, ice_candidates, peer_id}
+ Hub → Browser : HTTP response {sdp, ice_candidates}
+"""
+
+import asyncio
+import json
+import logging
+import uuid
+
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+
+from meshbay_hub.api.deps import get_current_user
+from meshbay_hub.db.models import User
+
+log = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/v1/nodes", tags=["signaling"])
+
+_webrtc_answers: dict[str, asyncio.Future] = {}
+
+
+class WebRTCOfferRequest(BaseModel):
+ sdp: str
+ ice_candidates: list[dict] = []
+
+
+class WebRTCOfferResponse(BaseModel):
+ sdp: str
+ ice_candidates: list[dict] = []
+ peer_id: str
+
+
+@router.post("/{node_id}/webrtc/offer", response_model=WebRTCOfferResponse)
+async def webrtc_offer(
+ node_id: str,
+ body: WebRTCOfferRequest,
+ current_user: User = Depends(get_current_user),
+):
+ """
+ Browser sends WebRTC SDP offer for a node. Hub relays via WebSocket.
+ Returns the node's SDP answer once received.
+ """
+ from meshbay_hub.api.revocation import _connected_nodes
+
+ ws = _connected_nodes.get(node_id)
+ if not ws:
+ raise HTTPException(status_code=404, detail="Node not connected")
+
+ peer_id = str(uuid.uuid4())
+ answer_future: asyncio.Future = asyncio.get_event_loop().create_future()
+ _webrtc_answers[peer_id] = answer_future
+
+ try:
+ await ws.send_text(json.dumps({
+ "type": "webrtc_offer",
+ "peer_id": peer_id,
+ "user_id": current_user.id,
+ "sdp": body.sdp,
+ "ice_candidates": body.ice_candidates,
+ }))
+
+ try:
+ answer = await asyncio.wait_for(answer_future, timeout=15.0)
+ except asyncio.TimeoutError:
+ raise HTTPException(
+ status_code=504, detail="Node did not respond with WebRTC answer")
+
+ return WebRTCOfferResponse(
+ sdp=answer["sdp"],
+ ice_candidates=answer.get("ice_candidates", []),
+ peer_id=peer_id,
+ )
+ finally:
+ _webrtc_answers.pop(peer_id, None)
+
+
+def handle_webrtc_answer(msg: dict) -> None:
+ """Called from the node WebSocket message loop when a webrtc_answer arrives."""
+ peer_id = msg.get("peer_id")
+ if not peer_id:
+ log.warning("webrtc_answer without peer_id")
+ return
+
+ future = _webrtc_answers.get(peer_id)
+ if future and not future.done():
+ future.set_result({
+ "sdp": msg.get("sdp", ""),
+ "ice_candidates": msg.get("ice_candidates", []),
+ })
+ else:
+ log.warning("webrtc_answer for unknown peer_id: %s", peer_id)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index 3d5e78b..6005927 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -27,6 +27,21 @@ async def app_js():
return FileResponse(STATIC_DIR / "app.js", media_type="application/javascript")
+@router.get("/transport.js")
+async def transport_js():
+ return FileResponse(STATIC_DIR / "transport.js", media_type="application/javascript")
+
+
+@router.get("/crypto.js")
+async def crypto_js():
+ return FileResponse(STATIC_DIR / "crypto.js", media_type="application/javascript")
+
+
+@router.get("/webrtc-test.html")
+async def webrtc_test():
+ return FileResponse(STATIC_DIR / "webrtc-test.html", media_type="text/html")
+
+
@router.get("/", response_class=HTMLResponse)
async def index():
return HTMLResponse(_HTML)