diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 89 |
1 files changed, 84 insertions, 5 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 15c7b09a..0d987d9a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -103,6 +103,71 @@ function _retiredBundleError() { return err; } +// ── A device that refuses the local network ────────────────────────────────── +// +// Android 17 makes reaching the local network a permission of the application +// (ACCESS_LOCAL_NETWORK), enforced for any application that targets it. Chrome +// does from 155, and holds the permission only once the person grants it. Until +// then the browser drops the node's addresses on the phone's own subnet: they +// are absent from getStats, no check goes out to them, the node's own checks +// get no answer, ICE fails and the channel closes. "DataChannel closed" was all +// this page could say, and on mobile data the same group opened, over the +// node's public address. Measured on a Pixel, Android 17, Chrome 155, +// 2026-10-16 (tests/test_local_network_refused.py). + +const _PRIVATE_V4 = /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/; +const _IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/; + +function _sdpCandidates(sdp) { + const out = []; + for (const line of String(sdp || '').split(/\r?\n/)) { + if (!line.startsWith('a=candidate:')) continue; + const parts = line.split(' '); + if (parts.length >= 8) out.push({ address: parts[4], type: parts[7] }); + } + return out; +} + +const _slash24 = (address) => address.split('.').slice(0, 3).join('.'); + +/** + * Whether this device dropped the node's local addresses rather than failed to + * reach them. + * + * A browser pairs every remote address of a family with each local candidate + * of that family, and getStats lists each one that was paired, answered or + * not: desktop Chrome 152, 155 and 157 listed all six of a node's. So a private + * IPv4 address of the node that is missing, while we have IPv4 of our own, was + * refused here. A desktop browser hides its own addresses behind `.local` + * names, which are on a local network by construction. With nothing paired at + * all there is no comparison to make, and only an address on our own subnet + * says it. + */ +function localNetworkRefused(offerSdp, answerSdp, remoteAddresses) { + const paired = new Set(remoteAddresses); + const refused = _sdpCandidates(answerSdp) + .filter((c) => c.type === 'host' && _PRIVATE_V4.test(c.address) && !paired.has(c.address)) + .map((c) => c.address); + if (!refused.length) return false; + const ours = _sdpCandidates(offerSdp); + const ourV4 = ours.filter((c) => _IPV4.test(c.address)).map((c) => c.address); + if (!ourV4.length && !ours.some((c) => c.address.endsWith('.local'))) return false; + if (paired.size > 0) return true; + return ourV4.some((a) => refused.some((r) => _slash24(r) === _slash24(a))); +} + +async function _localNetworkRefusedBy(pc, offerSdp, answerSdp) { + try { + const remote = []; + (await pc.getStats()).forEach((r) => { + if (r.type === 'remote-candidate') remote.push(r.address || r.ip); + }); + return localNetworkRefused(offerSdp, answerSdp, remote); + } catch { + return false; // a closed connection has no stats: the old error stands + } +} + // Segments of 256 KB: 24 in flight is 6 MB, enough to keep playback fed over a // slow link and small enough that nothing accumulates. // How long to collect ICE candidates before sending the offer anyway. Long @@ -886,6 +951,9 @@ class MeshBayTransport { // whatever is current — the `pc === this._pc` check below is what that // buys. const pc = this._pc; + // Whether a candidate pair ever answered on this connection: a failure + // after that is not the local network being refused. + let iceReached = false; pc.onconnectionstatechange = () => { console.log('[MeshBay] PC state:', pc.connectionState); trace('pc_state', { state: pc.connectionState }); @@ -913,10 +981,9 @@ class MeshBayTransport { // `connected`/`completed` is the first moment this browser knows the peer // is there at all: a candidate pair answered. `checking` is not — it is // this side trying addresses that may all be dead. - if (pc === this._pc - && (pc.iceConnectionState === 'connected' - || pc.iceConnectionState === 'completed')) { - this._noteConnectProgress('ice_connected'); + if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') { + iceReached = true; + if (pc === this._pc) this._noteConnectProgress('ice_connected'); } }; @@ -1029,9 +1096,20 @@ class MeshBayTransport { const answer = await resp.json(); this._rawAnswerSdp = answer.sdp; + const offerSdp = this._pc.localDescription.sdp; await this._pc.setRemoteDescription({ type: 'answer', sdp: answer.sdp }); - await channelReady; + try { + await channelReady; + } catch (err) { + if (!iceReached && await _localNetworkRefusedBy(pc, offerSdp, answer.sdp)) { + trace('local_network_refused', {}); + const refused = new Error('This device refused to reach the local network'); + refused.reason = 'local_network_blocked'; + throw refused; + } + throw err; + } console.log('[MeshBay] DataChannel open, sending handshake for group', groupId, 'channel=', this._channel?.readyState, 'crypto=', !!window.MeshBayCrypto); @@ -2581,4 +2659,5 @@ function extendTransport(part) { } // Export +MeshBayTransport.localNetworkRefused = localNetworkRefused; window.MeshBayTransport = MeshBayTransport; |