From 6329beaca34e31749ff0343d02e27db2826157e7 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 17 Oct 2026 10:01:05 +0200 Subject: fix(spa): say when the device refuses the local network Android 17 makes the local network a permission of the application, and Chrome 155 targets it: without it the node's LAN addresses are dropped and a group on the phone's own Wi-Fi failed with "DataChannel closed". The transport now names that refusal and the group page says what to allow. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_hub/static/group-page.js | 7 + .../src/meshbay_hub/static/locales/de.js | 2 + .../src/meshbay_hub/static/locales/en.js | 2 + .../src/meshbay_hub/static/locales/es.js | 2 + .../src/meshbay_hub/static/locales/fr.js | 2 + .../src/meshbay_hub/static/locales/it.js | 2 + .../src/meshbay_hub/static/locales/ja.js | 2 + .../src/meshbay_hub/static/locales/nl.js | 2 + .../src/meshbay_hub/static/locales/pl.js | 2 + .../src/meshbay_hub/static/locales/pt-BR.js | 2 + .../src/meshbay_hub/static/locales/zh-CN.js | 2 + .../src/meshbay_hub/static/transport.js | 89 +++++++++++- .../tests/harness/local_network_harness.mjs | 156 +++++++++++++++++++++ .../tests/test_local_network_refused.py | 106 ++++++++++++++ 14 files changed, 373 insertions(+), 5 deletions(-) create mode 100644 packages/meshbay-hub/tests/harness/local_network_harness.mjs create mode 100644 packages/meshbay-hub/tests/test_local_network_refused.py (limited to 'packages') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 422d90b9..5972e000 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -703,6 +703,13 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, if (err.reason === 'no_keys') setNeedsPass(true); // An identity sealed before the pepper: only the operator can clear it. if (err.reason === 'bundle_format_retired') err.message = t('group.bundle_format_retired'); + // This device dropped the node's local addresses (transport.js + // localNetworkRefused): a setting to change here, not a node to wait + // for. Android 17 gates it per application, Chrome from 155. + if (err.reason === 'local_network_blocked') { + err.message = t(/Android/i.test(navigator.userAgent) + ? 'group.lan_blocked_android' : 'group.lan_blocked_browser'); + } // A key this node has never pinned, for an account it knows. The way in // is a device already trusted here, not an operator — which is the // whole point of device linking: a second browser or a native client diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 0489b952..c121cee0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -1300,6 +1300,8 @@ export default { 'hosts.online': "online", 'group.bundle_format_retired': 'Dieser Node speichert Ihre Identität in einem Format, das diese Version nicht mehr liest. Bitten Sie den Betreiber, „meshbay-node member unpin" für Ihr Konto auszuführen und Ihnen einen neuen Einladungscode zu senden.', + 'group.lan_blocked_android': "Android erlaubt diesem Browser nicht, Geräte in Ihrem lokalen Netzwerk zu erreichen, und der Node dieser Gruppe ist dort. Öffnen Sie Android-Einstellungen › Apps › Chrome (oder Ihr Browser) › Berechtigungen › Geräte in der Nähe, erlauben Sie den Zugriff und tippen Sie dann auf Erneut versuchen. Über mobile Daten öffnet sich die Gruppe auch ohne.", + 'group.lan_blocked_browser': "Dieser Browser hat es abgelehnt, Geräte in Ihrem lokalen Netzwerk zu erreichen, und der Node dieser Gruppe ist dort. Erlauben Sie dieser Website den Zugriff auf das lokale Netzwerk (Symbol links neben der Adressleiste) und versuchen Sie es dann erneut.", 'settings.browser_access': 'Browserzugang', 'settings.browser_access_on_hint': 'Ein Browser kann Ihre Gruppen mit Ihrer Passphrase öffnen: Diese Anwendung legt Ihre Identität auf jedem Node ab, so versiegelt, dass nur Ihre Passphrase zusammen mit Ihrem Hub-Konto sie öffnen kann.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index cb74f186..0dd8448f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -1285,6 +1285,8 @@ export default { 'hosts.online': "online", 'group.bundle_format_retired': 'This node holds your identity in a format this version no longer reads. Ask its operator to run “meshbay-node member unpin” for your account and send you a new invitation code.', + 'group.lan_blocked_android': "Android does not let this browser reach devices on your local network, and this group's node is on it. Open Android Settings › Apps › Chrome (or the browser you use) › Permissions › Nearby devices, allow it, then tap Retry. On mobile data the group opens without it.", + 'group.lan_blocked_browser': "This browser refused to reach devices on your local network, and this group's node is on it. Allow local network access for this site (the icon left of the address bar), then Retry.", 'settings.browser_access': 'Browser access', 'settings.browser_access_on_hint': 'A browser can open your groups with your passphrase: this application leaves your identity on each node, sealed so that only your passphrase together with your hub account can open it.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index b8d1b89e..8bc6efbf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -1295,6 +1295,8 @@ export default { 'hosts.online': "en línea", 'group.bundle_format_retired': 'Este node guarda su identidad en un formato que esta versión ya no lee. Pida a su operador que ejecute «meshbay-node member unpin» para su cuenta y le envíe un nuevo código de invitación.', + 'group.lan_blocked_android': "Android no permite que este navegador acceda a los dispositivos de su red local, y el node de este grupo está en ella. Abra Ajustes de Android › Aplicaciones › Chrome (o el navegador que use) › Permisos › Dispositivos cercanos, permítalo y toque Reintentar. Con datos móviles, el grupo se abre sin este permiso.", + 'group.lan_blocked_browser': "Este navegador se negó a acceder a los dispositivos de su red local, y el node de este grupo está en ella. Permita el acceso a la red local para este sitio (el icono a la izquierda de la barra de direcciones) y vuelva a intentarlo.", 'settings.browser_access': 'Acceso desde el navegador', 'settings.browser_access_on_hint': 'Un navegador puede abrir sus grupos con su frase de contraseña: esta aplicación deja su identidad en cada node, sellada de modo que solo su frase de contraseña, junto con su cuenta del hub, pueda abrirla.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index c895b639..125a2891 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -1310,6 +1310,8 @@ export default { 'hosts.online': "en ligne", 'group.bundle_format_retired': 'Ce node détient votre identité dans un format que cette version ne lit plus. Demandez à son opérateur d\'exécuter « meshbay-node member unpin » pour votre compte et de vous envoyer un nouveau code d\'invitation.', + 'group.lan_blocked_android': "Android n’autorise pas ce navigateur à joindre les appareils de votre réseau local, où se trouve le node de ce groupe. Ouvrez Paramètres Android › Applis › Chrome (ou votre navigateur) › Autorisations › Appareils à proximité, autorisez, puis touchez Réessayer. En données mobiles, le groupe s’ouvre sans cette autorisation.", + 'group.lan_blocked_browser': "Ce navigateur a refusé de joindre les appareils de votre réseau local, où se trouve le node de ce groupe. Autorisez l’accès au réseau local pour ce site (icône à gauche de la barre d’adresse), puis Réessayer.", 'settings.browser_access': 'Accès depuis un navigateur', 'settings.browser_access_on_hint': 'Un navigateur peut ouvrir vos groupes avec votre phrase secrète : cette application dépose votre identité sur chaque node, scellée de sorte que seule votre phrase secrète, avec votre compte sur le hub, puisse l\'ouvrir.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 8da29530..e7990b9a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -1309,6 +1309,8 @@ export default { 'hosts.online': "online", 'group.bundle_format_retired': 'Questo node conserva la tua identità in un formato che questa versione non legge più. Chiedi al suo operatore di eseguire «meshbay-node member unpin» per il tuo account e di inviarti un nuovo codice di invito.', + 'group.lan_blocked_android': "Android non consente a questo browser di raggiungere i dispositivi della tua rete locale, dove si trova il node di questo gruppo. Apri Impostazioni Android › App › Chrome (o il browser che usi) › Autorizzazioni › Dispositivi nelle vicinanze, consenti, poi tocca Riprova. Con i dati mobili il gruppo si apre anche senza.", + 'group.lan_blocked_browser': "Questo browser ha rifiutato di raggiungere i dispositivi della tua rete locale, dove si trova il node di questo gruppo. Consenti l’accesso alla rete locale per questo sito (l’icona a sinistra della barra degli indirizzi), poi Riprova.", 'settings.browser_access': 'Accesso dal browser', 'settings.browser_access_on_hint': 'Un browser può aprire i tuoi gruppi con la tua passphrase: questa applicazione lascia la tua identità su ogni node, sigillata in modo che solo la tua passphrase, insieme al tuo account sull\'hub, possa aprirla.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index c1f95230..24450a3d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -1293,6 +1293,8 @@ export default { 'hosts.online': "オンライン", 'group.bundle_format_retired': 'この node は、このバージョンでは読めなくなった形式であなたの ID を保持しています。運用者に、あなたのアカウントに対して「meshbay-node member unpin」を実行し、新しい招待コードを送るよう依頼してください。', + 'group.lan_blocked_android': "Android がこのブラウザにローカルネットワーク上のデバイスへの接続を許可していません。このグループの node はそのネットワーク上にあります。Android の設定 › アプリ › Chrome(またはお使いのブラウザ)› 権限 › 付近のデバイス を開いて許可し、再試行をタップしてください。モバイルデータでは、この権限がなくてもグループを開けます。", + 'group.lan_blocked_browser': "このブラウザはローカルネットワーク上のデバイスへの接続を拒否しました。このグループの node はそのネットワーク上にあります。このサイトにローカルネットワークへのアクセスを許可し(アドレスバー左のアイコン)、再試行してください。", 'settings.browser_access': 'ブラウザーからのアクセス', 'settings.browser_access_on_hint': 'ブラウザーはパスフレーズであなたのグループを開けます。このアプリは各 node にあなたの ID を残し、パスフレーズと hub のアカウントの両方がそろった場合にのみ開けるよう封印します。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 66505da5..e37875ca 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -1311,6 +1311,8 @@ export default { 'hosts.online': "online", 'group.bundle_format_retired': 'Deze node bewaart je identiteit in een formaat dat deze versie niet meer leest. Vraag de beheerder om "meshbay-node member unpin" voor je account uit te voeren en je een nieuwe uitnodigingscode te sturen.', + 'group.lan_blocked_android': "Android staat deze browser niet toe apparaten in je lokale netwerk te bereiken, en de node van deze groep staat daar. Open Android-instellingen › Apps › Chrome (of je browser) › Rechten › Apparaten in de buurt, sta het toe en tik dan op Opnieuw proberen. Via mobiele data opent de groep ook zonder.", + 'group.lan_blocked_browser': "Deze browser weigerde apparaten in je lokale netwerk te bereiken, en de node van deze groep staat daar. Sta toegang tot het lokale netwerk toe voor deze site (het pictogram links van de adresbalk) en probeer het opnieuw.", 'settings.browser_access': 'Browsertoegang', 'settings.browser_access_on_hint': 'Een browser kan je groepen openen met je wachtzin: deze toepassing laat je identiteit achter op elke node, zo verzegeld dat alleen je wachtzin samen met je hub-account haar kan openen.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index e4fbda81..7ae5f9a2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -1339,6 +1339,8 @@ export default { 'hosts.online': "online", 'group.bundle_format_retired': 'Ten node przechowuje Twoją tożsamość w formacie, którego ta wersja już nie odczytuje. Poproś jego operatora o uruchomienie „meshbay-node member unpin" dla Twojego konta i przesłanie nowego kodu zaproszenia.', + 'group.lan_blocked_android': "Android nie pozwala tej przeglądarce łączyć się z urządzeniami w Twojej sieci lokalnej, a node tej grupy jest w niej. Otwórz Ustawienia Androida › Aplikacje › Chrome (lub Twoja przeglądarka) › Uprawnienia › Urządzenia w pobliżu, zezwól, a potem stuknij Ponów. Przez dane komórkowe grupa otwiera się i bez tego.", + 'group.lan_blocked_browser': "Ta przeglądarka odmówiła połączenia z urządzeniami w Twojej sieci lokalnej, a node tej grupy jest w niej. Zezwól tej witrynie na dostęp do sieci lokalnej (ikona po lewej stronie paska adresu), a potem ponów.", 'settings.browser_access': 'Dostęp z przeglądarki', 'settings.browser_access_on_hint': 'Przeglądarka może otworzyć Twoje grupy Twoim hasłem: ta aplikacja zostawia Twoją tożsamość na każdym node, zapieczętowaną tak, by otworzyć ją mogło tylko Twoje hasło wraz z kontem na hubie.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index bb7271c4..9098dcb7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -1296,6 +1296,8 @@ export default { 'hosts.online': "online", 'group.bundle_format_retired': 'Este node guarda sua identidade em um formato que esta versão não lê mais. Peça ao operador que execute "meshbay-node member unpin" para sua conta e envie um novo código de convite.', + 'group.lan_blocked_android': "O Android não permite que este navegador acesse os dispositivos da sua rede local, e o node deste grupo está nela. Abra Configurações do Android › Apps › Chrome (ou o navegador que você usa) › Permissões › Dispositivos por perto, permita e toque em Tentar novamente. Com dados móveis, o grupo abre mesmo sem isso.", + 'group.lan_blocked_browser': "Este navegador se recusou a acessar os dispositivos da sua rede local, e o node deste grupo está nela. Permita o acesso à rede local para este site (o ícone à esquerda da barra de endereço) e tente novamente.", 'settings.browser_access': 'Acesso pelo navegador', 'settings.browser_access_on_hint': 'Um navegador pode abrir seus grupos com sua frase secreta: este aplicativo deixa sua identidade em cada node, selada de forma que apenas sua frase secreta, junto com sua conta no hub, possa abri-la.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 9d922fed..5a2bc3ca 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -1282,6 +1282,8 @@ export default { 'hosts.online': "在线", 'group.bundle_format_retired': '此 node 以本版本不再读取的格式保存您的身份。请其运营者为您的账户运行“meshbay-node member unpin”,并向您发送新的邀请码。', + 'group.lan_blocked_android': "Android 不允许此浏览器访问您局域网中的设备,而此群组的 node 就在该网络中。请打开 Android 设置 › 应用 › Chrome(或您使用的浏览器)› 权限 › 附近的设备,允许后点按重试。使用移动数据时,无需此权限也能打开群组。", + 'group.lan_blocked_browser': "此浏览器拒绝访问您局域网中的设备,而此群组的 node 就在该网络中。请允许此网站访问本地网络(地址栏左侧的图标),然后重试。", 'settings.browser_access': '浏览器访问', 'settings.browser_access_on_hint': '浏览器可以用您的密码短语打开您的群组:本应用会在每个 node 上留下您的身份,并加以封存,只有您的密码短语配合您的 hub 账户才能打开。', 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; diff --git a/packages/meshbay-hub/tests/harness/local_network_harness.mjs b/packages/meshbay-hub/tests/harness/local_network_harness.mjs new file mode 100644 index 00000000..9afdf59e --- /dev/null +++ b/packages/meshbay-hub/tests/harness/local_network_harness.mjs @@ -0,0 +1,156 @@ +// Run the SHIPPED transport against a device that refuses the local network. +// +// Android 17 makes the local network a permission of the application, and an +// application that targets it (Chrome from 155) has none until the person +// grants it. The browser then drops the node's addresses on the phone's own +// subnet: they never show in getStats, no check goes out to them, ICE fails and +// the channel closes. Measured on a Pixel, Android 17, Chrome 155, 2026-10-16. +// +// transport.js is executed whole. What is modelled is the environment: a clock, +// a peer connection whose ICE fails (or connects and then fails) after the +// answer, the remote candidates its getStats reports, and a hub that answers the +// offer and the token. +// +// Usage: node local_network_harness.mjs +// {"classify": [[offerSdp, answerSdp, remoteAddresses], ...]} +// → the verdict of localNetworkRefused for each case +// {"offer": sdp, "answer": sdp, "remote": [...], "iceConnects": bool, "statsThrow": bool} +// → how connect() ends +import { readFileSync } from 'fs'; +import { delimiter } from 'path'; + +const transportSrc = process.argv[2].split(delimiter) + .map((p) => readFileSync(p, 'utf8')).join('\n'); +const cfg = JSON.parse(process.argv[3] || '{}'); + +for (const level of ['log', 'warn', 'error', 'info', 'debug']) { + console[level] = (...args) => process.stderr.write(args.join(' ') + '\n'); +} +process.on('unhandledRejection', () => {}); + +// ── The clock ──────────────────────────────────────────────────────────────── + +let now = 0; +let nextId = 1; +let timers = []; +globalThis.setTimeout = (fn, ms) => { + const t = { at: now + (ms || 0), fn, id: nextId++ }; + timers.push(t); + return t.id; +}; +globalThis.setInterval = (fn, ms) => { + const t = { at: now + ms, fn, id: nextId++, every: ms }; + timers.push(t); + return t.id; +}; +globalThis.clearTimeout = (id) => { timers = timers.filter((t) => t.id !== id); }; +globalThis.clearInterval = globalThis.clearTimeout; +Date.now = () => now; +const flush = async () => { for (let i = 0; i < 50; i++) await Promise.resolve(); }; +async function run(until) { + await flush(); + while (timers.length) { + const due = timers.reduce((a, b) => (b.at < a.at ? b : a)); + if (due.at > until) break; + if (due.every) due.at += due.every; + else timers = timers.filter((t) => t !== due); + now = Math.max(now, due.every ? due.at - due.every : due.at); + due.fn(); + await flush(); + } + now = until; +} + +// ── The page around the transport ──────────────────────────────────────────── + +globalThis.window = globalThis; +globalThis.addEventListener = () => {}; +globalThis.removeEventListener = () => {}; +globalThis.location = { hash: '' }; +globalThis.document = { + addEventListener() {}, removeEventListener() {}, visibilityState: 'visible', +}; +globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} }; +window.MeshBayCrypto = {}; + +globalThis.fetch = async (url) => ({ + ok: true, status: 200, headers: { get: () => null }, + json: async () => (url.endsWith('/mnp-token') ? { mnp_token: 't' } : { sdp: cfg.answer }), +}); + +// ── The device ─────────────────────────────────────────────────────────────── + +class FakeChannel { + constructor() { this.readyState = 'connecting'; this.binaryType = 'arraybuffer'; } + send() { throw new Error('InvalidStateError'); } + close() { + if (this.readyState === 'closed') return; + this.readyState = 'closed'; + if (this.onclose) this.onclose({}); + } +} + +let statsCalls = 0; +class FakePC { + constructor() { + this.connectionState = 'new'; + this.iceConnectionState = 'new'; + this.iceGatheringState = 'complete'; + } + createDataChannel() { this.channel = new FakeChannel(); return this.channel; } + async createOffer() { return { type: 'offer', sdp: cfg.offer }; } + async setLocalDescription(d) { this.localDescription = d; } + async setRemoteDescription() { + this.iceConnectionState = 'checking'; + this.oniceconnectionstatechange(); + if (cfg.iceConnects) { + setTimeout(() => { + this.iceConnectionState = 'connected'; + this.oniceconnectionstatechange(); + }, 200); + } + // ICE gives up, and the channel goes with it, as Chrome reports it. + setTimeout(() => { + if (this.connectionState === 'closed') return; + this.iceConnectionState = 'failed'; + this.oniceconnectionstatechange(); + this.connectionState = 'failed'; + this.onconnectionstatechange(); + this.channel.close(); + }, 12000); + } + async getStats() { + statsCalls += 1; + if (this.connectionState === 'closed' || cfg.statsThrow) throw new Error('InvalidStateError'); + const reports = new Map(); + (cfg.remote || []).forEach((address, i) => { + reports.set(`R${i}`, { id: `R${i}`, type: 'remote-candidate', address }); + }); + return reports; + } + close() { + this.connectionState = 'closed'; + if (this.channel) this.channel.close(); + } +} +globalThis.RTCPeerConnection = FakePC; + +// ── The shipped transport ──────────────────────────────────────────────────── + +new Function(transportSrc)(); +const Transport = window.MeshBayTransport; + +if (cfg.classify) { + process.stdout.write(JSON.stringify( + cfg.classify.map(([offer, answer, remote]) => Transport.localNetworkRefused(offer, answer, remote)))); +} else { + const main = new Transport('https://hub.invalid', 'token'); + let outcome = 'pending'; + let reason = null; + main.connect('node', 'token', 'group', new Uint8Array(32), null, null, 'u', 'uid', + undefined, undefined, '', 'pk') + .then(() => { outcome = 'connected'; }, + (err) => { outcome = err.message; reason = err.reason || null; main.close(); }); + await run(60000); + process.stdout.write(JSON.stringify({ outcome, reason, statsCalls })); +} diff --git a/packages/meshbay-hub/tests/test_local_network_refused.py b/packages/meshbay-hub/tests/test_local_network_refused.py new file mode 100644 index 00000000..3d0f3041 --- /dev/null +++ b/packages/meshbay-hub/tests/test_local_network_refused.py @@ -0,0 +1,106 @@ +""" +A device that refuses the local network is named, not reported as "DataChannel closed". + +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 to them, the node's own checks get no +answer, ICE fails and the channel closes. On mobile data the same group opens, +over the node's public address. Measured on a Pixel, Android 17, Chrome 155, +2026-10-16; the addresses below are that session's. + +These run the shipped transport: harness/local_network_harness.mjs. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest +from spa_source import transport_argv + +HARNESS = Path(__file__).parent / "harness" / "local_network_harness.mjs" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node is not available") + + +def _run(cfg: dict): + proc = subprocess.run(["node", str(HARNESS), transport_argv(), json.dumps(cfg)], + capture_output=True, text=True, encoding="utf-8", timeout=120) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _sdp(*candidates: tuple[str, str]) -> str: + lines = ["v=0", "m=application 9 UDP/DTLS/SCTP webrtc-datachannel"] + for i, (address, kind) in enumerate(candidates): + lines.append(f"a=candidate:{i} 1 udp 2130706431 {address} {50000 + i} typ {kind}") + return "\r\n".join(lines) + "\r\n" + + +PHONE_WIFI = _sdp(("192.168.1.13", "host"), ("2a01:cb15:80e8:de00:c601:3ad3:2b17:e6a6", "host"), + ("90.112.206.172", "srflx")) +DESKTOP = _sdp(("64f602b7-7667-411f-b8ae-910c4d1de6fb.local", "host"), + ("07b5e6a0-2b57-45ce-8748-ce2918cc6145.local", "host"), + ("90.112.206.172", "srflx")) +PHONE_MOBILE = _sdp(("10.161.7.20", "host"), ("2a04:cec0:1000:2c41::1", "host"), + ("92.184.100.12", "srflx")) +PHONE_V6_ONLY = _sdp(("2a04:cec0:1000:2c41::1", "host")) +NODE = _sdp(("192.168.1.22", "host"), ("2a01:cb15:80e8:de00:d455:bcab:c324:4d72", "host"), + ("192.168.200.254", "host"), ("192.168.100.254", "host"), + ("90.112.206.172", "srflx")) +NODE_NO_STUN = _sdp(("192.168.1.22", "host")) +EVERY_NODE_ADDRESS = ["192.168.1.22", "2a01:cb15:80e8:de00:d455:bcab:c324:4d72", + "192.168.200.254", "192.168.100.254", "90.112.206.172"] +# What Chrome 155 kept, refused: the node's virtual adapters, off the phone's +# subnet, and its public address; not 192.168.1.22 nor the IPv6 of the same /64. +KEPT_WHEN_REFUSED = ["192.168.200.254", "192.168.100.254", "90.112.206.172"] + + +def test_the_measured_refusal_is_recognised(): + assert _run({"classify": [[PHONE_WIFI, NODE, KEPT_WHEN_REFUSED]]}) == [True] + + +def test_a_browser_that_pairs_every_address_is_not_refusing(): + """Desktop Chrome 152, 155 and 157 listed all six of the node's addresses.""" + assert _run({"classify": [[DESKTOP, NODE, EVERY_NODE_ADDRESS], + [PHONE_WIFI, NODE, EVERY_NODE_ADDRESS]]}) == [False, False] + + +def test_an_unreachable_node_is_not_blamed_on_the_device(): + """On mobile data the node's LAN address is paired and simply never answers: + a node that is down, not a permission.""" + assert _run({"classify": [[PHONE_MOBILE, NODE, EVERY_NODE_ADDRESS]]}) == [False] + + +def test_without_an_ipv4_of_our_own_no_absence_means_anything(): + """No IPv4 candidate here, so no IPv4 address of the node could pair.""" + assert _run({"classify": [[PHONE_V6_ONLY, NODE, ["2a01:cb15:80e8:de00:d455:bcab:c324:4d72"]]]}) \ + == [False] + + +def test_a_node_with_no_public_address_is_judged_by_the_subnet(): + """With nothing paired at all, only an address on our own subnet tells.""" + other_lan = _sdp(("10.0.0.5", "host")) + assert _run({"classify": [[PHONE_WIFI, NODE_NO_STUN, []], + [other_lan, NODE_NO_STUN, []]]}) == [True, False] + + +def test_connect_fails_with_the_reason(): + out = _run({"offer": PHONE_WIFI, "answer": NODE, "remote": KEPT_WHEN_REFUSED}) + assert out["reason"] == "local_network_blocked", out + + +def test_a_connection_that_did_get_through_is_not_a_refusal(): + """ICE connected once: whatever failed after, the local network was reached.""" + out = _run({"offer": PHONE_WIFI, "answer": NODE, "remote": KEPT_WHEN_REFUSED, + "iceConnects": True}) + assert out["reason"] is None and out["statsCalls"] == 0, out + + +def test_stats_that_cannot_be_read_leave_the_old_error(): + out = _run({"offer": PHONE_WIFI, "answer": NODE, "remote": KEPT_WHEN_REFUSED, + "statsThrow": True}) + assert out["reason"] is None and out["outcome"] == "DataChannel closed", out -- cgit v1.2.3