/* The live half of the gates: one place that knows how to reach a running router, log into LuCI and * enumerate its pages. * * Every static gate here measures a FILE. The bugs that reached users measured a PAGE — a column * shredded to one character per line (#11), a submenu title clipped (#22), a doubled scrollbar in * Firefox (#12), a third-party app's tabs laid out wrong (#36, #33, #8), a client navigation that * painted less than a full load (upstream review). None can be seen in a stylesheet; all are one * query away on a live page. * * The routers are owlab's — `owlab status -json` names each one and the port it answers on, so * nothing here hard-codes a port or a container name. Nothing here boots anything either: a gate * that starts and stops containers by itself is a gate nobody runs locally. */ import { execFileSync } from 'node:child_process'; /* The routers a gate runs on by default: the three OpenWrt lines the theme supports. What changes * what the theme is measured against is the package manager (apk on 25.12+, opkg on 24.10) and the * luci-base the router carries; the snapshot box tracks luci-base's master, so an upstream change * fails here before it reaches a user. `--all` widens to every running OpenWrt router (the -b/-c/-d * twins); a gate that takes `--only` must honour `--all` too. * * ImmortalWrt is not a gate target at all, since 0.14.13: same luci-base, different brand and app * set, never the leg that caught something first, and its two legs produced 1685 `noname` findings * of their own app set on the 0.14.13 release run with nothing to read in them. A running `imm*` * router is ignored, and `--only imm2512` is refused by name rather than measured. */ export const CORE = [ 'owrt2512', 'owrt2410', 'owrtsnap' ]; /* Every RUNNING owlab router, newest release first, or an empty array when owlab is absent — the * caller decides whether that is a failure (a gate) or a reason to skip (a local convenience). * With no `only` and no `all`, the CORE pair above; if none of it is running, everything that is, * with a line saying so — a gate that silently measured a different set than it claims is worse * than a slow one. */ export function stands(only, { all = false } = {}) { let out; try { out = execFileSync('owlab', [ 'status', '-json' ], { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] }); } catch (e) { return []; } let parsed; try { parsed = JSON.parse(out); } catch (e) { return []; } const wanted = (only || '').split(',').map((s) => s.trim()).filter(Boolean); const foreign = (parsed.routers || []).filter((r) => r.distro !== 'openwrt' && wanted.includes(r.id)); if (foreign.length) { console.error(`${foreign.map((r) => r.id).join(', ')}: not a gate target — the gates measure OpenWrt ` + 'routers only (tools/lib/stands.mjs).'); process.exit(2); } const running = (parsed.routers || []) .filter((r) => r.state === 'running' && r.http_port && r.distro === 'openwrt') .map((r) => ({ id: r.id, base: `http://localhost:${r.http_port}/cgi-bin/luci`, release: r.release, distro: r.distro, pkg: r.package_manager, })); if (wanted.length) return running.filter((r) => wanted.includes(r.id)); if (all) return running; const core = running.filter((r) => CORE.includes(r.id)); if (core.length) return core; if (running.length) process.stderr.write(`(none of ${CORE.join(', ')} is running — measuring ` + `${running.map((r) => r.id).join(', ')} instead)\n`); return running; } /* Cut the browser off from everything except the router under test. * * A live gate measures what THIS theme renders. Anything a page fetches from the open internet is * somebody else's code reaching somebody else's host, and it can only add noise: `luci-app-ssclash` * asks GitHub for the latest mihomo release on two of its pages, and on a CI runner — whose egress * IP is shared and rate-limited — that answers 403. Nine console findings across three routers, on * a release that had nothing to do with any of them, none of it reproducible off the runner. * * Blocking is better than filtering the message afterwards, and better than dropping the app from * the stands: the app stays (its lazily-inserted Ace stylesheets are the one adversary that can * invert the cascade layer order — owlab.yaml), the request simply never leaves. The gates stop * depending on the network, on a rate limit, and on how fast github.com answers today. * * `abort`, not a fake 200: an invented response is a lie the page then renders. The abort still * writes `net::ERR_FAILED` to the console, which live-audit's own network-noise filter drops. * * The router's own origin passes, and so do data:/blob: — a stylesheet or an icon the page builds * for itself is not a fetch. */ export async function sealToRouter(ctx, base) { const host = new URL(base).host; /* The predicate form, never a catch-all glob: a glob route hands EVERY request to a JS callback, * including the hundreds the router itself serves, and each round trip through the client costs * time and disables the browser's own handling. A predicate is evaluated by Playwright, so a request to * the router is never intercepted at all — only the handful going elsewhere reach the handler. * The glob version pushed CI's live job from 17 minutes past its 45-minute limit. */ await ctx.route( (url) => url.host !== host && url.protocol !== 'data:' && url.protocol !== 'blob:', (route) => route.abort()); } /* LuCI answers an unauthenticated request with the login form, not a 403 page — so every live gate * has to log in before it can measure anything. owlab's routers are root with an empty password * (`owlab status` prints it); a hardware router is not what these gates run against. */ export async function login(page, base) { await page.goto(base, { waitUntil: 'domcontentloaded' }); if (await page.$('input[name="luci_password"]')) { await page.fill('input[name="luci_username"]', 'root'); await Promise.all([ page.waitForNavigation({ waitUntil: 'domcontentloaded' }), page.press('input[name="luci_password"]', 'Enter'), ]); } } /* Every LEAF of the router's own menu tree, as LuCI itself resolves it — not a list of paths kept * in the repo, which would go stale the moment an app is installed or a release moves a page. * * `L.ui.menu.load()` returns the tree ALREADY rooted at `admin`, so the walk starts with an empty * path: seeding it with the root's name produces `/admin/admin/...` and a sweep of 404s that looks * like a clean run. */ export async function menuPaths(page, opts = {}) { /* `L.ui` is only there once ui.js has been required by something on the page, and how soon that * happens differs between release lines — reading it straight after the login redirect crashed * the 24.10 leg with "Cannot read properties of undefined (reading 'menu')" while 25.12 was fine. * Wait for the runtime, then ask for the module by name rather than hoping somebody else did. */ await page.waitForFunction(() => window.L && typeof window.L.require === 'function', null, { timeout: 20000 }); /* Only the leaves that are PAGES. A dispatcher tree carries far more than the menu shows: on a * router with openclash and justclash installed, 105 of its 169 leaves are `call` nodes — RPC * endpoints an app registers for its own JS — plus `function` nodes and the untitled plumbing. * None renders a page, so a gate that measured layout on one measured an empty `#view` and spent * two thirds of its wall clock proving that. * * `view` and `template` are the two that paint (plus `cbi` on an old enough app), and a node the * menu does not title is not a page a user can reach. `{ all: true }` returns the raw walk, for a * caller that wants the dispatcher rather than the interface. */ return page.evaluate(async (all) => { const ui = await L.require('ui'); const tree = await ui.menu.load(); const RENDERS = { view: 1, template: 1, cbi: 1 }; const out = []; const walk = (node, path) => { for (const name of Object.keys(node.children || {})) { const child = node.children[name]; const p = path.concat(name); if (child.children && Object.keys(child.children).length) { walk(child, p); continue; } const type = (child.action && child.action.type) || ''; if (all || (RENDERS[type] && child.title)) out.push('/' + p.join('/')); } }; walk(tree, []); return out; }, !!opts.all); } /* Pages a sweep must not open twice: they end the session or the router, and the second visit * measures a login form or a dead container. Named by path fragment, because the menu labels are * translated and the paths are not. */ export const DESTRUCTIVE = /\/(logout|reboot|flash|backup|shutdown)(\/|$)/; /* The literal clone owlab boots beside `owrt2512`, `owrt2410` and `owrtsnap` (`owlab.yaml`, task * sweepspeed) — same distro, release and package manager, its own container answering its own * ubus — for exactly this: a sweep can treat the pair as one logical stand with twice the * concurrency, without changing which release lines it covers. Auto-paired only when the caller did * not already name the twin explicitly: `--only owrt2512,owrt2512b` means "measure both, * separately", and this must not silently halve that into "measure the union once." Returns a Map * keyed by the BASE stand's id; empty when owlab has no `-b` set running, so a caller with the old * three-stand lab degrades to exactly today's behaviour. */ export function pairStands(list) { let out; try { out = execFileSync('owlab', [ 'status', '-json' ], { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] }); } catch (e) { return new Map(); } let parsed; try { parsed = JSON.parse(out); } catch (e) { return new Map(); } const running = (parsed.routers || []).filter((r) => r.state === 'running' && r.http_port); const byId = new Map(running.map((r) => [ r.id, r ])); const explicit = new Set(list.map((s) => s.id)); const pairs = new Map(); for (const s of list) { const twinId = s.id + 'b'; if (explicit.has(twinId)) continue; const t = byId.get(twinId); if (t) pairs.set(s.id, { id: t.id, base: `http://localhost:${t.http_port}/cgi-bin/luci`, release: t.release, distro: t.distro, pkg: t.package_manager }); } return pairs; } /* No stand, no verdict — and a gate that quietly reports success on zero routers is worse than one * that fails, because it looks the same as a clean run in a log. */ export function requireStands(list, name) { if (list.length) return list; console.error(`${name}: no owlab router is running, so nothing was checked.`); console.error('Start one with `owlab up` (see docs/development.md) and run this again.'); process.exit(2); }