// 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 })); }