mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-25 19:03:36 +00:00
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.
340 lines
14 KiB
Go
340 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// realTransportFloodPacket is transmission 0a065d41d51f1f77 from the live
|
|
// instance, captured 2026-09-07. Header 0x14 = route_type 0 (TRANSPORT_FLOOD),
|
|
// payload_type 5 (GRP_TXT); transport codes 9209/0000; path byte 0x41 =
|
|
// hash_size 2, one hop "E3D3"; the rest is payload.
|
|
//
|
|
// A hand-built fixture would only prove the parser agrees with itself. This
|
|
// packet is the one that started the investigation: its code1 is exactly the
|
|
// code #fm-112 derives over its own payload, which is why the audit showed
|
|
// fm-112 as "not observed" for a repeater that was forwarding it.
|
|
const realTransportFloodPacket = "149209000041E3D3EC2D4481DA70893CD71B763958B064A9AAC011D54223FF8A0140CBB4093653BC61D67C960E3ECCE6639CC9FF1147AA6D0F9017"
|
|
|
|
func TestScopeHMACInputsParsesRealPacket(t *testing.T) {
|
|
payloadType, payload, code1, ok := scopeHMACInputs(realTransportFloodPacket)
|
|
if !ok {
|
|
t.Fatal("scopeHMACInputs returned ok=false for a valid transport-flood packet")
|
|
}
|
|
if payloadType != 5 {
|
|
t.Errorf("payloadType = %d, want 5 (GRP_TXT)", payloadType)
|
|
}
|
|
if code1 != "9209" {
|
|
t.Errorf("code1 = %q, want %q", code1, "9209")
|
|
}
|
|
if len(payload) != 51 {
|
|
t.Errorf("len(payload) = %d, want 51", len(payload))
|
|
}
|
|
if got := strings.ToUpper(hex.EncodeToString(payload[:4])); got != "EC2D4481" {
|
|
t.Errorf("payload starts %q, want %q — offset walked wrong", got, "EC2D4481")
|
|
}
|
|
}
|
|
|
|
func TestScopeHMACInputsRejectsNonTransportRoutes(t *testing.T) {
|
|
// A plain FLOOD packet carries no transport codes, so it has no code1 to
|
|
// verify against. Returning ok=false rather than a zero code1 keeps the
|
|
// caller from HMACing packets that can never match anything.
|
|
//
|
|
// Header 0x15 = route_type 1 (FLOOD), payload_type 5. No transport codes,
|
|
// so the path byte follows the header directly.
|
|
_, _, _, ok := scopeHMACInputs("15" + "41" + "E3D3" + "AABBCC")
|
|
if ok {
|
|
t.Error("ok = true for a non-transport route, want false — there is no code1 to verify")
|
|
}
|
|
}
|
|
|
|
func TestScopeHMACInputsRejectsMalformed(t *testing.T) {
|
|
for _, c := range []struct{ hex, why string }{
|
|
{"", "empty"},
|
|
{"zz", "not hex"},
|
|
{"14", "header only, no transport codes"},
|
|
{"1492090000", "transport codes but no path byte"},
|
|
{"149209000041", "path byte claims one 2-byte hop, none present"},
|
|
// pathByte 0xC0: upper two bits 11 -> hash_size 4, which firmware
|
|
// reserves and isValidPathLen rejects even at hash_count 0
|
|
// (cmd/server/decoder.go, mirroring Packet.cpp:13-18).
|
|
{"1492090000C0" + strings.Repeat("00", 8), "hash_size 4 is reserved"},
|
|
} {
|
|
if _, _, _, ok := scopeHMACInputs(c.hex); ok {
|
|
t.Errorf("ok = true for %q (%s), want false", c.hex, c.why)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRegionCodeMatchesTheRealPacket(t *testing.T) {
|
|
// The end-to-end arithmetic, against a packet whose true region is known.
|
|
payloadType, payload, code1, ok := scopeHMACInputs(realTransportFloodPacket)
|
|
if !ok {
|
|
t.Fatal("setup: scopeHMACInputs failed")
|
|
}
|
|
if got := regionCode("fm-112", payloadType, payload); got != code1 {
|
|
t.Errorf("regionCode(fm-112) = %q, want %q — this packet IS fm-112", got, code1)
|
|
}
|
|
// Both spellings must agree: the key is SHA256 over "#name", and callers
|
|
// hand us names with the '#' already stripped by normScope.
|
|
if got := regionCode("#fm-112", payloadType, payload); got != code1 {
|
|
t.Errorf("regionCode(#fm-112) = %q, want %q — leading '#' must be optional", got, code1)
|
|
}
|
|
// A region the repeater also declares, which this packet is NOT.
|
|
if got := regionCode("behss", payloadType, payload); got == code1 {
|
|
t.Errorf("regionCode(behss) = %q, must not equal fm-112's code1", got)
|
|
}
|
|
}
|
|
|
|
func TestRegionCodeIsCaseSensitive(t *testing.T) {
|
|
// The key is SHA256 over the raw bytes of "#name", so "#BEHSS" and
|
|
// "#behss" are different regions. Folding case here would silently name
|
|
// traffic for a region nobody configured.
|
|
payloadType, payload, _, _ := scopeHMACInputs(realTransportFloodPacket)
|
|
if regionCode("behss", payloadType, payload) == regionCode("BEHSS", payloadType, payload) {
|
|
t.Error("regionCode folded case — the key is a hash over raw bytes and must not")
|
|
}
|
|
}
|
|
|
|
func TestUnmatchedTransmissionsInWindow(t *testing.T) {
|
|
s := newScopeTestStore(t)
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
old := "2020-01-01T00:00:00Z"
|
|
seedTransmissionRouteAt(t, s, "E3D3", scopeUnmatched(), RouteFlood, recent)
|
|
seedTransmissionRouteAt(t, s, "E3D3", scopeMatched("#be"), RouteFlood, recent)
|
|
seedTransmissionRouteAt(t, s, "E3D3", scopeUnscoped(), RouteFlood, recent)
|
|
seedTransmissionRouteAt(t, s, "E3D3", scopeUnmatched(), RouteFlood, old)
|
|
|
|
since := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)
|
|
got, _, err := s.unmatchedTransmissionsInWindow(since)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("got %d rows, want 1 — only the recent scope_name='' row qualifies", len(got))
|
|
}
|
|
// scopeUnmatched() seeds raw_hex 'AA', which scopeHMACInputs rejects. The
|
|
// query's job is selection; unparseable rows are dropped by the caller, so
|
|
// they must still be returned here rather than filtered in SQL.
|
|
if got[0].txID == 0 {
|
|
t.Error("txID = 0, want the transmission's real id")
|
|
}
|
|
}
|
|
|
|
// buildVerifierFromPackets is a test helper: wraps raw hex strings as rows the
|
|
// verifier consumes, with ids 1..N in order.
|
|
func buildVerifierFromPackets(t *testing.T, hexes ...string) *scopeVerifier {
|
|
t.Helper()
|
|
rows := make([]unmatchedTransmissionRow, 0, len(hexes))
|
|
for i, h := range hexes {
|
|
rows = append(rows, unmatchedTransmissionRow{txID: int64(i + 1), rawHex: h})
|
|
}
|
|
return newScopeVerifier(rows)
|
|
}
|
|
|
|
func TestScopeVerifierNeedsTwoCorroboratingPackets(t *testing.T) {
|
|
// One match is 1-in-65536 and must not be enough; a second makes it
|
|
// (1/65536)^2. This threshold is the reason the approach is sound.
|
|
v := buildVerifierFromPackets(t, realTransportFloodPacket)
|
|
one := v.evidence([]int64{1}, []string{"fm-112"})
|
|
if one["fm-112"] != 1 {
|
|
t.Fatalf("evidence = %v, want fm-112:1", one)
|
|
}
|
|
if got := v.verified(one); len(got) != 0 {
|
|
t.Errorf("verified = %v, want none - one corroborating packet is not evidence", got)
|
|
}
|
|
|
|
// The same packet twice under different ids: two distinct transmissions
|
|
// both deriving to fm-112.
|
|
v2 := buildVerifierFromPackets(t, realTransportFloodPacket, realTransportFloodPacket)
|
|
two := v2.evidence([]int64{1, 2}, []string{"fm-112"})
|
|
if two["fm-112"] != 2 {
|
|
t.Fatalf("evidence = %v, want fm-112:2", two)
|
|
}
|
|
got := v2.verified(two)
|
|
if len(got) != 1 || got[0] != "fm-112" {
|
|
t.Errorf("verified = %v, want [fm-112]", got)
|
|
}
|
|
}
|
|
|
|
func TestScopeVerifierIgnoresRegionsThatDoNotMatch(t *testing.T) {
|
|
v := buildVerifierFromPackets(t, realTransportFloodPacket, realTransportFloodPacket)
|
|
got := v.evidence([]int64{1, 2}, []string{"behss", "be", "eu"})
|
|
if len(got) != 0 {
|
|
t.Errorf("evidence = %v, want empty - none of these regions is this packet", got)
|
|
}
|
|
}
|
|
|
|
func TestScopeVerifierSkipsUnparseablePackets(t *testing.T) {
|
|
// A row whose raw_hex cannot be walked contributes nothing and must not
|
|
// error the pass: one malformed row in the window would otherwise blank
|
|
// the verification for every repeater.
|
|
v := buildVerifierFromPackets(t, "AA", realTransportFloodPacket)
|
|
got := v.evidence([]int64{1, 2}, []string{"fm-112"})
|
|
if got["fm-112"] != 1 {
|
|
t.Errorf("evidence = %v, want fm-112:1 - the malformed row is skipped, the good one still counts", got)
|
|
}
|
|
}
|
|
|
|
func TestScopeVerifierCachesAcrossTargets(t *testing.T) {
|
|
// The cost argument: work depends on (region, transmission), not on which
|
|
// target asked. Two targets declaring the same region over the same packets
|
|
// must not double the HMACs.
|
|
v := buildVerifierFromPackets(t, realTransportFloodPacket, realTransportFloodPacket)
|
|
v.evidence([]int64{1, 2}, []string{"fm-112"})
|
|
after := v.hmacCount
|
|
v.evidence([]int64{1, 2}, []string{"fm-112"})
|
|
if v.hmacCount != after {
|
|
t.Errorf("hmacCount %d -> %d on a repeat query, want unchanged - the cache is what keeps this inside rule 0", after, v.hmacCount)
|
|
}
|
|
}
|
|
|
|
func TestScopeVerifierUnknownTxIDIsHarmless(t *testing.T) {
|
|
// A target's unmatchedTxIDs come from a different query than the verifier's
|
|
// rows. They are taken in the same window, but a row pruned between the two
|
|
// must degrade to "no evidence", not panic.
|
|
v := buildVerifierFromPackets(t, realTransportFloodPacket)
|
|
got := v.evidence([]int64{1, 999}, []string{"fm-112"})
|
|
if got["fm-112"] != 1 {
|
|
t.Errorf("evidence = %v, want fm-112:1 - the unknown id contributes nothing", got)
|
|
}
|
|
}
|
|
|
|
// BenchmarkScopeVerifierAudit models a full audit refresh: every declared name
|
|
// against every unmatched packet, once, through the memo. The naive shape would
|
|
// be targets x names x packets; this asserts the memo keeps it at names x
|
|
// packets, which is what makes the feature affordable (AGENTS.md rule 0).
|
|
func BenchmarkScopeVerifierAudit(b *testing.B) {
|
|
const packets, names, targets = 400, 124, 205
|
|
rows := make([]unmatchedTransmissionRow, 0, packets)
|
|
txIDs := make([]int64, 0, packets)
|
|
for i := 0; i < packets; i++ {
|
|
rows = append(rows, unmatchedTransmissionRow{txID: int64(i + 1), rawHex: realTransportFloodPacket})
|
|
txIDs = append(txIDs, int64(i+1))
|
|
}
|
|
declared := make([]string, 0, names)
|
|
for i := 0; i < names; i++ {
|
|
declared = append(declared, fmt.Sprintf("r%04d", i))
|
|
}
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
v := newScopeVerifier(rows)
|
|
for t := 0; t < targets; t++ {
|
|
v.evidence(txIDs, declared)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUnmatchedTransmissionsInWindowIsBounded pins the LIMIT and what it keeps.
|
|
//
|
|
// The "~400 packets in a 7d window" this feature was sized against holds only
|
|
// for an instance whose hashRegions covers most of what its repeaters forward.
|
|
// The ingestor writes scope_name = ” for EVERY transport-scoped packet it
|
|
// cannot name, so a stock instance with no hashRegions at all has every scoped
|
|
// packet in this result set, and the verifier's work is (distinct declared
|
|
// names x rows). Unbounded on the axis that grows fastest is exactly what
|
|
// AGENTS.md rule 0 forbids.
|
|
//
|
|
// Newest-first is not arbitrary: a partial answer built from the most recent
|
|
// traffic matches what the window claims to describe, and a repeater still
|
|
// forwarding a region is far likelier to have done so recently.
|
|
func TestUnmatchedTransmissionsInWindowIsBounded(t *testing.T) {
|
|
s := newScopeTestStore(t)
|
|
base := time.Now().UTC().Add(-2 * time.Hour)
|
|
total := scopeVerifyMaxWindowPackets + 25
|
|
for i := 0; i < total; i++ {
|
|
at := base.Add(time.Duration(i) * time.Second).Format(time.RFC3339)
|
|
seedTransmissionRouteAt(t, s, "E3D3", scopeUnmatched(), RouteFlood, at)
|
|
}
|
|
|
|
since := time.Now().UTC().Add(-3 * time.Hour).Format(time.RFC3339)
|
|
got, truncated, err := s.unmatchedTransmissionsInWindow(since)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != scopeVerifyMaxWindowPackets {
|
|
t.Fatalf("got %d rows, want the cap of %d", len(got), scopeVerifyMaxWindowPackets)
|
|
}
|
|
if !truncated {
|
|
t.Error("truncated = false, want true — the caller has to be able to say the evidence is partial")
|
|
}
|
|
|
|
// The 25 oldest rows are the ones that must have been dropped: seeded ids
|
|
// ascend with first_seen, so every kept id must be above that boundary.
|
|
for _, r := range got {
|
|
if r.txID <= 25 {
|
|
t.Fatalf("kept txID %d, want only the %d newest rows", r.txID, scopeVerifyMaxWindowPackets)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUnmatchedTransmissionsInWindowReportsNoTruncationUnderCap: the flag has to
|
|
// distinguish "this is everything" from "this is a sample", or the caller
|
|
// cannot tell an exact answer from a partial one.
|
|
func TestUnmatchedTransmissionsInWindowReportsNoTruncationUnderCap(t *testing.T) {
|
|
s := newScopeTestStore(t)
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
seedTransmissionRouteAt(t, s, "E3D3", scopeUnmatched(), RouteFlood, recent)
|
|
|
|
since := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)
|
|
got, truncated, err := s.unmatchedTransmissionsInWindow(since)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 1 || truncated {
|
|
t.Fatalf("got %d rows truncated=%v, want 1 and false", len(got), truncated)
|
|
}
|
|
}
|
|
|
|
// TestCapVerifyRegions bounds the other axis. A repeater's declared list
|
|
// arrives over MQTT from a companion app: handleClientRegions validates the
|
|
// target and each entry, but nothing limits how MANY entries a client may
|
|
// report, and every distinct name costs one pass over every unmatched packet.
|
|
// The longest genuine list on this network declares 21 regions.
|
|
func TestCapVerifyRegions(t *testing.T) {
|
|
short := []string{"be", "nl", "eu"}
|
|
if got := capVerifyRegions(short); len(got) != 3 {
|
|
t.Errorf("len = %d, want 3 — a normal list must pass through untouched", len(got))
|
|
}
|
|
long := make([]string, scopeVerifyMaxRegionsPerTarget+10)
|
|
for i := range long {
|
|
long[i] = fmt.Sprintf("r%03d", i)
|
|
}
|
|
got := capVerifyRegions(long)
|
|
if len(got) != scopeVerifyMaxRegionsPerTarget {
|
|
t.Fatalf("len = %d, want the cap of %d", len(got), scopeVerifyMaxRegionsPerTarget)
|
|
}
|
|
if got[0] != long[0] {
|
|
t.Errorf("got[0] = %q, want %q — the cap keeps the first entries, it does not reorder", got[0], long[0])
|
|
}
|
|
}
|
|
|
|
// BenchmarkScopeVerifierStress runs the shape the cap allows, not the shape
|
|
// today's network produces. BenchmarkScopeVerifierAudit models 400 packets;
|
|
// this one models a full sample, which is what an instance with few configured
|
|
// region keys actually hands the verifier.
|
|
func BenchmarkScopeVerifierStress(b *testing.B) {
|
|
const names, targets = 124, 205
|
|
packets := scopeVerifyMaxWindowPackets
|
|
rows := make([]unmatchedTransmissionRow, 0, packets)
|
|
txIDs := make([]int64, 0, packets)
|
|
for i := 0; i < packets; i++ {
|
|
rows = append(rows, unmatchedTransmissionRow{txID: int64(i + 1), rawHex: realTransportFloodPacket})
|
|
txIDs = append(txIDs, int64(i+1))
|
|
}
|
|
declared := make([]string, 0, names)
|
|
for i := 0; i < names; i++ {
|
|
declared = append(declared, fmt.Sprintf("r%04d", i))
|
|
}
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
v := newScopeVerifier(rows)
|
|
for t := 0; t < targets; t++ {
|
|
v.evidence(txIDs, declared)
|
|
}
|
|
}
|
|
}
|