Files
meshcore-analyzer/cmd/server/scope_audit.go
T
efiten 2c8c1161b5 feat(#1975): network-wide Scope Audit page, fed by confirmed scopes (#1976)
One row per repeater whose configured region list is known, answering a question
no other view answers: you declare these regions, but were you seen forwarding
them? default_scope says what a node's adverts were observed under and
transported_scopes (#1751) says what it carried, but nothing lined the declared
list up against observed forwarding.

The declared side merges every confirmed-scope source the database carries,
newest answer per node wins, rather than naming one. On a stock install only
nodes.configured_scope (#1865/#1971) exists and it degrades to the one-source
case; deployments that collect the same fact another way keep working. Reading a
single hard-coded source would have rendered an empty page on the very instance
the evidence came from.

Declared and observed are compared through normScope, so a leading "#" and a
bare region name are one region. Unobserved regions render neutral, not red:
absence over a short window is weak evidence, which the page header already
states in words.

Ported from a long-running fork deployment with its 17 server tests, rewired to
the upstream data source, plus 14 frontend cases asserting rendered markup.
2026-09-06 23:04:42 +02:00

852 lines
31 KiB
Go

package main
// Issue #1975: the network-wide Scope Audit, GET /api/scope-audit.
//
// One row per repeater whose configured region list is known, answering a
// question no other view answers: you declare these regions, but were you
// actually seen forwarding them? default_scope says what a node's adverts were
// observed under and transported_scopes (#1751) says what it carried, but
// nothing lines the declared list up against observed forwarding, so a
// repeater configured for eight regions that only ever forwards one looks
// healthy everywhere else.
//
// Declared side: nodes.configured_scope, written by the observer /neighbors
// ingest from #1865/#1971. Observed side: forwarder hops in the window,
// aggregated per target. Both sides are compared through normScope, so the
// leading "#" that configured_scope carries and a bare region name are the
// same region.
//
// Ported from a long-running fork deployment. The measurements quoted in
// #1975 come from a 1179-repeater instance.
import (
"database/sql"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"golang.org/x/sync/singleflight"
)
// ScopeObservation is one region scope this repeater has been observed
// forwarding traffic for, within the query window.
type ScopeObservation struct {
Scope string `json:"scope"` // matched region name (transmissions.scope_name)
Packets int64 `json:"packets"`
FirstSeen string `json:"firstSeen"`
LastSeen string `json:"lastSeen"`
}
// RouteTypeMix is the route-type breakdown of packets this node was
// observed FORWARDING — i.e. packets on which this pubkey was the last hop
// of a FLOOD-family route (RouteTransportFlood, RouteFlood). It does NOT
// mean "packets in which this node appears anywhere in the path": a DIRECT
// or TRANSPORT_DIRECT packet's last path hop is the route's far end, never
// the transmitter, so crediting it here would attribute forwarding this node
// never did. Direct and TransportDirect are therefore always zero by
// construction — the forwarder join can never match those route types.
// scopeConformanceForwarderRouteTypesSQL restricts the forwarder join to the
// only route types whose path[last] is the packet's actual transmitter:
// RouteTransportFlood (0) and RouteFlood (1). A DIRECT route consumes hops
// from the front, so its path[last] is the route's far end rather than the
// forwarder — including RouteDirect (2) / RouteTransportDirect (3) here
// would misattribute a scope to a node that never forwarded the packet.
const scopeConformanceForwarderRouteTypesSQL = "t.route_type IN (0, 1)"
// minForwarderHopHexLen is the shortest path_json hop ScopeConformance will
// treat as an attribution: 4 hex chars = 2 bytes. Matches deriveHeardKey's
// floor (cmd/ingestor/client_reception.go: "exclude 1-byte (collision-prone),
// matching Reach") — a 1-byte/2-hex-char hash collides too often across a
// real fleet, and attributing a scope to the wrong node is worse than
// attributing none.
const minForwarderHopHexLen = 4
type DeclaredRegions struct {
Regions []string `json:"regions"`
ObservedAt string `json:"observedAt"`
Truncated bool `json:"truncated"`
}
// splitRegionsCSV parses regions_csv (comma-separated, "#" prefixes already
// stripped by the firmware) into a slice, always non-nil.
func splitRegionsCSV(csv string) []string {
regions := []string{}
if csv == "" {
return regions
}
for _, part := range strings.Split(csv, ",") {
part = strings.TrimSpace(part)
if part != "" {
regions = append(regions, part)
}
}
return regions
}
// CurrentDeclaredRegions returns pubkey's most recently declared region
// list, or nil (not an error) when the repeater has never successfully
// answered, or when node_declared_regions is absent (an older database that
// predates this table).
//
// "Most recent" is ordered by the greatest observed_at, NEVER ingested_at —
// mirrors the ingestor's own CurrentDeclaredRegions exactly: a drive
// buffered offline can arrive days late, and ordering by arrival would let
type DeclaredRegionsRow struct {
Target string
ObservedAt string
RegionsCSV string
Truncated bool
}
// AllCurrentDeclaredRegions returns the newest confirmed region list per node,
// merged across every source this database happens to carry.
//
// A confirmed list is a repeater's own answer about which regions it is
// configured to forward, read back off the node rather than inferred from
// traffic. Upstream that answer arrives one way, via the observer /neighbors
// report which #1865/#1971 writes to nodes.configured_scope. It is not the
// only possible collector: neighbor reports have also landed in
// meshcore-packet-capture and openHop, and some deployments gather the same
// answer over a companion app. So the lookup merges rather than hard-coding a
// single table, and an instance with only the stock source is simply the
// one-source case.
//
// Newest answer wins, compared on the recorded instant. Both sources store
// that in canonical UTC RFC3339, so a lexicographic compare is chronological;
// a row with no instant loses to any row that has one, and only wins against
// nothing at all.
//
// A node that has never answered is absent, never synthesized as declaring
// nothing: "no answer" and "answered with nothing" are different states and
// the audit distinguishes them.
func (db *DB) AllCurrentDeclaredRegions() ([]DeclaredRegionsRow, error) {
merged := map[string]DeclaredRegionsRow{}
keep := func(r DeclaredRegionsRow) {
if r.Target == "" {
return
}
if cur, ok := merged[r.Target]; ok && cur.ObservedAt >= r.ObservedAt {
return
}
merged[r.Target] = r
}
if db.hasConfiguredScope {
rows, err := db.conn.Query(`
SELECT public_key, COALESCE(configured_scope_at, ''), configured_scope
FROM nodes
WHERE configured_scope IS NOT NULL
`)
if err != nil {
return nil, fmt.Errorf("declared regions from configured_scope: %w", err)
}
for rows.Next() {
var d DeclaredRegionsRow
if err := rows.Scan(&d.Target, &d.ObservedAt, &d.RegionsCSV); err != nil {
rows.Close()
return nil, fmt.Errorf("declared regions from configured_scope scan: %w", err)
}
// Truncated has no equivalent on this source: the /neighbors report
// has a size cap but does not say whether it fired, and claiming
// false would invent a fact. The page omits the caveat instead.
keep(d)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, fmt.Errorf("declared regions from configured_scope rows: %w", err)
}
rows.Close()
}
if db.hasDeclaredRegionsTable {
rows, err := db.conn.Query(`
WITH ranked AS (
SELECT target, observed_at, regions_csv, truncated,
ROW_NUMBER() OVER (PARTITION BY target ORDER BY observed_at DESC) AS rn
FROM node_declared_regions
)
SELECT target, observed_at, regions_csv, truncated
FROM ranked
WHERE rn = 1
`)
if err != nil {
return nil, fmt.Errorf("declared regions from node_declared_regions: %w", err)
}
for rows.Next() {
var d DeclaredRegionsRow
var truncated int
if err := rows.Scan(&d.Target, &d.ObservedAt, &d.RegionsCSV, &truncated); err != nil {
rows.Close()
return nil, fmt.Errorf("declared regions from node_declared_regions scan: %w", err)
}
d.Truncated = truncated == 1
keep(d)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, fmt.Errorf("declared regions from node_declared_regions rows: %w", err)
}
rows.Close()
}
if len(merged) == 0 {
return nil, nil
}
out := make([]DeclaredRegionsRow, 0, len(merged))
for _, r := range merged {
out = append(out, r)
}
// Stable output: the handler ranks rows itself, but a deterministic order
// keeps responses comparable between calls on unchanged data.
sort.Slice(out, func(i, j int) bool { return out[i].Target < out[j].Target })
return out, nil
}
// scopeAuditNodeIdentity is the name/role display identity for one
// scope-audit row, resolved in bulk (one IN query for every declared
// target) rather than one GetNodeByPubkey call per repeater.
type scopeAuditNodeIdentity struct {
Name *string
Role *string
}
// scopeAuditNodeIdentities resolves name/role for pubkeys in a single query.
// A pubkey with no matching nodes row (deleted, pruned) resolves to the zero
// value rather than being an error — GET /api/scope-audit still has a
// PublicKey to identify and link the row.
func (db *DB) scopeAuditNodeIdentities(pubkeys []string) map[string]scopeAuditNodeIdentity {
result := make(map[string]scopeAuditNodeIdentity, len(pubkeys))
if len(pubkeys) == 0 {
return result
}
placeholders := make([]string, len(pubkeys))
args := make([]interface{}, len(pubkeys))
for i, k := range pubkeys {
placeholders[i] = "?"
args[i] = strings.ToLower(k)
}
rows, err := db.conn.Query(
"SELECT public_key, name, role FROM nodes WHERE public_key IN ("+strings.Join(placeholders, ",")+")",
args...)
if err != nil {
return result
}
defer rows.Close()
for rows.Next() {
var pk string
var name, role sql.NullString
if rows.Scan(&pk, &name, &role) != nil {
continue
}
id := scopeAuditNodeIdentity{}
if name.Valid {
v := name.String
id.Name = &v
}
if role.Valid {
v := role.String
id.Role = &v
}
result[strings.ToLower(pk)] = id
}
return result
}
// normScope strips a leading '#' so the two sides of the declared/observed
// comparison can be matched at all — mirrors public/node-scopes.js's
// normScope exactly. transmissions.scope_name keeps the '#' (region keys
// are configured as hashRegions: ["#belgium", "#eu"]); regions_csv arrives
// from the firmware with the prefix already stripped. Comparing raw
// silently inverts the whole comparison while looking entirely plausible.
func normScope(s string) string {
if strings.HasPrefix(s, "#") {
return s[1:]
}
return s
}
// scopeAuditTargetAgg is one declared target's forwarding aggregate for the
// GET /api/scope-audit scan window: named scopes it forwarded (key =
// normScope'd name) plus a count of the plain-FLOOD (unscoped) packets it
// forwarded — the signal the wildcard-contradiction check needs, since '*'
// governs exactly those packets, not any named scope.
type scopeAuditTargetAgg struct {
scopes map[string]*ScopeObservation
unscopedPackets int64
// ambiguousHops counts forwarder-hop observations in the window whose
// truncated hash prefix matched this target AND at least one other
// declared target — see ScopeAuditForwarding's doc comment for why
// those hops are attributed to neither candidate instead of both.
ambiguousHops int64
}
// scopeAuditPrefixIndex builds, for every even hex length from
// minForwarderHopHexLen up to a full 64-char pubkey, a lowercase prefix ->
// []target map. A forwarder hop of any valid truncated-hash length then
// resolves to its matching declared target(s) via a single map lookup,
// instead of a per-target SQL join — this is what keeps
// ScopeAuditForwarding's underlying scan a single query independent of how
// many repeaters have declared a region list.
func scopeAuditPrefixIndex(targets []string) map[int]map[string][]string {
byLen := make(map[int]map[string][]string)
for _, t := range targets {
t = strings.ToLower(t)
for l := minForwarderHopHexLen; l <= len(t); l += 2 {
m, ok := byLen[l]
if !ok {
m = map[string][]string{}
byLen[l] = m
}
prefix := t[:l]
m[prefix] = append(m[prefix], t)
}
}
return byLen
}
// scopeAuditForwarderScanQuery is the single full-window scan behind
// GET /api/scope-audit. Unlike scopeConformanceQuery (one EXISTS-correlated
// call per pubkey — fine for one node, but 37+ repeater-sized loop of them
// would each re-scan the same first_seen index range), this scans the
// FLOOD-family window exactly once and returns every forwarder hop found.
// It applies the SAME three conditions scopeConformanceQuery does —
// minForwarderHopHexLen, scopeConformanceForwarderRouteTypesSQL, and the
// explicit json_valid guard against a single malformed path_json row
// erroring the whole query — but does not join against any target list:
// attribution to a specific declared target happens in Go
// (ScopeAuditForwarding), against the small in-memory prefix index built by
// scopeAuditPrefixIndex, so the SQL cost stays O(rows in window) regardless
// of len(targets).
var scopeAuditForwarderScanQuery = `
SELECT t.id, je.value, t.scope_name, t.first_seen
FROM transmissions t
JOIN observations o ON o.transmission_id = t.id
JOIN json_each(o.path_json) je ON je.key = json_array_length(o.path_json) - 1
WHERE t.first_seen >= ?
AND ` + scopeConformanceForwarderRouteTypesSQL + `
AND o.path_json IS NOT NULL
AND json_valid(o.path_json)
AND json_array_length(o.path_json) > 0
AND LENGTH(je.value) >= ` + fmt.Sprint(minForwarderHopHexLen) + `
`
// ScopeAuditForwarding runs scopeAuditForwarderScanQuery once for the whole
// window and attributes every forwarder hop it finds to targets, by the same
// truncated-hash prefix match ScopeConformance uses for a single pubkey.
//
// A hop is attributed only when its prefix matches EXACTLY ONE declared
// target. This endpoint exists to find a repeater that declares a region and
// is not actually forwarding it — crediting a hop to every target sharing
// its prefix would let a colliding neighbour's traffic silently paper over a
// real gap, and crediting nobody (the alternative of dropping the hop
// entirely) would invent failures for targets that simply share a collision-
// prone prefix. Instead, an ambiguous hop is credited to NEITHER candidate,
// and every candidate's ambiguousHops counter is incremented instead, so the
// row can say "this notObserved might just be a prefix collision" rather
// than presenting it as a confirmed finding. See scopeAuditTargetAgg's
// ambiguousHops field and ScopeAuditRow.AmbiguousHops.
//
// Each (target, transmission) pair is counted at most once even if seen via
// multiple observations, for both the attributed and the ambiguous count —
// the same de-duplication scopeConformanceQuery gets for free from EXISTS,
// done explicitly here since this scan is not correlated per target.
func (s *PacketStore) ScopeAuditForwarding(sinceISO string, targets []string) (map[string]*scopeAuditTargetAgg, error) {
byLen := scopeAuditPrefixIndex(targets)
result := make(map[string]*scopeAuditTargetAgg, len(targets))
rows, err := s.db.conn.Query(scopeAuditForwarderScanQuery, sinceISO)
if err != nil {
return nil, fmt.Errorf("scope audit forwarder scan: %w", err)
}
defer rows.Close()
getAgg := func(target string) *scopeAuditTargetAgg {
agg, ok := result[target]
if !ok {
agg = &scopeAuditTargetAgg{scopes: map[string]*ScopeObservation{}}
result[target] = agg
}
return agg
}
seen := make(map[string]bool) // "<target>|<txID>" already counted (attributed or ambiguous)
for rows.Next() {
var txID int64
var hop string
var scopeName sql.NullString
var firstSeen string
if err := rows.Scan(&txID, &hop, &scopeName, &firstSeen); err != nil {
return nil, fmt.Errorf("scope audit forwarder scan scan: %w", err)
}
hop = strings.ToLower(hop)
candidates := byLen[len(hop)][hop]
if len(candidates) > 1 {
for _, target := range candidates {
key := target + "|" + strconv.FormatInt(txID, 10)
if seen[key] {
continue
}
seen[key] = true
getAgg(target).ambiguousHops++
}
continue
}
for _, target := range candidates {
key := target + "|" + strconv.FormatInt(txID, 10)
if seen[key] {
continue
}
seen[key] = true
agg := getAgg(target)
if !scopeName.Valid {
agg.unscopedPackets++
continue
}
if scopeName.String == "" {
continue // unmatched — not part of the declared/observed comparison
}
name := normScope(scopeName.String)
so, ok := agg.scopes[name]
if !ok {
so = &ScopeObservation{Scope: name, FirstSeen: firstSeen, LastSeen: firstSeen}
agg.scopes[name] = so
}
so.Packets++
if firstSeen < so.FirstSeen {
so.FirstSeen = firstSeen
}
if firstSeen > so.LastSeen {
so.LastSeen = firstSeen
}
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("scope audit forwarder scan rows: %w", err)
}
return result, nil
}
// Scope audit configuration-state values — see ScopeAuditRow.ConfigState and
// scopeAuditConfigState.
const (
ScopeConfigFull = "full" // named regions AND '*'
ScopeConfigNoScopes = "no-scopes" // '*' only, no named regions
ScopeConfigNoUnscoped = "no-unscoped" // named regions, no '*'
ScopeConfigNoFlood = "no-flood" // neither named regions nor '*'
)
// scopeAuditConfigState classifies a repeater's declared-regions answer into
// one of four configuration states, from ONLY the two fields the caller has
// already parsed out of the raw declared list (namedRegions and wildcard) —
// never re-derived from regions_csv, which splitRegionsCSV/normScope have
// already reduced to exactly these two facts.
//
// The firmware exports the FLOOD-allowed set (region_map.exportNamesTo(...,
// REGION_DENY_FLOOD) in examples/simple_repeater/MyMesh.cpp), so "no named
// regions in the export" does not strictly prove "no regions defined": a
// repeater with regions defined but every one of them marked deny-flood
// exports exactly the same list as one with no region tree at all, and the
// two are indistinguishable from this data alone. That caveat lands on
// ScopeConfigNoScopes and ScopeConfigNoFlood, both of which read "zero named
// regions in the export". It does NOT land on the wildcard half of the
// classification: '*' absent from the export is exact, not an inference —
// it means the wildcard denies flooding, i.e. plain unscoped floods are not
// forwarded, full stop. That exactness is why ScopeConfigNoUnscoped needs no
// caveat, and why ScopeConfigNoFlood's "no unscoped forwarding" half is
// exact even though its "no named regions" half is not.
func scopeAuditConfigState(namedRegions []string, wildcard bool) string {
switch {
case len(namedRegions) > 0 && wildcard:
return ScopeConfigFull
case len(namedRegions) == 0 && wildcard:
return ScopeConfigNoScopes
case len(namedRegions) > 0 && !wildcard:
return ScopeConfigNoUnscoped
default:
return ScopeConfigNoFlood
}
}
// ScopeAuditRow is one repeater's declared-vs-observed comparison for
// GET /api/scope-audit — the network-wide answer to "which repeaters
// declare a region they are not actually forwarding". Every field is
// already normalised (no leading '#') and '*' is never present in
// DeclaredRegions/NotObserved/UndeclaredObserved — see DeclaredWildcard and
// WildcardContradiction for its counterpart.
type ScopeAuditRow struct {
PublicKey string `json:"publicKey"`
// Name/Role are pointers so "we hold no nodes row for this target" serialises
// as null rather than "". A declared-regions row can name a repeater this
// instance has never recorded, and an empty string would make that
// indistinguishable from a node we DO know that simply has no name — the
// same absent-is-not-empty rule the declared side already follows.
Name *string `json:"name"`
Role *string `json:"role"`
DeclaredRegions []string `json:"declaredRegions"` // '*' excluded — see DeclaredWildcard
DeclaredWildcard bool `json:"declaredWildcard"` // '*' present in the raw declared list
// ConfigState is scopeAuditConfigState(DeclaredRegions, DeclaredWildcard)
// — one of ScopeConfigFull/NoScopes/NoUnscoped/NoFlood, the config-state
// reading of those same two fields so a caller does not have to
// re-derive it from them. See scopeAuditConfigState's doc comment for
// the caveat on the "no named regions" half of NoScopes/NoFlood.
ConfigState string `json:"configState"`
DeclaredAt string `json:"declaredAt"` // ISO — age of the declared answer, not the window
Truncated bool `json:"truncated"` // declared list may have had entries silently dropped
// NotObserved is declared regions with zero matched-forwarding observed
// in the window — the headline this endpoint exists to surface.
NotObserved []string `json:"notObserved"`
// UndeclaredObserved is scopes this repeater was observed forwarding
// that are absent from its declared list.
UndeclaredObserved []ScopeObservation `json:"undeclaredObserved"`
// ObservedUnscopedPackets is plain-FLOOD (no transport scope) packets
// this repeater was observed forwarding in the window — the '*'
// counterpart, per DeclaredWildcard's doc comment.
ObservedUnscopedPackets int64 `json:"observedUnscopedPackets"`
// WildcardContradiction is true when this repeater was observed
// forwarding unscoped floods but its declared list omits '*' — it says
// it does NOT forward them, and the traffic says otherwise.
WildcardContradiction bool `json:"wildcardContradiction"`
// AmbiguousHops counts forwarder-hop observations in the window whose
// truncated hash prefix matched this target's pubkey AND at least one
// other declared target's — see ScopeAuditForwarding's doc comment for
// why those hops are attributed to neither and instead counted here on
// every candidate. A non-zero value is a caveat, not a finding: any
// NotObserved entry on this row could be explained by a colliding
// neighbour's traffic rather than a real absence, and any
// UndeclaredObserved entry is unaffected by it (ambiguous hops are never
// attributed to a scope at all).
AmbiguousHops int64 `json:"ambiguousHops"`
}
// ScopeAuditResponse is the payload for GET /api/scope-audit. Only
// repeaters with at least one declared-regions answer are included — a
// repeater never successfully asked is absent, not shown declaring nothing
// (see AllCurrentDeclaredRegions).
type ScopeAuditResponse struct {
Window string `json:"window"`
Since string `json:"since"` // ISO — start of the observed-forwarding window
Repeaters []ScopeAuditRow `json:"repeaters"`
}
// NodeScopesResponse is the payload for GET /api/nodes/{pubkey}/scopes: the
// observed-forwarding side (ScopeConformance, embedded BY VALUE so its three
// scope states sit at the JSON top level — unmatched and unscoped are
// separate counts and a matched scope never has an empty name) plus the
// declared side (DeclaredRegions), returned together so the UI needs only
// one request per node rather than a second per-item call.
//
// ScopeConformance is embedded by value rather than as *ScopeConformance:
// encoding/json silently skips fields promoted through a nil embedded
// pointer instead of erroring, which would drop observed/unmatched/unscoped
// from the body entirely rather than surfacing the failure.
type scopesState struct {
cacheMu sync.RWMutex
cache map[string]scopesCacheEntry
sf singleflight.Group
// lastSeenBlacklistGen mirrors reachState's field: when the live
// blacklist generation advances past this value, the cache is purged
// wholesale on the next request. The 404 path itself is never cached
// (the handler returns before a cache key is even computed), so this
// only guards a pre-existing successful entry from outliving a
// blacklist/hide edit made after it was filled.
lastSeenBlacklistGen atomic.Uint64
}
type scopesCacheEntry struct {
at time.Time
raw []byte
}
const (
// nodeScopesCacheTTL matches the sibling /api/scope-stats endpoint's
// cache lifetime — also per-window region-scope data recomputed from
// the same transmissions table.
nodeScopesCacheTTL = 30 * time.Second
nodeScopesCacheMax = 256
)
// scopesCacheGet returns the cached marshalled JSON for key. The returned
// slice is shared (not copied) and MUST NOT be mutated by callers.
func (s *Server) scopesCacheGet(key string) ([]byte, bool) {
s.scopes.cacheMu.RLock()
defer s.scopes.cacheMu.RUnlock()
e, ok := s.scopes.cache[key]
if !ok || time.Since(e.at) > nodeScopesCacheTTL {
return nil, false
}
return e.raw, true
}
func (s *Server) scopesCachePut(key string, raw []byte) {
s.scopes.cacheMu.Lock()
defer s.scopes.cacheMu.Unlock()
if s.scopes.cache == nil {
s.scopes.cache = map[string]scopesCacheEntry{}
}
if _, exists := s.scopes.cache[key]; !exists && len(s.scopes.cache) >= nodeScopesCacheMax {
s.evictScopesLocked()
}
s.scopes.cache[key] = scopesCacheEntry{at: time.Now(), raw: raw}
}
// evictScopesLocked drops expired entries first; if still at the cap it
// evicts the single oldest entry. Caller holds s.scopes.cacheMu (write).
func (s *Server) evictScopesLocked() {
now := time.Now()
for k, e := range s.scopes.cache {
if now.Sub(e.at) > nodeScopesCacheTTL {
delete(s.scopes.cache, k)
}
}
if len(s.scopes.cache) < nodeScopesCacheMax {
return
}
var oldestKey string
var oldestAt time.Time
first := true
for k, e := range s.scopes.cache {
if first || e.at.Before(oldestAt) {
oldestKey, oldestAt, first = k, e.at, false
}
}
if !first {
delete(s.scopes.cache, oldestKey)
}
}
// scopesPurgeIfBlacklistGenChanged drops every cached entry when the live
// blacklist generation has advanced past the cache's last-seen value,
// mirroring reachPurgeIfBlacklistGenChanged. CAS gates the purge so
// concurrent callers only do the work once per gen bump.
func (s *Server) scopesPurgeIfBlacklistGenChanged(gen uint64) {
seen := s.scopes.lastSeenBlacklistGen.Load()
if gen == seen {
return
}
if !s.scopes.lastSeenBlacklistGen.CompareAndSwap(seen, gen) {
return
}
s.scopes.cacheMu.Lock()
s.scopes.cache = nil
s.scopes.cacheMu.Unlock()
}
// nodeScopesWindowLookback maps the ?window= vocabulary to a lookback
// duration. Matches the sibling /api/scope-stats endpoint's vocabulary
// exactly (1h, 24h, 7d) rather than the broader ParseTimeWindow alias set
// (which also accepts 1d/3d/1w/30d) used by unrelated analytics endpoints.
func nodeScopesWindowLookback(window string) (time.Duration, bool) {
switch window {
case "1h":
return time.Hour, true
case "24h":
return 24 * time.Hour, true
case "7d":
return 7 * 24 * time.Hour, true
default:
return 0, false
}
}
// handleNodeScopes serves GET /api/nodes/{pubkey}/scopes?window=1h|24h|7d.
//
// A pubkey never heard forwarding anything is a valid question with an
// empty answer (200), not a 404 — ScopeConformance already treats it that
// way, and this handler performs no node-existence lookup that would
// override it.
// handleScopeAudit serves GET /api/scope-audit?window=1h|24h|7d: the
// network-wide answer to "which repeaters declare a region they are not
// actually forwarding". Unlike the per-repeater /api/nodes/{pubkey}/scopes,
// this compares every repeater that has ever declared a region list in one
// pass — see scopes.go's AllCurrentDeclaredRegions and ScopeAuditForwarding
// for why that stays a single scan rather than one query per repeater.
func (s *Server) handleScopeAudit(w http.ResponseWriter, r *http.Request) {
const scopeAuditTTL = 30 * time.Second
window := r.URL.Query().Get("window")
if window == "" {
window = "24h"
}
lookback, ok := nodeScopesWindowLookback(window)
if !ok {
writeError(w, 400, "window must be 1h, 24h, or 7d")
return
}
s.scopeAuditMu.Lock()
if s.scopeAuditCache != nil {
if cached, ok := s.scopeAuditCache[window]; ok && time.Since(s.scopeAuditCachedAt[window]) < scopeAuditTTL {
s.scopeAuditMu.Unlock()
writeJSON(w, cached)
return
}
}
s.scopeAuditMu.Unlock()
declared, err := s.db.AllCurrentDeclaredRegions()
if err != nil {
writeError(w, 500, err.Error())
return
}
targets := make([]string, 0, len(declared))
for _, d := range declared {
targets = append(targets, strings.ToLower(d.Target))
}
sinceISO := time.Now().Add(-lookback).UTC().Format(time.RFC3339)
forwarding := map[string]*scopeAuditTargetAgg{}
if s.store != nil {
forwarding, err = s.store.ScopeAuditForwarding(sinceISO, targets)
if err != nil {
writeError(w, 500, err.Error())
return
}
}
identities := s.db.scopeAuditNodeIdentities(targets)
resp := &ScopeAuditResponse{Window: window, Since: sinceISO, Repeaters: []ScopeAuditRow{}}
for _, d := range declared {
pk := strings.ToLower(d.Target)
if s.cfg != nil && s.cfg.IsBlacklisted(pk) {
continue
}
id := identities[pk]
// A target with no nodes row has no name to match a hidden-prefix rule
// against; it cannot be hidden by name, so only check when we have one.
if id.Name != nil && s.cfg != nil && s.cfg.IsNameHidden(*id.Name) {
continue
}
allRegions := splitRegionsCSV(d.RegionsCSV)
declaredWildcard := false
declaredNamed := make([]string, 0, len(allRegions))
declaredSet := make(map[string]bool, len(allRegions))
for _, rgn := range allRegions {
if rgn == "*" {
declaredWildcard = true
continue
}
// normScope mirrors the observed side (scopes.go: agg.scopes keys
// are already normScope'd) — regions_csv is not guaranteed to
// arrive with '#' already stripped in every case, and comparing
// raw here would reintroduce the exact trap normScope exists to
// prevent, just on the other side of the comparison.
rgn = normScope(rgn)
declaredNamed = append(declaredNamed, rgn)
declaredSet[rgn] = true
}
agg := forwarding[pk]
notObserved := []string{}
for _, rgn := range declaredNamed {
if agg == nil || agg.scopes[rgn] == nil {
notObserved = append(notObserved, rgn)
}
}
undeclared := []ScopeObservation{}
if agg != nil {
names := make([]string, 0, len(agg.scopes))
for name := range agg.scopes {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
if !declaredSet[name] {
undeclared = append(undeclared, *agg.scopes[name])
}
}
}
var unscopedPackets, ambiguousHops int64
if agg != nil {
unscopedPackets = agg.unscopedPackets
ambiguousHops = agg.ambiguousHops
}
resp.Repeaters = append(resp.Repeaters, ScopeAuditRow{
PublicKey: pk,
Name: id.Name,
Role: id.Role,
DeclaredRegions: declaredNamed,
DeclaredWildcard: declaredWildcard,
ConfigState: scopeAuditConfigState(declaredNamed, declaredWildcard),
DeclaredAt: d.ObservedAt,
Truncated: d.Truncated,
NotObserved: notObserved,
UndeclaredObserved: undeclared,
ObservedUnscopedPackets: unscopedPackets,
WildcardContradiction: unscopedPackets > 0 && !declaredWildcard,
AmbiguousHops: ambiguousHops,
})
}
// Interesting rows first: a repeater silently not forwarding a declared
// region is the headline this endpoint exists to surface, ranked by how
// many declared regions it's missing. The wildcard contradiction and
// undeclared-observed counts are secondary tie-breaks — flags on the
// row, not a separate ranking. Full agreement (no issues at all) sorts
// to the bottom, alphabetically, so it doesn't crowd out the rows that
// matter.
sort.Slice(resp.Repeaters, func(i, j int) bool {
a, b := resp.Repeaters[i], resp.Repeaters[j]
if len(a.NotObserved) != len(b.NotObserved) {
return len(a.NotObserved) > len(b.NotObserved)
}
if a.WildcardContradiction != b.WildcardContradiction {
return a.WildcardContradiction
}
if len(a.UndeclaredObserved) != len(b.UndeclaredObserved) {
return len(a.UndeclaredObserved) > len(b.UndeclaredObserved)
}
// Fall back to the pubkey for an unnamed or unknown node so the order is
// still total and stable rather than grouping every nameless row together.
an, bn := a.PublicKey, b.PublicKey
if a.Name != nil && *a.Name != "" {
an = *a.Name
}
if b.Name != nil && *b.Name != "" {
bn = *b.Name
}
return an < bn
})
s.scopeAuditMu.Lock()
if s.scopeAuditCache == nil {
s.scopeAuditCache = make(map[string]*ScopeAuditResponse)
s.scopeAuditCachedAt = make(map[string]time.Time)
}
s.scopeAuditCache[window] = resp
s.scopeAuditCachedAt[window] = time.Now()
s.scopeAuditMu.Unlock()
writeJSON(w, resp)
}