// Run the SHIPPED transport through a change of network, and see whether the // connection follows. // // A phone moving between mobile data and the Wi-Fi the node is on keeps a // connection whose candidate pair runs over the network it left. Reported on // the Android application: the group stopped answering and the application had // to be killed. transport.js is executed whole; what is modelled is the // environment: a clock, the window's events, `navigator.connection`, a peer // connection that opens its channel after the answer, a hub that answers the // offer and the token (or does not, while the device is offline), and a node // that answers the handshake and pings until it is cut off. // // Usage: node network_change_harness.mjs // {"scenario": "change-dead" | "change-alive" | "backoff" | "ice-dead" | "ice-alive" // | "events" | "closed" | "refused" // | "refused-during"} import { readFileSync } from 'fs'; import { delimiter } from 'path'; const transportSrc = process.argv[2].split(delimiter) .map((p) => readFileSync(p, 'utf8')).join('\n'); const { scenario } = 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; globalThis.performance = { 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 ──────────────────────────────────────────── function eventTarget(obj) { const listeners = new Map(); obj.addEventListener = (type, fn) => { if (!listeners.has(type)) listeners.set(type, new Set()); listeners.get(type).add(fn); }; obj.removeEventListener = (type, fn) => { listeners.get(type)?.delete(fn); }; obj.dispatch = (type) => { for (const fn of [...(listeners.get(type) || [])]) fn({ type }); }; obj.listening = () => [...listeners.values()].reduce((n, s) => n + s.size, 0); return obj; } globalThis.window = globalThis; eventTarget(globalThis); const connection = eventTarget({ type: 'cellular', effectiveType: '4g' }); Object.defineProperty(globalThis, 'navigator', { value: { userAgent: 'harness', connection }, configurable: true }); globalThis.location = { hash: '' }; globalThis.document = { addEventListener() {}, removeEventListener() {}, visibilityState: 'visible', }; globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} }; const b64 = (u8) => Buffer.from(u8).toString('base64'); window.MeshBayCrypto = { b64encode: (u8) => b64(u8), b64decode: (s) => new Uint8Array(Buffer.from(String(s), 'base64')), verifyNodeSignature: async () => true, challengeTranscript: () => new Uint8Array(1), webrtcBinding: () => new Uint8Array(1), handshakeProof: async () => new Uint8Array([7]), handshakeTranscript: () => new Uint8Array(1), constantTimeEqual: () => true, openGroup: async () => window.__codec.msgpack_encode({ chat_epoch: 1 }), }; const SDP = 'v=0\r\na=fingerprint:sha-256 AA:BB\r\n'; const candidate = (address, kind) => `a=candidate:1 1 udp 2130706431 ${address} 50000 typ ${kind}\r\n`; // On the node's Wi-Fi without the permission (test_local_network_refused.py): // the phone's offer and the node's answer as measured, and the node's address // on the phone's subnet missing from what the browser paired. const WIFI_OFFER = SDP + candidate('192.168.1.13', 'host') + candidate('90.112.206.172', 'srflx'); const NODE_ANSWER = SDP + candidate('192.168.1.22', 'host') + candidate('90.112.206.172', 'srflx'); const KEPT_WHEN_REFUSED = ['90.112.206.172']; let offline = false; let lanRefused = false; globalThis.fetch = async (url) => { if (offline) throw new TypeError('Failed to fetch'); return { ok: true, status: 200, headers: { get: () => null }, json: async () => (url.endsWith('/mnp-token') ? { mnp_token: 't' } : { sdp: lanRefused ? NODE_ANSWER : SDP }), }; }; // ── The node ───────────────────────────────────────────────────────────────── let pings = 0; const pcs = []; class NodeSession { constructor(channel) { this.channel = channel; this.cut = false; } reply(obj, ms) { setTimeout(() => { if (this.cut || this.channel.readyState !== 'open') return; const body = window.__codec.msgpack_encode(obj); const frame = new Uint8Array(4 + body.byteLength); new DataView(frame.buffer).setUint32(0, body.byteLength, false); frame.set(body, 4); this.channel.onmessage({ data: frame.buffer }); }, ms); } receive(frame) { if (this.cut) return; const msg = window.__codec.msgpack_decode(frame.slice(4)); const req_id = msg.req_id; switch (msg.type) { case 'handshake': return this.reply({ type: 'handshake_challenge', v: '99.0', v_min: '0.0', nonce: b64(new Uint8Array(32)), node_pk: 'pk', sig: 's', req_id }, 50); case 'handshake_response': return this.reply({ type: 'handshake_ack', proof: 'AA==', node_pk: 'pk', sig: 's', nonce: 'n', ct: 'c', req_id }, 50); case 'device_hello': return this.reply({ type: 'device_hello_ack', req_id }, 50); case 'ping': pings += 1; return this.reply({ type: 'pong', token: msg.token, req_id }, 30); default: return undefined; } } } class FakeChannel { constructor() { this.readyState = 'connecting'; this.binaryType = 'arraybuffer'; } send(frame) { if (this.readyState !== 'open') throw new Error('InvalidStateError'); this.session.receive(frame); } close() { if (this.readyState === 'closed') return; this.readyState = 'closed'; if (this.onclose) this.onclose({}); } } class FakePC { constructor() { this.connectionState = 'new'; this.iceConnectionState = 'new'; this.iceGatheringState = 'complete'; pcs.push(this); } createDataChannel() { this.channel = new FakeChannel(); return this.channel; } async createOffer() { return { type: 'offer', sdp: lanRefused ? WIFI_OFFER : SDP }; } async setLocalDescription(d) { this.localDescription = d; } async getStats() { const reports = new Map(); if (this.refused) { KEPT_WHEN_REFUSED.forEach((address, i) => { reports.set(`R${i}`, { id: `R${i}`, type: 'remote-candidate', address }); }); } return reports; } async setRemoteDescription() { if (lanRefused) { // ICE gives up, and the channel goes with it, as Chrome reports it: // there after thirty seconds, here after fifteen. this.refused = true; setTimeout(() => { if (this.connectionState === 'closed') return; this.iceConnectionState = 'failed'; this.connectionState = 'failed'; this.channel.close(); }, 15000); return; } setTimeout(() => { if (this.connectionState === 'closed') return; this.channel.session = new NodeSession(this.channel); this.channel.readyState = 'open'; this.connectionState = 'connected'; this.iceConnectionState = 'connected'; if (this.channel.onopen) this.channel.onopen(); }, 100); } close() { this.connectionState = 'closed'; if (this.channel) this.channel.close(); } } globalThis.RTCPeerConnection = FakePC; // ── The shipped transport ──────────────────────────────────────────────────── new Function(`${transportSrc}\nwindow.__codec = { msgpack_encode, msgpack_decode };`)(); const Transport = window.MeshBayTransport; // What the transport's own module listens to, before any connection exists. const moduleListeners = window.listening() + connection.listening(); const out = {}; if (scenario === 'events') { // The helper alone: which events count as another network, and how many. let calls = 0; const off = Transport.onNetworkChange(() => { calls += 1; }); const at = []; const step = async (fire) => { fire(); await run(now + 5000); at.push(calls); }; await step(() => connection.dispatch('change')); // a bandwidth estimate await step(() => { connection.type = 'wifi'; connection.dispatch('change'); }); await step(() => window.dispatch('online')); await step(() => window.dispatch('meshbay-default-network')); await step(() => { // one change, three events connection.type = 'cellular'; connection.dispatch('change'); window.dispatch('online'); window.dispatch('meshbay-default-network'); }); off(); await step(() => window.dispatch('online')); out.calls = at; out.leftListening = window.listening() + connection.listening() - moduleListeners; } else { const main = new Transport('https://hub.invalid', 'token'); let reconnectedAt = null; main.addReconnectListener(() => { if (reconnectedAt === null) reconnectedAt = now; }); const failures = []; main.addReconnectFailureListener((err) => { failures.push(err.reason || null); }); let connected = false; const identity = { pkEdB64: 'ed', signAs: async () => 'sig', native: false }; main.connect('node', 'token', 'group', new Uint8Array(32), identity, null, 'u', 'uid', undefined, undefined, '', 'pk') .then(() => { connected = true; }, (err) => { out.connectError = err.message; }); await run(5000); out.connected = connected; const first = pcs[0]; if (scenario === 'change-dead' || scenario === 'change-alive') { // Mobile data to Wi-Fi. The pair in use ran over mobile data; when it is // gone, the node hears nothing more on it. if (scenario === 'change-dead') first.channel.session.cut = true; connection.type = 'wifi'; connection.dispatch('change'); await run(25000); } else if (scenario === 'ice-dead' || scenario === 'ice-alive') { if (scenario === 'ice-dead') first.channel.session.cut = true; first.iceConnectionState = 'disconnected'; first.oniceconnectionstatechange(); await run(25000); } else if (scenario === 'backoff') { // The connection fails while the device has no network, and every attempt // fails with it until the backoff has grown long. Then Wi-Fi comes back. offline = true; first.channel.session.cut = true; first.connectionState = 'failed'; first.onconnectionstatechange(); await run(40000); offline = false; connection.type = 'wifi'; connection.dispatch('change'); out.backAt = now; await run(80000); } else if (scenario === 'refused-during') { // The same, with the permission granted while an attempt set up before it // is still waiting on ICE: that attempt is given up, the next goes at once. lanRefused = true; first.channel.session.cut = true; connection.type = 'wifi'; connection.dispatch('change'); await run(40000); const attempts = pcs.length; while (pcs.length === attempts) await run(now + 100); await run(now + 2000); lanRefused = false; out.backAt = now; main.retryNow(); await run(now + 40000); } else if (scenario === 'refused') { // Connected on mobile data, then on the node's Wi-Fi without the local // network permission: every attempt is refused, and says so. The // permission is granted after a while, and the page asks to try now. lanRefused = true; first.channel.session.cut = true; connection.type = 'wifi'; connection.dispatch('change'); await run(60000); out.failures = [...failures]; out.stillReconnecting = main.reconnecting; lanRefused = false; out.backAt = now; main.retryNow(); await run(80000); } else if (scenario === 'closed') { main.close(); connection.type = 'wifi'; connection.dispatch('change'); await run(25000); out.leftListening = window.listening() + connection.listening() - moduleListeners; } out.reconnectedAt = reconnectedAt; out.connections = pcs.length; out.pings = pings; } process.stdout.write(JSON.stringify(out));