mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-20 09:40:57 +00:00
Implements #1727. ## What this adds **Mobile client-RX coverage** — an opt-in, crowdsourced RF-coverage feature. A roaming MeshCore **companion** radio (driven by the open-source [corescope-rx](https://github.com/efiten/corescope-rx) PWA, GPLv3) reports which nodes it heard directly, tagged with the phone's GPS and the packet's SNR/RSSI. CoreScope ingests these into a new `client_receptions` table and renders per-node **hex coverage** on the Reach page, plus a standalone **Coverage dashboard** (`#/rx-coverage`) with a top-mobile-observers leaderboard. Also includes **`GET /api/nodes/resolve?prefix=<hex>`** — a read-only node-name lookup by pubkey prefix (`{name, pubkey, ambiguous}`), used by the companion app for friendly names. ## Opt-in — default OFF (zero impact on existing deployments) The whole feature is gated behind one config flag, **disabled by default**: ```jsonc "clientRxCoverage": { "enabled": false } ``` When disabled (the default): the ingestor writes **no** `client_receptions`; the three coverage endpoints return a clean **404**; the UI hides the Coverage nav link, the `#/rx-coverage` route, and the Reach-page toggle. `/api/nodes/resolve` is always available (not coverage-specific). ## How it works ``` companion ──BLE 0x88 (snr+rssi+raw)──▶ corescope-rx PWA ──▶ MQTT meshcore/client/{pubkey}/packets │ ingestor (gated) ──▶ client_receptions (GPS + SNR + heard-key) │ server: pure-Go hex grid ──▶ GeoJSON ──▶ Reach hex overlay + Coverage dashboard ``` - **Direct-only capture:** records only what the companion heard itself and directly — a 0-hop advert's pubkey, or `path[last]` (last forwarder) for FLOOD routes; ≥2-byte path-hash required. Upstream hops discarded. - **No new deps:** hexbins are a pure-Go pointy-top grid over Web Mercator (`cmd/server/hexgrid.go`) computed at query time (`CGO_ENABLED=0` / `modernc.org/sqlite` friendly); frontend uses the existing Leaflet. - **Trust:** companion pubkey = identity; an EMQX ACL binds each client to publish only to its own `meshcore/client/{pubkey}/packets` topic. Payload contract in `docs/client-rx-coverage.md`. ## How to enable / try it 1. In `config.json`, set `"clientRxCoverage": { "enabled": true }` and restart server + ingestor. 2. Point an EMQX (or any broker) listener so a client can publish to `meshcore/client/<pubkey>/packets`; the ingestor already subscribes under `meshcore/#`. 3. Run the [corescope-rx](https://github.com/efiten/corescope-rx) PWA on an Android phone paired (BLE) to a MeshCore companion — it captures heard nodes + GPS and publishes. 4. View results: per-node Reach page → toggle **coverage**, or the **Coverage** dashboard at `#/rx-coverage`. ## What's where - **Ingestor:** `cmd/ingestor/client_reception.go` (ingest), `db.go` (`client_receptions` + `client_observers` schema), `main.go` (gated dispatch), `config.go` (flag). - **Server:** `cmd/server/rx_coverage.go` + `rx_dashboard.go` (endpoints, self-guard 404 when off), `hexgrid.go` (pure-Go grid), `node_resolve.go` (resolve), `routes.go` / `types.go` / `config.go` (wiring + flag + `/api/config/client` field). - **Frontend:** `public/rx-coverage.js` (dashboard), `node-reach-coverage.js` + `.css` (overlay), `node-reach.js` (Reach toggle, flag-gated), `roles.js` (reads the flag, hides nav when off). - **Docs:** `docs/client-rx-coverage.md`. ## Testing - Go: `cd cmd/server && go test ./...` and `cd cmd/ingestor && go test ./...` — green, including new gate tests (`coverage_gate_test.go` in both: off → no rows / 404, on → works) and the rx-coverage / resolve / hexgrid suites. - JS: `node test-coverage-gate.js`, `node test-node-reach-coverage.js` (wired into CI). The Playwright `test-node-reach-coverage-e2e.js` is wired into the e2e job and **skips when `clientRxCoverage` is disabled**, so it's safe under the default-off config. ## Notes for reviewers - The four new routes are registered in `cmd/server/openapi_known_gaps.json` (the existing OpenAPI-completeness ratchet), matching how other not-yet-spec'd routes are tracked. Happy to write full OpenAPI spec entries instead if you prefer. - Commits are split per layer (ingestor / server endpoints / resolve / frontend / CI) for review. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Erwin Fiten <e.fiten@opteco.be>
62 lines
3.1 KiB
JavaScript
62 lines
3.1 KiB
JavaScript
'use strict';
|
|
// Unit test for #14: the mobile RX coverage leaderboard must HTML-escape the
|
|
// pubkey it interpolates into the row markup (data-rx="..." and the truncated
|
|
// fallback label), not only the name. A no-ACL broker / pre-validation rows
|
|
// could carry a non-hex pubkey, and the rest of the row is built by string
|
|
// concatenation, so an unescaped pubkey is an HTML-injection vector.
|
|
//
|
|
// Like test-coverage-gate.js we slice the real row-building expression out of
|
|
// public/rx-coverage.js and evaluate it in a vm sandbox — no hand-copied
|
|
// duplicate — so the test tracks the actual source.
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
|
|
const src = fs.readFileSync(path.join(__dirname, 'public', 'rx-coverage.js'), 'utf8');
|
|
|
|
// Slice from `var nm = o.name ...` through the end of the returned row string.
|
|
const startMarker = 'var nm = o.name ? escapeHtml(o.name)';
|
|
const endMarker = "o.nodes + '</span></div>';";
|
|
const startIdx = src.indexOf(startMarker);
|
|
assert.ok(startIdx >= 0, 'could not locate row-builder start in rx-coverage.js');
|
|
const endIdx = src.indexOf(endMarker, startIdx);
|
|
assert.ok(endIdx >= 0, 'could not locate row-builder end in rx-coverage.js');
|
|
const block = src.slice(startIdx, endIdx + endMarker.length);
|
|
|
|
// Canonical escapeHtml (public/app.js).
|
|
function escapeHtml(s) {
|
|
if (s == null) return '';
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
function renderRow(o) {
|
|
const sandbox = { o: o, i: 0, selectedRx: '', escapeHtml: escapeHtml };
|
|
vm.createContext(sandbox);
|
|
return vm.runInContext('(function () { ' + block + ' })()', sandbox);
|
|
}
|
|
|
|
// Malicious pubkey that would break out of the data-rx attribute and inject a
|
|
// tag if interpolated raw. With escaping, no raw '<', '>' or attribute-closing
|
|
// '"' survives.
|
|
const evil = '"><img src=x onerror=alert(1)>';
|
|
|
|
// Case 1: no name → pubkey used as the visible label fallback too.
|
|
const row1 = renderRow({ pubkey: evil, name: '', receptions: 1, nodes: 1 });
|
|
assert.ok(row1.indexOf('<img') === -1, 'raw <img must not appear in row (label fallback): ' + row1);
|
|
assert.ok(row1.indexOf('data-rx="' + evil + '"') === -1, 'raw pubkey must not appear unescaped in data-rx');
|
|
assert.ok(row1.indexOf('<img') !== -1 || row1.indexOf('">') !== -1, 'pubkey should be HTML-escaped: ' + row1);
|
|
|
|
// Case 2: name present → label is the (escaped) name, but data-rx still carries
|
|
// the pubkey and must be escaped.
|
|
const row2 = renderRow({ pubkey: evil, name: 'Mob', receptions: 2, nodes: 3 });
|
|
assert.ok(row2.indexOf('<img') === -1, 'raw <img must not appear in row (named): ' + row2);
|
|
|
|
// #a11y: the clickable row must be keyboard-operable (role/tabindex) and expose
|
|
// pressed state, so it isn't a mouse-only <div>.
|
|
assert.ok(/role="button"/.test(row2), 'row must have role="button"');
|
|
assert.ok(/tabindex="0"/.test(row2), 'row must be focusable (tabindex)');
|
|
assert.ok(/aria-pressed="(true|false)"/.test(row2), 'row must expose aria-pressed');
|
|
|
|
console.log('rx-coverage pubkey escaping + row a11y OK');
|