// Peer delivery in a browser: pages that hold objects and serve them over WebRTC data channels, // a holder reached over a local HTTP link, and pages that ask, with faults planted on the // holders' side. // // PLAYWRIGHT_HOST=/ node run.mjs [] // // It serves this directory (the bundle in pkg/, built as verify_peer.mjs says) and the corpus's // world from one local origin, which is also the pages' origin service and the mailbox that // introduces them; the HTTP holder is another origin. Every service binds port 0 and says its // port, and all are closed before exit. The engine is WAI_E2E_ENGINE's // (scripts/browser-ci/engine.mjs: chromium, firefox or webkit); for chromium, the installed one // of WAI_PEER_CHANNEL (or WAI_E2E_CHANNEL; default `chrome`; empty for the engine Playwright // carries). Nothing is fetched from outside this machine. // // A browser offers its local addresses to a page as names that resolve on the local network // (§9). On a machine where those names do not resolve between two profiles (a session started // over SSH, with no multicast), no data channel opens, and the run says so: each failed // connection names the kinds of candidate it offered. WAI_PEER_BROWSER_ARGS passes Chromium // arguments for such a machine; `--disable-features=WebRtcHideLocalIpsWithMdns` has it offer // the addresses themselves. WAI_PEER_HOST_ADDRESSES=1 does the same in any engine (that // argument for Chromium, the preference media.peerconnection.ice.obfuscate_host_addresses off // for Firefox). // // The pages, each a profile of its own: // h0 a holder whose viewer leaves serving off // hm holds A0 to A2, and serves A0 with a byte changed // hc holds A whole, and states the group one object larger than it is // h1 holds A0 to A3 and B1, and serves what it holds // hl holds A but for A4, and lists A whole // a0 an asker whose viewer leaves asking off // a1 an asker with asking on: hm, hc, h1 and hl in that order, then the HTTP holder (B whole) // // Exit 0 iff every check holds. import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, extname, join, normalize, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { PeerServer, memoryStore } from "./peer.js"; import { checks, httpHolder, listen, loadBundle, loadWorld, sha256 } from "./harness.mjs"; import { knownFromEnv, launch, skipped } from "../../../scripts/browser-ci/engine.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const [rootArg, sessionOut] = process.argv.slice(2); if (!rootArg) { console.log("usage: PLAYWRIGHT_HOST=/ node run.mjs []"); process.exit(2); } let playwright; try { playwright = createRequire(process.env.PLAYWRIGHT_HOST || HERE + "/")("playwright"); } catch (e) { console.log(` FAIL run.mjs: playwright is not here (set PLAYWRIGHT_HOST): ${e.message}`); process.exit(1); } const w = await loadBundle(join(HERE, "pkg")); const world = loadWorld(resolve(rootArg)); const { expect, done } = checks(); const [A, B] = world.groups; const POLICY = { ask: true, serve: true, max_object_bytes: 1 << 22, relay_only: false }; const closing = []; let browser; // Whatever happens, the browser and every service are closed. const cleanup = async () => { await browser?.close().catch(() => {}); for (const s of closing) s.close(); }; const guard = setTimeout(async () => { console.log(" FAIL the run did not finish in ten minutes"); await cleanup(); process.exit(1); }, 600000); for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => cleanup().then(() => process.exit(143))); // ── The HTTP holder: B whole, on an origin of its own. const hh = new PeerServer(w, { seed: crypto.getRandomValues(new Uint8Array(32)), policy: POLICY, setting: { serve: true }, store: memoryStore() }); for (const [i, o] of B.objects.entries()) await hh.store.put(B.link, i, o.envelope); hh.publish(Date.now() - 5000, Date.now() + 3600000, [{ group: B.link, leaf_count: B.objects.length, held: B.objects.map((_, i) => i) }]); const httpLog = []; const http = await listen(httpHolder(hh, (l) => httpLog.push(l)), "the HTTP holder"); closing.push(http); const httpBase = `http://127.0.0.1:${http.address().port}/`; // ── The pages' origin: this directory, the configuration, the world and the mailbox. const TYPES = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".mjs": "text/javascript", ".wasm": "application/wasm", ".json": "application/json" }; const originLog = []; const mail = new Map(); const senders = new Set(); const site = await listen(async (req, res) => { const url = new URL(req.url, "http://x"); const send = (status, type, body) => res.writeHead(status, { "content-type": type, "cache-control": "no-store" }).end(body); const path = url.pathname; if (path === "/config.json") return send(200, TYPES[".json"], JSON.stringify({ policy: POLICY, origin: "/origin/", signal: "/signal/", http_holders: [httpBase], relays: [] })); if (path === "/origin/world.json") { // Receipts only: the envelopes are fetched one by one. return send(200, TYPES[".json"], JSON.stringify({ groups: world.groups.map((g) => ({ link: g.link, receipt: g.receipt, objects: g.objects.map((o) => ({ receipt: o.receipt })) })) })); } const object = /^\/origin\/object\/([0-9a-f]{64})\/(\d+)$/.exec(path); if (object) { const g = world.groups.find((x) => x.link === object[1]); const o = g?.objects[Number(object[2])]; if (!o) return send(404, "text/plain", ""); originLog.push(`${url.searchParams.get("c")} ${g === A ? "A" : "B"}${object[2]}`); return send(200, "application/octet-stream", o.envelope); } const signal = /^\/signal\/(send|recv)\/([a-z0-9]+)$/.exec(path); if (signal && signal[1] === "recv") return send(200, TYPES[".json"], JSON.stringify(mail.get(signal[2])?.splice(0) || [])); if (signal && req.method === "POST") { let text = ""; for await (const c of req) text += c; const m = JSON.parse(text); senders.add(m.from); if (!mail.has(signal[2])) mail.set(signal[2], []); mail.get(signal[2]).push(m); return send(200, TYPES[".json"], "{}"); } const file = normalize(join(HERE, decodeURIComponent(path))); if (req.method !== "GET" || !file.startsWith(HERE + sep) || !existsSync(file) || !statSync(file).isFile()) return send(404, "text/plain", ""); return send(200, TYPES[extname(file)] || "application/octet-stream", readFileSync(file)); }, "the pages' origin"); closing.push(site); const base = `http://127.0.0.1:${site.address().port}`; const hostAddresses = !!process.env.WAI_PEER_HOST_ADDRESSES; const launched = await launch(playwright, { channel: process.env.WAI_PEER_CHANNEL ?? process.env.WAI_E2E_CHANNEL, chromiumArgs: [...(process.env.WAI_PEER_BROWSER_ARGS || "").split(" ").filter(Boolean), ...(hostAddresses ? ["--disable-features=WebRtcHideLocalIpsWithMdns"] : [])], firefoxPrefs: hostAddresses ? { "media.peerconnection.ice.obfuscate_host_addresses": false } : {}, }); browser = launched.browser; console.log(` the browser: ${launched.label}`); // Every check needs two pages of this browser to reach each other, tested here with these // settings before the first page. Where this host stops them and WAI_E2E_KNOWN_LACKS says so, // the run is skipped with that reason: exit 3. Where they reach each other and it says they do // not, or they do not and it does not say so, the run fails here, saying which. let skip; try { skip = await skipped(browser, launched.engine, launched.label, "peer delivery", ["rtc-pair"], knownFromEnv()); } catch (e) { console.log(` FAIL ${e?.message || e}`); await cleanup(); process.exit(1); } if (skip) { await cleanup(); process.exit(3); } const errors = []; /** A page in a profile of its own, with a count of the connections it constructs and the URLs it fetches. */ async function open(id, query, { ask = false, serve = false } = {}) { const context = await browser.newContext(); await context.addInitScript(() => { const probe = (window.__probe = { connections: 0, fetches: [] }); const Native = window.RTCPeerConnection; window.RTCPeerConnection = class extends Native { constructor(...a) { super(...a); probe.connections++; } }; const nativeFetch = window.fetch; window.fetch = (...a) => { probe.fetches.push(String(a[0] instanceof Request ? a[0].url : a[0])); return nativeFetch(...a); }; }); const page = await context.newPage(); page.on("pageerror", (e) => errors.push(`${id}: ${e}`)); await page.goto(`${base}/index.html?id=${id}&${query}`); await page.waitForFunction(() => window.__wai_peer && window.__wai_peer.state !== "loading", null, { timeout: 60000 }); // The viewer's setting: both boxes are off when the page opens, and only a click turns one on. const boxes = await page.evaluate(() => [document.getElementById("ask").checked, document.getElementById("serve").checked]); if (boxes[0] || boxes[1]) errors.push(`${id}: a setting was on before anyone turned it on`); if (ask) await page.check("#ask"); if (serve) await page.check("#serve"); await page.click("#start"); const state = async (...states) => { await page.waitForFunction((s) => s.includes(window.__wai_peer.state) || window.__wai_peer.state === "failed", states, { timeout: 120000 }); return page.evaluate(() => JSON.parse(JSON.stringify({ hook: window.__wai_peer, probe: window.__probe, status: document.getElementById("status").textContent }))); }; return { id, page, state }; } let status = 1; try { // ── A holder whose viewer leaves serving off. const h0 = await (await open("h0", "role=holder&holds=0:0-5")).state("off", "serving"); expect(h0.hook.state === "off" && h0.probe.connections === 0 && !h0.hook.record && !originLog.some((l) => l.startsWith("h0 ")) && !senders.has("h0"), "serving off: the page signs no record, opens no connection and holds nothing for its neighbours"); // ── The holders. const holders = { hm: await open("hm", "role=holder&holds=0:0-2&alter=0:0", { serve: true }), hc: await open("hc", "role=holder&holds=0:0-5&count=1", { serve: true }), h1: await open("h1", "role=holder&holds=0:0-3;1:1", { serve: true }), hl: await open("hl", "role=holder&holds=0:0-3,5&lists=0:4", { serve: true }), }; const serving = {}; for (const [name, h] of Object.entries(holders)) serving[name] = await h.state("serving"); expect(Object.values(serving).every((s) => s.hook.state === "serving" && s.hook.record), `four holder pages hold their objects in the Cache API and serve${Object.values(serving).find((s) => s.hook.error) ? ": " + Object.values(serving).map((s) => s.hook.error).join(" | ") : ""}`); // ── An asker whose viewer leaves asking off. const a0 = await (await open("a0", "role=asker&peers=hm,hc,h1,hl")).state("done"); const quiet = async () => (await Promise.all(Object.values(holders).map((h) => h.page.evaluate(() => window.__wai_peer.stats.wants + window.__wai_peer.links)))).reduce((x, y) => x + y, 0); expect(a0.hook.state === "done" && a0.hook.mayAsk === false && a0.probe.connections === 0, `asking off: the page constructs no peer connection (${a0.hook.error || a0.probe.connections})`); expect(a0.hook.objects?.length === 9 && a0.hook.objects.every((o) => o.from === "origin" && o.route === "origin reason=peer_not_permitted" && o.verified), "asking off: every object is the origin's, and verified"); expect(!a0.probe.fetches.some((u) => u.startsWith(httpBase)) && httpLog.length === 0 && !senders.has("a0") && (await quiet()) === 0 && a0.hook.acks.length === 0, "asking off: no holder is sent a request, an introduction or an acknowledgement"); // ── The asker, asking on. const a1 = await (await open("a1", "role=asker&peers=hm,hc,h1,hl", { ask: true })).state("done"); const hook = a1.hook; expect(hook.state === "done" && Object.values(hook.peers || {}).every(Boolean) && a1.probe.connections === 4, `asking on: four data channels and the HTTP link are up${hook.error || hook.failures?.length ? ": " + [hook.error, ...(hook.failures || [])].filter(Boolean).join(" | ") : ""}`); const objects = hook.objects || []; const at = (name) => objects.find((o) => `${"AB"[o.group]}${o.index}` === name) || { attempts: [] }; const tried = (name) => at(name).attempts.map((x) => `${x.holder}:${x.outcome}`).join(", "); let same = objects.length === 9; for (const o of objects) same &&= o.verified && o.sha256 === (await sha256(world.groups[o.group].objects[o.index].envelope)); expect(same, "every object the page shows verified under its receipt and is the publisher's bytes"); expect(JSON.stringify(hook.before) === JSON.stringify(["stands", "reject reason=holdings_leaf_count_mismatch", "stands", "stands", "stands"]), `a record that overstates its group is refused whole (${JSON.stringify(hook.before)})`); expect(tried("A0") === "hm:reject step=4 reason=content_hash_mismatch, h1:taken", `altered bytes are refused, and the next holder serves (A0: ${tried("A0")})`); expect(["A1", "A2", "A3"].every((k) => tried(k) === "h1:taken"), "the peer that served altered bytes is asked nothing more (A1 to A3 from h1 alone)"); expect(tried("A4") === "hl:miss reason=holdings_overstated" && at("A4").from === "origin", `a peer that lists what it lacks answers miss, and the origin serves (A4: ${tried("A4")})`); expect(at("A5").attempts.length === 0 && at("A5").from === "origin", "its record is set aside: the next object it listed is not asked of it (A5)"); expect(at("B0").from === httpBase && at("B2").from === httpBase && tried("B1") === "h1:taken", `objects come over the HTTP link, and one past a data frame over a data channel (B0 ${at("B0").from}, B1 ${tried("B1")})`); expect(JSON.stringify(hook.after) === JSON.stringify(["set-aside reason=peer_bytes_refused", "reject reason=holdings_leaf_count_mismatch", "stands", "set-aside reason=holdings_overstated", "stands"]), `the records' labels after the session (${JSON.stringify(hook.after)})`); expect(JSON.stringify(hook.asks) === JSON.stringify({ ...hook.asks, hm: 1, h1: 5, hl: 1 }) && hook.asks.hc === undefined && hook.asks[httpBase] === 2, `each holder was asked what its route gave it, and the overstating record's holder nothing (${JSON.stringify(hook.asks)})`); const mine = originLog.filter((l) => l.startsWith("a1 ")).join(","); expect(mine === "a1 A4,a1 A5", `the origin served the asker only what no peer did (${mine})`); expect(httpLog.length > 0 && httpLog.every((l) => !l.cookie && !l.authorization && !l.referer), "requests to the HTTP holder carried no cookie, credential or referrer"); // ── The acknowledgements, and what each holder made of them. const acks = hook.acks || []; const final = {}; for (const [name, h] of Object.entries(holders)) final[name] = await h.page.evaluate(() => JSON.parse(JSON.stringify(window.__wai_peer))); const size = (names) => names.reduce((n, k) => n + at(k).length, 0); const to = (name) => acks.find((x) => x.upstream_name === name); expect(acks.length === 2 && to("h1") && to(httpBase) && acks.every((x) => x.sent && x.taken), `one acknowledgement each to the two holders that served, each taken (${acks.map((x) => `${x.upstream_name}:${x.taken}`).join(", ")})`); expect(!acks.some((x) => [final.hm.key, final.hc.key, final.hl.key].includes(x.upstream)), "no acknowledgement names the altering peer, the overstating one or the miscounting one"); const taken = (stats) => stats.acks.filter((x) => x.outcome === "taken"); const t1 = taken(final.h1.stats)[0] || {}; expect(taken(final.h1.stats).length === 1 && t1.objects === 5 && t1.bytes === size(["A0", "A1", "A2", "A3", "B1"]) && t1.downstream === hook.key, `h1 checked its acknowledgement: five objects, ${t1.bytes} bytes, signed by the asker`); const th = taken(hh.stats)[0] || {}; expect(taken(hh.stats).length === 1 && th.objects === 2 && th.bytes === size(["B0", "B2"]), `the HTTP holder checked its own: two objects, ${th.bytes} bytes`); expect(final.hm.stats.wants === 1 && final.hm.stats.acks.length === 0 && final.hc.stats.wants === 0 && final.hl.stats.wants === 1 && final.hl.stats.acks.length === 0, "the altering peer and the overstating peer were each asked once and the miscounting peer never, and none was sent an acknowledgement"); expect(errors.length === 0, `no page error${errors.length ? "s: " + errors.join(" | ") : ""}`); if (sessionOut) { writeFileSync(sessionOut, JSON.stringify({ asker: hook.key, acks: acks.map(({ upstream, record, items }) => ({ upstream, record, items })), expected: { [final.h1.key]: { objects: 5, bytes: size(["A0", "A1", "A2", "A3", "B1"]) }, [hh.key]: { objects: 2, bytes: size(["B0", "B2"]) } } }, null, 1) + "\n"); console.log(` the session's records: ${sessionOut}`); } status = done(23); } catch (e) { console.log(` FAIL the run stopped: ${e?.stack || e}`); } finally { clearTimeout(guard); await cleanup(); } process.exit(status);