Files
meshcore-analyzer/public/scope-audit.css
T
efiten 8c164c5315 feat(scope-audit): verify a declared region against the repeater's own traffic (#1990)
Follow-up to #1987, and the point of counting that traffic in the first
place.

A region this instance holds no `hashRegions` key for is **unnameable,
not absent**. #1987 says so with a caveat chip. This settles it wherever
the evidence allows: derive `SHA256("#region")[:16]` from the repeater's
own declaration and HMAC that repeater's own unmatched packets with it.
Same computation the ingestor performs at ingest, with the candidate set
narrowed from every configured key to this repeater's handful of
declarations.

Where it fires, a grey "declared but not observed" chip becomes a green
one and the caveat count shrinks by the packets it explained.

## Two packets, not one

`code1` is two bytes, so an unrelated name matches a given packet with
probability 1/65536. Across ~400 unmatched packets and ~124 declared
names, chance alone produces roughly one false match per refresh. Two
matches on the same region for the same repeater is (1/65536)², about
one in four billion.

Lowering the threshold to one would not make this noisy, it would make
it **unsound**, so the constant carries that arithmetic and a test
rather than a comment. A region with exactly one hit stays grey and
reports its single hit, so the page can say why it is still shown as not
observed instead of leaving the reader to wonder.

## What it deliberately does not do

**It writes nothing.** Read-time only. A wrong answer expires with the
window instead of sitting in `transmissions.scope_name` until someone
runs a repair, and `cmd/server` stays read-only per the invariant in
AGENTS.md.

**`notObserved` remains the single source of chip colour.**
`regionEvidence` says only HOW a region was established. Two fields that
can disagree about the same fact is how this column got confusing in the
first place.

## Rule 0, including the part that was wrong at first

The naive shape is `targets × names × packets` HMACs: 205 × 124 × 400 ≈
10M.

Caching per `(region, transmission)` pair cuts the HMACs to ~50k. **That
measured 501ms**, because the HMACs had become a rounding error while
the *iteration* stayed cubic at 10.2M map lookups. Re-keyed per region,
holding the set of matching transmissions, it is **36ms** at the same
worst-case shape: a region is HMACed over every packet once, and a
target then asks one question per declared region instead of one per
(region, packet). Most declared regions match nothing, so the common
case is a single map lookup and no packet loop at all.

`hmacCount` exists so a test can assert the first mistake cannot come
back; the benchmark exists because only it caught the second.

## Both axes are bounded, because neither is bounded by the data

The "~400 packets in a 7 day window" this was sized against is a
property of one instance's configuration, not of the feature: the
ingestor stores the unnameable state for every transport-scoped packet
no configured key names, so an instance with few or no `hashRegions`
entries — the stock state, and the one this helps most — has **every**
scoped packet in that set.

- the window query takes the 4096 most recent candidates and reports
truncation, which the handler logs, so a grey chip on a sampled refresh
is not read as "not forwarded"
- the declared list is capped at 32 names per repeater: it arrives from
a collector that validates each entry's shape but never how many entries
there are
- measured at the cap: **306ms** for 205 targets over 124 names, against
29ms for the shape a real network produces

Because both caps make the evidence a sample, the response carries
`observedUnmatchedSampled`. Without it a client subtracts a capped
numerator from an uncapped total and overstates the unexplained traffic
with no way to know it is doing so. The chip subtracts only evidence for
regions **absent** from `notObserved` — a single-hit region the server
refused to accept is not called explained either — and says "at most N"
when the count was sampled.

## Verified on live data

Six repeaters clear the threshold in a 7d window on a real instance. One
of them: `nl-nb` green with 3 corroborating packets and the tooltip
stating the count, `belml` still grey on 1, and the caveat chip reading
31 of 34 packets unexplained rather than 30.

## Tests

`scope_verify_test.go` covers the HMAC-input walk against a real
transport-flood packet captured from a live instance (a hand-built
fixture would only prove the parser agrees with itself), that
`regionCode` does not fold case, the threshold in both directions, the
memo's HMAC count, both bounds with their truncation flag, and the
benchmark at cap size. Handler-level tests cover a region verified into
green, a single hit left grey with its count reported, and the
sample-size field.

`cd cmd/server && go test ./...` passes (77s), frontend 723 assertions
pass, `go vet` and `gofmt -l` clean.
2026-09-09 17:58:09 +02:00

92 lines
5.2 KiB
CSS

/* Network-wide scope audit page (#/scope-audit). Reuses .ns-decl / .ns-table /
.ns-empty / .ns-truncated from node-scopes.css (same badge vocabulary, so
the per-node and network-wide views agree visually) plus .analytics-time-range
for the window buttons. No hex/rgb literals — --text-muted / --border /
--status-* / --link-color are defined globally. */
.sa-page { max-width: 1200px; margin: 0 auto; padding: 12px 16px; }
.sa-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
.sa-head h2 { margin: 0; font-size: 18px; }
.sa-intro { color: var(--text-muted); font-size: 12px; margin: 4px 0 10px; }
.sa-intro a { color: var(--link-color); }
/* .sa-search reuses .nodes-search (style.css) for the input's colours/border
so this page doesn't invent a second search-box style — only the sizing
below is page-specific. */
.sa-search-bar { margin: 0 0 10px; }
.sa-search { width: 100%; max-width: 360px; }
.sa-window-note {
font-size: 12px; color: var(--text-muted); margin: 4px 0 10px;
border-left: 3px solid var(--border); padding: 4px 10px;
}
.sa-table-wrap { overflow-x: auto; }
.sa-table { min-width: 720px; }
.sa-name a { color: var(--link-color); text-decoration: none; }
.sa-name a:hover { text-decoration: underline; }
.sa-name-unknown { color: var(--text-muted); font-style: italic; }
.sa-role { font-size: 10px; text-transform: uppercase; letter-spacing: .3px; }
.sa-chip {
display: inline-block; padding: 1px 6px; margin: 1px 2px 1px 0; border-radius: 4px;
font-size: 11px; font-family: var(--mono); white-space: nowrap;
}
.sa-chip-declared { background: var(--section-bg, var(--card-bg)); color: var(--text); border: 1px solid var(--border); }
.sa-chip-undeclared { background: color-mix(in srgb, var(--status-yellow) 18%, transparent); color: var(--status-amber-text); }
.sa-chip-wildcard { background: var(--section-bg, var(--card-bg)); color: var(--text-muted); font-weight: 700; }
.sa-chip-ambiguous { background: var(--section-bg, var(--card-bg)); color: var(--text-muted); border: 1px dashed var(--border); font-family: inherit; font-style: italic; }
.sa-count { font-size: 11px; margin-top: 6px; }
/* sa-summary is the per-configState count line at the top of the page —
"13 no scopes configured" etc — reusing .ns-decl (node-scopes.css) for the
count pill so it shares the exact colour vocabulary the row-level Config
badge (also .ns-decl) uses, rather than a separate summary palette. */
.sa-summary { display: flex; flex-wrap: wrap; gap: 10px 16px; font-size: 12px; color: var(--text-muted); margin: 2px 0 10px; }
.sa-summary-item { display: inline-flex; align-items: center; gap: 5px; }
@media (max-width: 640px) {
.sa-table th:nth-child(6), .sa-table td:nth-child(6) { display: none; }
}
/* Observed forwarding, the green half of the merged Scopes column. Pairs with
.sa-chip-unobserved below, which stays neutral. Only the observed side is
coloured: red on every unobserved region would read as an alarm on rows that
are often just a quiet region over a short window. */
.sa-chip-observed { background: color-mix(in srgb, var(--status-green) 16%, transparent); color: var(--status-green-text); }
/* Declared but not observed in this window. Deliberately NOT red: absence over
a short window is weak evidence, which the page header says in words, so it
should not shout in colour. */
.sa-chip-unobserved { background: var(--section-bg, var(--card-bg)); color: var(--text-muted); border: 1px solid var(--border); }
/* Provenance note under the intro. Same muted treatment as .sa-intro; it is
context, not a finding, and must not compete with the table. */
.sa-sources { color: var(--text-muted); font-size: 12px; margin: 0 0 10px; line-height: 1.5; }
.sa-sources a { color: var(--link-color); }
.sa-sources code { font-family: var(--mono); font-size: 11px; }
/* Provenance note under the intro, and the empty state that has to carry the
same explanation on its own: on a stock install the table IS empty, so that
is where an operator actually reads it. Muted like .sa-intro; this is
context, not a finding, and must not compete with the table. */
.sa-sources { color: var(--text-muted); font-size: 12px; margin: 0 0 10px; line-height: 1.5; }
.sa-sources a, .sa-empty a { color: var(--link-color); }
.sa-sources code, .sa-empty code { font-family: var(--mono); font-size: 11px; }
.sa-empty { line-height: 1.6; max-width: 70ch; }
.sa-empty ul { margin: 8px 0; padding-left: 20px; }
.sa-empty li { margin: 4px 0; }
/* Same muted, dashed treatment as .sa-chip-ambiguous on purpose: both are
caveats on the row's finding, not findings themselves, and neither may
compete visually with the red/green scope chips beside them. */
.sa-chip-unmatched { background: var(--section-bg, var(--card-bg)); color: var(--text-muted); border: 1px dashed var(--border); font-family: inherit; font-style: italic; }
/* Verified-by-declaration: the same green as any observed chip, because the
region IS observed. The dotted underline says how that was established
without introducing a third colour into a column that already carries two. */
.sa-chip-verified { text-decoration: underline dotted; text-underline-offset: 2px; }