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/static/webrtc-test.html | |
| 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/static/webrtc-test.html')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html | 258 |
1 files changed, 258 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html b/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html new file mode 100644 index 0000000..0d5750b --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html @@ -0,0 +1,258 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>MeshBay — WebRTC Spike Test</title> + <style> + *, *::before, *::after { box-sizing: border-box; } + body { font-family: system-ui, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; } + .container { max-width: 800px; margin: 32px auto; padding: 0 16px; } + h1 { color: #38bdf8; font-size: 1.4em; } + h2 { color: #94a3b8; font-size: 1.1em; margin-top: 2em; } + .step { background: #1e293b; border: 1px solid #334155; border-radius: 8px; + padding: 16px; margin: 12px 0; } + .step.done { border-color: #22c55e; } + .step.fail { border-color: #ef4444; } + .step.active { border-color: #38bdf8; } + input { padding: 8px 12px; border: 1px solid #475569; border-radius: 6px; + background: #0f172a; color: #e2e8f0; font-size: 0.95em; margin: 4px; width: 240px; } + button { padding: 8px 20px; background: #0ea5e9; color: #fff; border: none; + border-radius: 6px; cursor: pointer; font-size: 0.95em; margin: 4px; } + button:hover { background: #0284c7; } + button:disabled { background: #475569; cursor: not-allowed; } + #log { background: #020617; border: 1px solid #1e293b; border-radius: 8px; + padding: 12px; font-family: monospace; font-size: 0.85em; line-height: 1.6; + max-height: 400px; overflow-y: auto; white-space: pre-wrap; } + .ok { color: #22c55e; } + .err { color: #ef4444; } + .info { color: #38bdf8; } + .warn { color: #f59e0b; } + .dim { color: #64748b; } + .badge { display: inline-block; background: #22c55e; color: #0f172a; padding: 2px 8px; + border-radius: 4px; font-size: 0.8em; font-weight: bold; margin-left: 8px; } + .badge.fail { background: #ef4444; color: #fff; } + </style> +</head> +<body> +<div class="container"> + <h1>MeshBay — WebRTC DataChannel Spike Test</h1> + <p class="dim">Phase 9.5 — E2E browser → NAT → node file transfer via WebRTC</p> + + <div class="step" id="step-login"> + <h2>1. Login to Hub</h2> + <input id="username" placeholder="Username" value="bob"> + <input id="password" placeholder="Password" type="password" value="bob"> + <button id="btn-login" onclick="doLogin()">Login</button> + <span id="login-status"></span> + </div> + + <div class="step" id="step-connect"> + <h2>2. Connect to Node via WebRTC</h2> + <input id="node-id" placeholder="Node ID"> + <input id="group-id" placeholder="Group ID (optional)"> + <button id="btn-connect" onclick="doConnect()" disabled>Connect</button> + <span id="connect-status"></span> + </div> + + <div class="step" id="step-transfer"> + <h2>3. File Transfer Test</h2> + <button id="btn-index" onclick="doFetchIndex()" disabled>Fetch Index</button> + <br> + <input id="file-id" placeholder="File ID (blake3 hex, from node log)"> + <button id="btn-chunk" onclick="doFetchChunk()" disabled>Fetch Chunk</button> + <span id="transfer-status"></span> + </div> + + <h2>Log</h2> + <div id="log"></div> +</div> + +<script src="/transport.js"></script> +<script> +const HUB_URL = window.location.origin; +const params = new URLSearchParams(window.location.search); +let accessToken = null; +let jwtToken = null; +let transport = null; +let fileIndex = null; + +// Pre-fill from URL params +if (params.get('user')) document.getElementById('username').value = params.get('user'); +if (params.get('pass')) document.getElementById('password').value = params.get('pass'); +if (params.get('node')) document.getElementById('node-id').value = params.get('node'); +if (params.get('group')) document.getElementById('group-id').value = params.get('group'); +if (params.get('file')) document.getElementById('file-id').value = params.get('file'); + +// Auto-run if all params provided +if (params.get('auto')) { + setTimeout(async () => { + await doLogin(); + if (accessToken) await doConnect(); + if (transport && transport.connected) { + await doFetchIndex(); + if (document.getElementById('file-id').value) await doFetchChunk(); + } + }, 500); +} + +function logMsg(cls, text) { + const el = document.getElementById('log'); + const line = document.createElement('span'); + line.className = cls; + line.textContent = text + '\n'; + el.appendChild(line); + el.scrollTop = el.scrollHeight; +} + +function setStep(id, state) { + const el = document.getElementById(id); + el.className = 'step ' + state; +} + +async function doLogin() { + const user = document.getElementById('username').value; + const pass = document.getElementById('password').value; + logMsg('info', `Logging in as ${user}...`); + setStep('step-login', 'active'); + + try { + const resp = await fetch(`${HUB_URL}/v1/users/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: user, password: pass }), + }); + + if (!resp.ok) { + const err = await resp.json(); + throw new Error(err.detail || resp.statusText); + } + + const data = await resp.json(); + accessToken = data.access_token; + jwtToken = data.access_token; + logMsg('ok', `Login OK — token: ${accessToken.substring(0, 20)}...`); + setStep('step-login', 'done'); + document.getElementById('login-status').innerHTML = '<span class="badge">OK</span>'; + document.getElementById('btn-connect').disabled = false; + } catch (e) { + logMsg('err', `Login FAILED: ${e.message}`); + setStep('step-login', 'fail'); + document.getElementById('login-status').innerHTML = '<span class="badge fail">FAIL</span>'; + } +} + +async function doConnect() { + const nodeId = document.getElementById('node-id').value; + const groupId = document.getElementById('group-id').value; + if (!nodeId) { logMsg('warn', 'Enter a node ID'); return; } + + logMsg('info', `Connecting to node ${nodeId.substring(0, 8)}... via WebRTC`); + setStep('step-connect', 'active'); + + try { + transport = new MeshBayTransport(HUB_URL, accessToken); + + logMsg('dim', ' Creating RTCPeerConnection...'); + logMsg('dim', ' Creating DataChannel "mnp"...'); + logMsg('dim', ' Gathering ICE candidates...'); + logMsg('dim', ' Sending SDP offer to hub...'); + + const t0 = performance.now(); + const ack = await transport.connect(nodeId, jwtToken, groupId); + const elapsed = (performance.now() - t0).toFixed(0); + + logMsg('ok', `WebRTC connected in ${elapsed}ms`); + logMsg('ok', ` MNP handshake_ack — node_pk: ${ack.node_pk?.substring(0, 16)}...`); + logMsg('ok', ` DataChannel: open, ordered, reliable`); + setStep('step-connect', 'done'); + document.getElementById('connect-status').innerHTML = '<span class="badge">P2P OK</span>'; + document.getElementById('btn-index').disabled = false; + document.getElementById('btn-chunk').disabled = false; + } catch (e) { + logMsg('err', `Connection FAILED: ${e.message}`); + setStep('step-connect', 'fail'); + document.getElementById('connect-status').innerHTML = '<span class="badge fail">FAIL</span>'; + } +} + +async function doFetchIndex() { + logMsg('info', 'Fetching Mesh Group Index via DataChannel...'); + try { + const t0 = performance.now(); + const indexBytes = await transport.fetchIndex(); + const elapsed = (performance.now() - t0).toFixed(0); + + logMsg('ok', `Index received: ${indexBytes.byteLength} bytes in ${elapsed}ms`); + + try { + const envelope = msgpack_decode(indexBytes); + logMsg('dim', ` type: ${envelope.type}, encrypted: ${envelope.encrypted}, version: ${envelope.version}`); + logMsg('dim', ` group_id: ${envelope.group_id}`); + + if (envelope.encrypted) { + logMsg('warn', ` Index is GEK-encrypted — browser decryption not implemented in spike`); + logMsg('dim', ` ct_b64 length: ${envelope.ct_b64?.length || 0} chars`); + logMsg('info', ` Spike workaround: enter a file_id manually or use Fetch First Chunk`); + // Store envelope so chunk test can proceed with manual file_id + fileIndex = { entries: [], envelope }; + } else { + // Public group: decompress and parse + logMsg('dim', ` Public index — data_b64 length: ${envelope.data_b64?.length || 0}`); + fileIndex = { entries: [], envelope }; + } + } catch (pe) { + logMsg('warn', ` Could not parse index envelope: ${pe.message}`); + } + } catch (e) { + logMsg('err', `Index fetch FAILED: ${e.message}`); + } +} + +async function doFetchChunk() { + let fileId = document.getElementById('file-id').value.trim(); + + if (!fileId) { + logMsg('warn', 'Enter a file_id (blake3 hex hash from node indexer log)'); + logMsg('dim', ' Look for "Initial scan complete" in the node terminal'); + logMsg('dim', ' Or run: python -c "import blake3; print(blake3.blake3(open(\'QE/demo-v3/shared_media/sample.txt\',\'rb\').read()).hexdigest())"'); + return; + } + + logMsg('info', `Fetching chunk 0 of file ${fileId.substring(0, 16)}... via DataChannel...`); + + try { + const t0 = performance.now(); + const chunkMsg = await transport.fetchChunk(fileId, 0); + const elapsed = (performance.now() - t0).toFixed(0); + + if (chunkMsg.type === 'error') { + logMsg('err', `Chunk fetch error: ${chunkMsg.detail}`); + return; + } + + logMsg('ok', `Chunk received in ${elapsed}ms:`); + logMsg('ok', ` type: ${chunkMsg.type}`); + logMsg('ok', ` chunk_index: ${chunkMsg.chunk_index}`); + logMsg('ok', ` plaintext_size: ${chunkMsg.plaintext_size} bytes`); + logMsg('ok', ` ct_b64 length: ${chunkMsg.ct_b64?.length || 0} chars`); + logMsg('ok', ` nonce_b64: ${chunkMsg.nonce_b64?.substring(0, 16)}...`); + logMsg('ok', ` sig_b64: ${chunkMsg.sig_b64?.substring(0, 16)}...`); + + logMsg('', ''); + logMsg('ok', '=== SPIKE TEST PASSED ==='); + logMsg('ok', 'Browser connected to node via WebRTC DataChannel.'); + logMsg('ok', 'MNP handshake, index sync, and file chunk transfer all work.'); + logMsg('ok', 'Data flowed P2P — hub was only used for signaling.'); + + setStep('step-transfer', 'done'); + document.getElementById('transfer-status').innerHTML = '<span class="badge">E2E OK</span>'; + } catch (e) { + logMsg('err', `Chunk fetch FAILED: ${e.message}`); + setStep('step-transfer', 'fail'); + document.getElementById('transfer-status').innerHTML = '<span class="badge fail">FAIL</span>'; + } +} +</script> +</body> +</html> |