diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-10 22:12:59 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-10 22:12:59 +0200 |
| commit | 60c4570e72e36c2a9720593c8baec74ee2ab52d6 (patch) | |
| tree | e833e222a6a95e64e5b24a0813882636044198c2 /packages/meshbay-hub/src/meshbay_hub/api | |
| parent | 1a53eb4cc404ec94658fde0ae04cfe2ccf1810dc (diff) | |
| download | meshbay-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.py | 5 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/signaling.py | 102 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/webapp.py | 15 |
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) |