diff --git a/cmd/server/analytics_cache_bounds.go b/cmd/server/analytics_cache_bounds.go new file mode 100644 index 000000000..cff42da4b --- /dev/null +++ b/cmd/server/analytics_cache_bounds.go @@ -0,0 +1,229 @@ +package main + +import ( + "net/http" + "time" +) + +// Fixed entry ceilings for the public analytics response caches whose keys +// incorporate caller-controlled filters or time-window variants (issue #370). +// +// TTL expiry alone does not cap how many distinct keys a cache retains inside +// one TTL window: `/api/analytics/*?region=…&area=…&from=…&to=…` lets a client +// mint an unbounded number of distinct keys (absolute from/to ranges are not a +// closed alias set like ?window=), and each miss stored one more entry. That +// is the AGENTS.md rule 0 "no unbounded data structures" surface this file +// closes, mirroring the eviction already used by channelListCache.put and +// evictReachLocked: on a new key past the ceiling, drop expired entries first, +// then — if still full — a single deterministic victim. A refresh of an +// existing key never evicts a different live entry. + +const ( + // analyticsCacheMaxEntries bounds each analytics response cache keyed by + // region/area/window. Legitimate use touches a small set of region×area× + // window combinations (the UI drives a handful); this is far above that + // yet a hard cap against a client cycling distinct absolute windows. Each + // entry holds one already-computed response map, so the bound is on count, + // not per-entry size. + analyticsCacheMaxEntries = 256 + + // subpathMaxLenCeiling clamps the ?maxLen= hop count on the subpath + // endpoints. It is part of the subpathCache key and queryInt places no + // upper bound on it, so an unclamped value is a second key-cardinality + // vector beyond region. The firmware stores the hop count in bits 0-5 of + // path_length (firmware/docs/packet_format.md, Packet::getPathHashCount = + // path_len & 63), so no path has more than 63 hops: clamping here leaves + // every result unchanged and only collapses larger values onto one key. + subpathMaxLenCeiling = 63 + + // analyticsFilterMaxLen caps a region/area/channel filter value, rejected + // before any cache/index/graph work. A normalized region set is comma- + // joined upper-case IATA codes and an area/channel key is short, so no + // legitimate selection approaches this; it bounds both the wasted compute + // and the stored key size. Mirrors reachRankMaxQueryLen's handler-side + // length guard. + analyticsFilterMaxLen = 128 + + // wardrivingStatsMaxEntries bounds wardrivingStatsCache, whose key is + // window|channel with a free-text channel. The window is a 3-value + // whitelist, so legitimate use needs only a few entries per real channel; + // this caps a client cycling distinct channel filters. + wardrivingStatsMaxEntries = 64 + + // regionObsCacheMaxEntries bounds regionObsCache (region → observer-id set). + // The whole map already resets every 30s, but within one window a client + // cycling distinct region strings accumulated entries without limit. + regionObsCacheMaxEntries = 256 + + // inspectCacheMaxEntries bounds inspectCache, keyed by caller path prefixes + // plus observer/since/until. It already drops expired entries on write; + // this adds the entry ceiling that TTL alone does not provide. + inspectCacheMaxEntries = 256 +) + +// evictCachedResultLocked makes room for a NEW key in m. It is a no-op when key +// already exists (refreshing a live entry must never evict a different one) or +// m is below the ceiling. Otherwise every expired entry is dropped; if that +// still leaves m at the ceiling, the single entry nearest expiry is evicted, +// ties broken by the smallest key so the victim is deterministic regardless of +// Go map iteration order. Caller holds the cache's write lock. +func evictCachedResultLocked(m map[string]*cachedResult, key string) { + if _, exists := m[key]; exists || len(m) < analyticsCacheMaxEntries { + return + } + now := time.Now() + for k, v := range m { + if now.After(v.expiresAt) { + delete(m, k) + } + } + if len(m) < analyticsCacheMaxEntries { + return + } + victim := "" + var soonest time.Time + first := true + for k, v := range m { + switch { + case first, v.expiresAt.Before(soonest): + victim, soonest, first = k, v.expiresAt, false + case v.expiresAt.Equal(soonest) && k < victim: + victim = k + } + } + if !first { + delete(m, victim) + } +} + +// evictStatsCacheLocked bounds a per-window stats cache that tracks expiry in a +// sibling `at` map (the routes.go pattern), mirroring evictCachedResultLocked: +// a no-op when newKey already exists (a refresh never evicts a live key) or the +// cache is below max; otherwise entries older than ttl are dropped first, then — +// if still at the ceiling — the single oldest, ties broken by the smallest key. +// Deletes from both maps together. Caller holds the cache's lock. +func evictStatsCacheLocked[T any](cache map[string]*T, at map[string]time.Time, newKey string, now time.Time, ttl time.Duration, max int) { + if _, exists := cache[newKey]; exists || len(cache) < max { + return + } + for k, t := range at { + if now.Sub(t) >= ttl { + delete(cache, k) + delete(at, k) + } + } + if len(cache) < max { + return + } + victim := "" + var oldest time.Time + first := true + for k, t := range at { + switch { + case first, t.Before(oldest): + victim, oldest, first = k, t, false + case t.Equal(oldest) && k < victim: + victim = k + } + } + if !first { + delete(cache, victim) + delete(at, victim) + } +} + +// evictInspectCacheLocked bounds inspectCache. It always drops expired entries +// (relative to now), then — only when inserting a NEW key would exceed +// inspectCacheMaxEntries — evicts the single entry nearest expiry, ties broken +// by the smallest key for a deterministic victim. Refreshing an existing key +// never evicts another entry. Caller holds inspectMu. +func evictInspectCacheLocked(m map[string]*inspectCachedResult, key string, now time.Time) { + for k, v := range m { + if now.After(v.expiresAt) { + delete(m, k) + } + } + if _, exists := m[key]; exists || len(m) < inspectCacheMaxEntries { + return + } + victim := "" + var soonest time.Time + first := true + for k, v := range m { + switch { + case first, v.expiresAt.Before(soonest): + victim, soonest, first = k, v.expiresAt, false + case v.expiresAt.Equal(soonest) && k < victim: + victim = k + } + } + if !first { + delete(m, victim) + } +} + +// evictRegionObsLocked bounds regionObsCache for a new region key. The cache +// has no per-entry timestamp (the whole map shares regionObsCacheTime and is +// reset wholesale every 30s), so the deterministic victim is the smallest key. +// A no-op when newKey already exists or the map is below the ceiling. Caller +// holds regionObsMu. +func (s *PacketStore) evictRegionObsLocked(newKey string) { + if _, exists := s.regionObsCache[newKey]; exists || len(s.regionObsCache) < regionObsCacheMaxEntries { + return + } + victim := "" + first := true + for k := range s.regionObsCache { + if first || k < victim { + victim, first = k, false + } + } + if !first { + delete(s.regionObsCache, victim) + } +} + +// putCachedResultLocked stores entry under key in m, enforcing +// analyticsCacheMaxEntries. Caller holds the cache's write lock (s.cacheMu for +// the store.go analytics caches). +func putCachedResultLocked(m map[string]*cachedResult, key string, entry *cachedResult) { + evictCachedResultLocked(m, key) + m[key] = entry +} + +// analyticsFilterError validates the region/area query parameters shared by the +// analytics handlers before any cache/index/graph work. It returns a non-empty +// 400 message when a filter is too long or names an area that is not a +// configured area; "" means the selection is legitimate (including the empty +// region/area that means "global"). areas is s.cfg.Areas (nil when none are +// configured, in which case any non-empty area is unknown). +func analyticsFilterError(region, area string, areas map[string]AreaEntry) string { + if len(region) > analyticsFilterMaxLen { + return "region filter too long" + } + if len(area) > analyticsFilterMaxLen { + return "area filter too long" + } + if area != "" { + if _, ok := areas[area]; !ok { + return "unknown area" + } + } + return "" +} + +// rejectAnalyticsFilters writes a 400 and returns true when the region/area +// query parameters are invalid, so the analytics handlers can bail out before +// any cache/index/graph work. It returns false (and writes nothing) for a +// legitimate selection. +func (s *Server) rejectAnalyticsFilters(w http.ResponseWriter, region, area string) bool { + var areas map[string]AreaEntry + if s.cfg != nil { + areas = s.cfg.Areas + } + if msg := analyticsFilterError(region, area, areas); msg != "" { + writeError(w, 400, msg) + return true + } + return false +} diff --git a/cmd/server/analytics_cache_bounds_test.go b/cmd/server/analytics_cache_bounds_test.go new file mode 100644 index 000000000..df42687e3 --- /dev/null +++ b/cmd/server/analytics_cache_bounds_test.go @@ -0,0 +1,468 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" +) + +// TestPutCachedResultLocked_BoundsEntries proves the ceiling: inserting far +// more distinct keys than analyticsCacheMaxEntries leaves the map at exactly +// the ceiling, never above it. +func TestPutCachedResultLocked_BoundsEntries(t *testing.T) { + m := make(map[string]*cachedResult) + const n = analyticsCacheMaxEntries * 4 + for i := 0; i < n; i++ { + putCachedResultLocked(m, fmt.Sprintf("k%06d", i), &cachedResult{data: map[string]interface{}{"i": i}, expiresAt: time.Now().Add(time.Minute)}) + if len(m) > analyticsCacheMaxEntries { + t.Fatalf("after %d inserts len=%d exceeds ceiling %d", i+1, len(m), analyticsCacheMaxEntries) + } + } + if len(m) != analyticsCacheMaxEntries { + t.Fatalf("len=%d want ceiling %d", len(m), analyticsCacheMaxEntries) + } +} + +// TestPutCachedResultLocked_RefreshDoesNotEvict proves a refresh of an existing +// key never evicts a different live key, even at the ceiling. +func TestPutCachedResultLocked_RefreshDoesNotEvict(t *testing.T) { + m := make(map[string]*cachedResult) + for i := 0; i < analyticsCacheMaxEntries; i++ { + putCachedResultLocked(m, fmt.Sprintf("k%06d", i), &cachedResult{data: map[string]interface{}{"i": i}, expiresAt: time.Now().Add(time.Minute)}) + } + // All entries live; map is exactly full. + if len(m) != analyticsCacheMaxEntries { + t.Fatalf("precondition len=%d want %d", len(m), analyticsCacheMaxEntries) + } + // Snapshot the keys that must survive a refresh. + before := make(map[string]bool, len(m)) + for k := range m { + before[k] = true + } + // Refresh an existing key many times. No other live key may disappear. + for r := 0; r < 100; r++ { + putCachedResultLocked(m, "k000000", &cachedResult{data: map[string]interface{}{"r": r}, expiresAt: time.Now().Add(time.Minute)}) + if len(m) != analyticsCacheMaxEntries { + t.Fatalf("refresh %d changed size to %d (want %d) — refresh evicted a live key", r, len(m), analyticsCacheMaxEntries) + } + } + for k := range before { + if _, ok := m[k]; !ok { + t.Fatalf("refresh evicted live key %q", k) + } + } +} + +// TestEvictCachedResultLocked_ExpiredFirst proves expired entries are dropped +// before any live entry: a map full of expired entries plus a new key must not +// evict the newest live entry, and the stale ones go. +func TestEvictCachedResultLocked_ExpiredFirst(t *testing.T) { + m := make(map[string]*cachedResult) + past := time.Now().Add(-time.Hour) + for i := 0; i < analyticsCacheMaxEntries; i++ { + m[fmt.Sprintf("old%06d", i)] = &cachedResult{data: map[string]interface{}{}, expiresAt: past} + } + putCachedResultLocked(m, "fresh", &cachedResult{data: map[string]interface{}{"x": 1}, expiresAt: time.Now().Add(time.Minute)}) + if _, ok := m["fresh"]; !ok { + t.Fatal("fresh key was not stored") + } + if len(m) != 1 { + t.Fatalf("expected expired sweep to leave only the fresh key, len=%d", len(m)) + } +} + +// TestEvictCachedResultLocked_DeterministicVictim proves the victim at the +// ceiling is the entry nearest expiry, ties broken by smallest key, regardless +// of map iteration order. +func TestEvictCachedResultLocked_DeterministicVictim(t *testing.T) { + base := time.Now().Add(time.Hour) + for trial := 0; trial < 20; trial++ { + m := make(map[string]*cachedResult) + // Fill to the ceiling with entries whose expiry increases with the key, + // so the smallest key is the soonest to expire = the victim. + for i := 0; i < analyticsCacheMaxEntries; i++ { + m[fmt.Sprintf("k%06d", i)] = &cachedResult{ + data: map[string]interface{}{}, + expiresAt: base.Add(time.Duration(i) * time.Second), + } + } + putCachedResultLocked(m, "zzz-new", &cachedResult{data: map[string]interface{}{}, expiresAt: time.Now().Add(time.Hour)}) + if _, ok := m["k000000"]; ok { + t.Fatalf("trial %d: soonest-expiry entry k000000 should have been evicted", trial) + } + if _, ok := m["zzz-new"]; !ok { + t.Fatalf("trial %d: new key not stored", trial) + } + if len(m) != analyticsCacheMaxEntries { + t.Fatalf("trial %d: len=%d want %d", trial, len(m), analyticsCacheMaxEntries) + } + } +} + +// TestEvictCachedResultLocked_TieBreakSmallestKey proves that among entries +// sharing the soonest expiry, the lexicographically smallest key is evicted. +func TestEvictCachedResultLocked_TieBreakSmallestKey(t *testing.T) { + soon := time.Now().Add(time.Minute) + later := time.Now().Add(time.Hour) + for trial := 0; trial < 20; trial++ { + m := make(map[string]*cachedResult) + // Two entries tie for soonest expiry: "aaa" and "bbb". "aaa" must go. + m["aaa"] = &cachedResult{data: map[string]interface{}{}, expiresAt: soon} + m["bbb"] = &cachedResult{data: map[string]interface{}{}, expiresAt: soon} + for i := 0; i < analyticsCacheMaxEntries-2; i++ { + m[fmt.Sprintf("c%06d", i)] = &cachedResult{data: map[string]interface{}{}, expiresAt: later} + } + putCachedResultLocked(m, "new", &cachedResult{data: map[string]interface{}{}, expiresAt: time.Now().Add(time.Hour)}) + if _, ok := m["aaa"]; ok { + t.Fatalf("trial %d: tie should evict smallest key 'aaa'", trial) + } + if _, ok := m["bbb"]; !ok { + t.Fatalf("trial %d: 'bbb' should have survived the tie", trial) + } + } +} + +// TestAnalyticsFilterError covers the handler-side rejection of overlong +// filters and unknown areas, and that legitimate selections pass. +func TestAnalyticsFilterError(t *testing.T) { + areas := map[string]AreaEntry{"sjaelland": {}, "fyn": {}} + long := make([]byte, analyticsFilterMaxLen+1) + for i := range long { + long[i] = 'A' + } + ok := string(make([]byte, analyticsFilterMaxLen)) + cases := []struct { + name string + region, area string + wantNonEmpty bool + wantSubstring string + }{ + {"empty global", "", "", false, ""}, + {"valid region", "EDI,GLA", "", false, ""}, + {"valid area", "EDI", "fyn", false, ""}, + {"region at limit", ok, "", false, ""}, + {"overlong region", string(long), "", true, "region"}, + {"overlong area", "EDI", string(long), true, "area"}, + {"unknown area", "EDI", "atlantis", true, "unknown area"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := analyticsFilterError(c.region, c.area, areas) + if c.wantNonEmpty && got == "" { + t.Fatalf("expected a rejection message, got none") + } + if !c.wantNonEmpty && got != "" { + t.Fatalf("expected no rejection, got %q", got) + } + if !strings.Contains(got, c.wantSubstring) { + t.Fatalf("message %q does not mention %q", got, c.wantSubstring) + } + }) + } +} + +// TestAnalyticsFilterError_NilAreasRejectsArea proves that with no configured +// areas, any non-empty area is unknown (and empty area still passes). +func TestAnalyticsFilterError_NilAreasRejectsArea(t *testing.T) { + if msg := analyticsFilterError("EDI", "", nil); msg != "" { + t.Fatalf("empty area with nil areas should pass, got %q", msg) + } + if msg := analyticsFilterError("EDI", "fyn", nil); msg == "" { + t.Fatal("non-empty area with nil areas should be rejected") + } +} + +// BenchmarkPutCachedResultLocked_FullMapMiss measures the worst case: a cache +// pinned at the ceiling with all-live entries, where every insert is a new key +// and so pays the deterministic single-victim scan (O(ceiling), bounded). This +// is the per-request cost the eviction adds on a cold miss. +func BenchmarkPutCachedResultLocked_FullMapMiss(b *testing.B) { + m := make(map[string]*cachedResult, analyticsCacheMaxEntries) + for i := 0; i < analyticsCacheMaxEntries; i++ { + m[fmt.Sprintf("seed%06d", i)] = &cachedResult{data: map[string]interface{}{}, expiresAt: time.Now().Add(time.Hour)} + } + res := map[string]interface{}{"x": 1} + b.ResetTimer() + for i := 0; i < b.N; i++ { + putCachedResultLocked(m, fmt.Sprintf("new%08d", i), &cachedResult{data: res, expiresAt: time.Now().Add(time.Hour)}) + } +} + +// BenchmarkPutCachedResultLocked_Refresh measures the common refresh path (key +// already present): the guard returns before any scan, so it must match a +// plain map assignment. +func BenchmarkPutCachedResultLocked_Refresh(b *testing.B) { + m := make(map[string]*cachedResult, analyticsCacheMaxEntries) + for i := 0; i < analyticsCacheMaxEntries; i++ { + m[fmt.Sprintf("seed%06d", i)] = &cachedResult{data: map[string]interface{}{}, expiresAt: time.Now().Add(time.Hour)} + } + res := map[string]interface{}{"x": 1} + b.ResetTimer() + for i := 0; i < b.N; i++ { + putCachedResultLocked(m, "seed000000", &cachedResult{data: res, expiresAt: time.Now().Add(time.Hour)}) + } +} + +// BenchmarkPlainMapAssign_Baseline is the pre-#370 unbounded write, for a delta +// against the eviction cost above. +func BenchmarkPlainMapAssign_Baseline(b *testing.B) { + m := make(map[string]*cachedResult, analyticsCacheMaxEntries) + res := map[string]interface{}{"x": 1} + b.ResetTimer() + for i := 0; i < b.N; i++ { + m[fmt.Sprintf("new%08d", i)] = &cachedResult{data: res, expiresAt: time.Now().Add(time.Hour)} + } +} + +// TestEvictStatsCacheLocked_Bounds covers the routes.go per-window stats cache +// evictor (wardrivingStatsCache): bounded size, refresh does not evict, expired +// entries go first, and the deterministic smallest-key victim on a tie. +func TestEvictStatsCacheLocked_Bounds(t *testing.T) { + const ttl = 30 * time.Second + max := wardrivingStatsMaxEntries + cache := map[string]*int{} + at := map[string]time.Time{} + now := time.Now() + + // Bounded: inserting far more than max distinct keys never exceeds max. + for i := 0; i < max*3; i++ { + k := fmt.Sprintf("k%06d", i) + evictStatsCacheLocked(cache, at, k, now, ttl, max) + v := i + cache[k] = &v + at[k] = now + if len(cache) > max { + t.Fatalf("insert %d: len=%d exceeds ceiling %d", i, len(cache), max) + } + } + if len(cache) != max { + t.Fatalf("len=%d want %d", len(cache), max) + } + + // Refresh an existing key: no eviction, size unchanged. + anyKey := "" + for k := range cache { + anyKey = k + break + } + evictStatsCacheLocked(cache, at, anyKey, now, ttl, max) + if len(cache) != max { + t.Fatalf("refresh changed size to %d", len(cache)) + } + + // Expired-first: a full cache of stale entries plus a new key collapses to + // just the survivors of the expiry sweep. + cache = map[string]*int{} + at = map[string]time.Time{} + stale := now.Add(-ttl - time.Hour) + for i := 0; i < max; i++ { + k := fmt.Sprintf("old%06d", i) + v := i + cache[k] = &v + at[k] = stale + } + evictStatsCacheLocked(cache, at, "fresh", now, ttl, max) + if len(cache) != 0 { + t.Fatalf("expired sweep should have emptied the cache, len=%d", len(cache)) + } +} + +// TestEvictInspectCacheLocked_Bounds covers the path-inspect cache evictor: +// expired-first, bounded size, refresh-no-evict, deterministic victim. +func TestEvictInspectCacheLocked_Bounds(t *testing.T) { + now := time.Now() + m := map[string]*inspectCachedResult{} + // Fill with entries whose expiry increases with the key, all live. + for i := 0; i < inspectCacheMaxEntries; i++ { + m[fmt.Sprintf("k%06d", i)] = &inspectCachedResult{expiresAt: now.Add(time.Duration(i+1) * time.Minute)} + } + // New key at the cap: the helper makes room (the handler then inserts), so + // the soonest-expiry entry (smallest key here) is evicted and the insert + // lands at exactly the ceiling. + evictInspectCacheLocked(m, "znew", now) + m["znew"] = &inspectCachedResult{expiresAt: now.Add(time.Hour)} + if _, ok := m["k000000"]; ok { + t.Fatal("soonest-expiry entry should have been evicted") + } + if len(m) != inspectCacheMaxEntries { + t.Fatalf("len=%d want %d", len(m), inspectCacheMaxEntries) + } + // Refresh of an existing key never evicts (and never grows the map). + before := len(m) + evictInspectCacheLocked(m, "znew", now) + if len(m) != before { + t.Fatalf("refresh changed size %d -> %d", before, len(m)) + } + // Expired entries are always dropped, even below the cap. + m2 := map[string]*inspectCachedResult{ + "a": {expiresAt: now.Add(-time.Hour)}, + "b": {expiresAt: now.Add(time.Hour)}, + } + evictInspectCacheLocked(m2, "c", now) + if _, ok := m2["a"]; ok { + t.Fatal("expired entry 'a' should have been dropped") + } + if _, ok := m2["b"]; !ok { + t.Fatal("live entry 'b' should remain") + } +} + +// TestEvictRegionObsLocked_Bounds covers the region→observer-id cache bound: +// bounded size and the deterministic smallest-key victim. +func TestEvictRegionObsLocked_Bounds(t *testing.T) { + s := NewPacketStore(nil, nil) + s.regionObsCache = map[string]map[string]bool{} + for i := 0; i < regionObsCacheMaxEntries*3; i++ { + k := fmt.Sprintf("R%06d", i) + s.evictRegionObsLocked(k) + s.regionObsCache[k] = map[string]bool{} + if len(s.regionObsCache) > regionObsCacheMaxEntries { + t.Fatalf("insert %d: len=%d exceeds ceiling %d", i, len(s.regionObsCache), regionObsCacheMaxEntries) + } + } + if len(s.regionObsCache) != regionObsCacheMaxEntries { + t.Fatalf("len=%d want %d", len(s.regionObsCache), regionObsCacheMaxEntries) + } + // The smallest keys were evicted first; the earliest key must be gone. + if _, ok := s.regionObsCache["R000000"]; ok { + t.Fatal("earliest region key should have been evicted") + } +} + +// TestHandleAnalyticsRF_FilterRejection proves the handler rejects overlong +// filters and unknown areas with 400 before any store work, while legitimate +// region/area selections pass (store is nil, so a passing request returns the +// empty 200 response — the point is only that it is NOT a 400). +func TestHandleAnalyticsRF_FilterRejection(t *testing.T) { + srv := &Server{cfg: &Config{Areas: map[string]AreaEntry{"fyn": {}}}} + long := strings.Repeat("A", analyticsFilterMaxLen+1) + call := func(q string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/analytics/rf?"+q, nil) + w := httptest.NewRecorder() + srv.handleAnalyticsRF(w, req) + return w + } + reject := []struct{ name, query string }{ + {"overlong region", "region=" + url.QueryEscape(long)}, + {"overlong area", "area=" + url.QueryEscape(long)}, + {"unknown area", "area=atlantis"}, + } + for _, c := range reject { + if w := call(c.query); w.Code != 400 { + t.Errorf("%s: want 400, got %d", c.name, w.Code) + } + } + pass := []struct{ name, query string }{ + {"global", ""}, + {"valid region", "region=EDI,GLA"}, + {"valid area", "area=fyn"}, + } + for _, c := range pass { + if w := call(c.query); w.Code == 400 { + t.Errorf("%s: should not be rejected, got 400", c.name) + } + } +} + +// TestHandleAnalyticsSubpathsBulk_RegionRejection proves the region-only +// handlers reject an overlong region before work and accept a legitimate one. +func TestHandleAnalyticsSubpathsBulk_RegionRejection(t *testing.T) { + srv := &Server{cfg: &Config{}} + long := strings.Repeat("A", analyticsFilterMaxLen+1) + call := func(q string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/analytics/subpaths-bulk?"+q, nil) + w := httptest.NewRecorder() + srv.handleAnalyticsSubpathsBulk(w, req) + return w + } + if w := call("region=" + url.QueryEscape(long)); w.Code != 400 { + t.Errorf("overlong region: want 400, got %d", w.Code) + } + // A legitimate region passes validation (missing groups then yields a + // non-400 groups-required response). + if w := call("region=EDI"); w.Code == 400 { + t.Errorf("valid region should not be rejected, got 400") + } +} + +// TestHandleWardrivingStats_RejectsOverlongChannel proves the free-text channel +// filter is length-rejected before GetWardrivingStats, and a normal channel +// still works end-to-end against a real store. +func TestHandleWardrivingStats_RejectsOverlongChannel(t *testing.T) { + _, router := setupTestServer(t) + long := strings.Repeat("x", analyticsFilterMaxLen+1) + + req := httptest.NewRequest(http.MethodGet, "/api/analytics/wardriving?window=24h&channel="+url.QueryEscape(long), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 400 { + t.Fatalf("overlong channel: want 400, got %d", w.Code) + } + var body map[string]interface{} + _ = json.Unmarshal(w.Body.Bytes(), &body) + if msg, _ := body["error"].(string); !strings.Contains(msg, "channel") { + t.Fatalf("expected channel-length error, got %q", msg) + } + + req2 := httptest.NewRequest(http.MethodGet, "/api/analytics/wardriving?window=24h&channel="+url.QueryEscape("#wardriving"), nil) + w2 := httptest.NewRecorder() + router.ServeHTTP(w2, req2) + if w2.Code == 400 { + t.Fatalf("normal channel should not be rejected, got 400") + } +} + +// TestGetAnalyticsRF_CacheBounded is the wiring proof: driving the real cached +// wrapper with far more distinct absolute time-windows than the ceiling must +// leave rfCache bounded. Red before the write site goes through the helper. +func TestGetAnalyticsRF_CacheBounded(t *testing.T) { + s := NewPacketStore(nil, nil) + base := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + const n = analyticsCacheMaxEntries * 3 + for i := 0; i < n; i++ { + // Distinct absolute windows => distinct cache keys, region/area empty so + // no DB work is needed. + w := TimeWindow{Since: base.Add(time.Duration(i) * time.Minute).Format(time.RFC3339)} + s.GetAnalyticsRFWithWindow("", "", w) + } + s.cacheMu.RLock() + got := len(s.rfCache) + s.cacheMu.RUnlock() + if got > analyticsCacheMaxEntries { + t.Fatalf("rfCache grew to %d entries, exceeding ceiling %d", got, analyticsCacheMaxEntries) + } +} + +// TestGetAnalyticsRF_ConcurrentBounded drives the real cached wrapper from many +// goroutines with overlapping distinct windows. Run under -race it proves the +// bounded insertion adds no data race on cacheMu, and the map stays bounded. +func TestGetAnalyticsRF_ConcurrentBounded(t *testing.T) { + s := NewPacketStore(nil, nil) + base := time.Date(2021, 6, 1, 0, 0, 0, 0, time.UTC) + const workers = 16 + const perWorker = analyticsCacheMaxEntries * 2 + var wg sync.WaitGroup + for g := 0; g < workers; g++ { + wg.Add(1) + go func(g int) { + defer wg.Done() + for i := 0; i < perWorker; i++ { + w := TimeWindow{Since: base.Add(time.Duration((g*perWorker + i)) * time.Minute).Format(time.RFC3339)} + s.GetAnalyticsRFWithWindow("", "", w) + } + }(g) + } + wg.Wait() + s.cacheMu.RLock() + got := len(s.rfCache) + s.cacheMu.RUnlock() + if got > analyticsCacheMaxEntries { + t.Fatalf("rfCache grew to %d entries under concurrency, exceeding ceiling %d", got, analyticsCacheMaxEntries) + } +} diff --git a/cmd/server/analytics_cache_wiring_test.go b/cmd/server/analytics_cache_wiring_test.go new file mode 100644 index 000000000..be73c5fc2 --- /dev/null +++ b/cmd/server/analytics_cache_wiring_test.go @@ -0,0 +1,412 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "sort" + "strings" + "testing" + "time" +) + +// Wiring tests for #370: every write site of a bounded cache is driven through +// its real entry point (Get… wrapper or HTTP handler) with more distinct keys +// than the ceiling. Each asserts the ceiling holds and that re-filling an +// existing key at the ceiling evicts no other live key. The helper unit tests +// in analytics_cache_bounds_test.go cannot catch a call site reverted to a +// plain map assignment; these do. + +// cachedResultFamily is one write site of a map[string]*cachedResult cache. +type cachedResultFamily struct { + name string + cache func(s *PacketStore) map[string]*cachedResult + // call drives the real wrapper with the i-th distinct key. Keys must sort + // in insertion order so the newest key is never the smallest. + call func(s *PacketStore, i int) +} + +func wiringWindow(i int) TimeWindow { + base := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + return TimeWindow{Since: base.Add(time.Duration(i) * time.Minute).Format(time.RFC3339)} +} + +// wiringArea is a distinct, unconfigured area key: resolveAreaNodes returns +// nil for it, so the cache key is distinct while the compute stays cheap. +func wiringArea(i int) string { return fmt.Sprintf("a%06d", i) } + +// newWiringStore is the seeded test store, so every compute path runs for +// real; region stays empty so no key needs a configured region. +func newWiringStore(t *testing.T) *PacketStore { + srv, _ := setupTestServer(t) + return srv.store +} + +func cachedResultFamilies() []cachedResultFamily { + return []cachedResultFamily{ + {"rf", func(s *PacketStore) map[string]*cachedResult { return s.rfCache }, + func(s *PacketStore, i int) { s.GetAnalyticsRFWithWindow("", "", wiringWindow(i)) }}, + {"relay-airtime-share", func(s *PacketStore) map[string]*cachedResult { return s.rfCache }, + func(s *PacketStore, i int) { s.GetRelayAirtimeShareWithWindow(wiringWindow(i)) }}, + {"topology", func(s *PacketStore) map[string]*cachedResult { return s.topoCache }, + func(s *PacketStore, i int) { s.GetAnalyticsTopologyWithWindow("", "", wiringWindow(i)) }}, + {"channels", func(s *PacketStore) map[string]*cachedResult { return s.chanCache }, + func(s *PacketStore, i int) { s.GetAnalyticsChannelsWithWindow("", "", wiringWindow(i)) }}, + {"distance", func(s *PacketStore) map[string]*cachedResult { return s.distCache }, + func(s *PacketStore, i int) { s.GetAnalyticsDistance("", wiringArea(i)) }}, + {"hash-sizes", func(s *PacketStore) map[string]*cachedResult { return s.hashCache }, + func(s *PacketStore, i int) { s.GetAnalyticsHashSizes("", wiringArea(i)) }}, + {"hash-collisions", func(s *PacketStore) map[string]*cachedResult { return s.collisionCache }, + func(s *PacketStore, i int) { s.GetAnalyticsHashCollisions("", wiringArea(i)) }}, + {"subpaths", func(s *PacketStore) map[string]*cachedResult { return s.subpathCache }, + func(s *PacketStore, i int) { s.GetAnalyticsSubpaths("", 2, 2, 1000+i) }}, + {"subpaths-window", func(s *PacketStore) map[string]*cachedResult { return s.subpathCache }, + func(s *PacketStore, i int) { s.GetAnalyticsSubpathsWithWindow("", 2, 2, 10, wiringWindow(i)) }}, + {"subpaths-bulk", func(s *PacketStore) map[string]*cachedResult { return s.subpathCache }, + func(s *PacketStore, i int) { + s.GetAnalyticsSubpathsBulk("", []subpathGroup{{MinLen: 2, MaxLen: 2, Limit: 1000 + i}}) + }}, + } +} + +func TestAnalyticsCacheWiring_EachFamilyBounded(t *testing.T) { + const n = analyticsCacheMaxEntries + 32 + for _, f := range cachedResultFamilies() { + f := f + t.Run(f.name, func(t *testing.T) { + s := newWiringStore(t) + for i := 0; i < n; i++ { + f.call(s, i) + s.cacheMu.Lock() + got := len(f.cache(s)) + s.cacheMu.Unlock() + if got > analyticsCacheMaxEntries { + t.Fatalf("after %d distinct keys len=%d exceeds ceiling %d", i+1, got, analyticsCacheMaxEntries) + } + } + s.cacheMu.Lock() + m := f.cache(s) + if len(m) != analyticsCacheMaxEntries { + s.cacheMu.Unlock() + t.Fatalf("len=%d want exactly the ceiling %d (keys not distinct?)", len(m), analyticsCacheMaxEntries) + } + // Re-fill the newest key: expire it so the wrapper misses and + // writes it again at a full cache. No other live key may go. + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + newest := keys[len(keys)-1] + m[newest].expiresAt = time.Now().Add(-time.Second) + s.cacheMu.Unlock() + + f.call(s, n-1) + + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + m = f.cache(s) + for _, k := range keys { + if _, ok := m[k]; !ok { + t.Fatalf("refreshing %q evicted live key %q", newest, k) + } + } + if e := m[newest]; e == nil || !time.Now().Before(e.expiresAt) { + t.Fatalf("refreshed key %q not re-stored live", newest) + } + if len(m) != analyticsCacheMaxEntries { + t.Fatalf("len=%d after refresh, want %d", len(m), analyticsCacheMaxEntries) + } + }) + } +} + +func TestWardrivingStatsCacheWiring_Bounded(t *testing.T) { + srv, router := setupTestServer(t) + get := func(i int) { + req := httptest.NewRequest(http.MethodGet, "/api/analytics/wardriving?window=24h&channel="+url.QueryEscape(fmt.Sprintf("#c%06d", i)), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("channel %d: status %d: %s", i, w.Code, w.Body.String()) + } + } + const n = wardrivingStatsMaxEntries + 16 + for i := 0; i < n; i++ { + get(i) + srv.wardrivingStatsMu.Lock() + got := len(srv.wardrivingStatsCache) + srv.wardrivingStatsMu.Unlock() + if got > wardrivingStatsMaxEntries { + t.Fatalf("after %d channels len=%d exceeds ceiling %d", i+1, got, wardrivingStatsMaxEntries) + } + } + srv.wardrivingStatsMu.Lock() + if len(srv.wardrivingStatsCache) != wardrivingStatsMaxEntries { + srv.wardrivingStatsMu.Unlock() + t.Fatalf("len=%d want %d", len(srv.wardrivingStatsCache), wardrivingStatsMaxEntries) + } + keys := make([]string, 0, wardrivingStatsMaxEntries) + for k := range srv.wardrivingStatsCache { + keys = append(keys, k) + } + sort.Strings(keys) + newest := keys[len(keys)-1] + srv.wardrivingStatsCachedAt[newest] = time.Now().Add(-time.Hour) // older than the handler's 30s TTL + srv.wardrivingStatsMu.Unlock() + + get(n - 1) + + srv.wardrivingStatsMu.Lock() + defer srv.wardrivingStatsMu.Unlock() + for _, k := range keys { + if _, ok := srv.wardrivingStatsCache[k]; !ok { + t.Fatalf("refreshing %q evicted live key %q", newest, k) + } + } + if len(srv.wardrivingStatsCache) != wardrivingStatsMaxEntries { + t.Fatalf("len=%d after refresh, want %d", len(srv.wardrivingStatsCache), wardrivingStatsMaxEntries) + } +} + +func TestPathInspectCacheWiring_Bounded(t *testing.T) { + srv := newTestServerForInspect(t) + srv.store.nodeCache = []nodeInfo{{PublicKey: "aabb1234", Name: "A", Role: "repeater"}} + srv.store.nodePM = buildPrefixMap(srv.store.nodeCache) + srv.store.nodeCacheTime = time.Now() + post := func(i int) { + body := fmt.Sprintf(`{"prefixes":["aa"],"context":{"observerId":"o%06d"}}`, i) + if rr := doInspectRequest(srv, body); rr.Code != 200 { + t.Fatalf("request %d: status %d: %s", i, rr.Code, rr.Body.String()) + } + } + const n = inspectCacheMaxEntries + 32 + for i := 0; i < n; i++ { + post(i) + srv.store.inspectMu.RLock() + got := len(srv.store.inspectCache) + srv.store.inspectMu.RUnlock() + if got > inspectCacheMaxEntries { + t.Fatalf("after %d keys len=%d exceeds ceiling %d", i+1, got, inspectCacheMaxEntries) + } + } + srv.store.inspectMu.Lock() + if len(srv.store.inspectCache) != inspectCacheMaxEntries { + srv.store.inspectMu.Unlock() + t.Fatalf("len=%d want %d", len(srv.store.inspectCache), inspectCacheMaxEntries) + } + keys := make([]string, 0, inspectCacheMaxEntries) + for k := range srv.store.inspectCache { + keys = append(keys, k) + } + sort.Strings(keys) + newest := keys[len(keys)-1] + srv.store.inspectCache[newest].expiresAt = time.Now().Add(-time.Second) + srv.store.inspectMu.Unlock() + + post(n - 1) + + srv.store.inspectMu.RLock() + defer srv.store.inspectMu.RUnlock() + for _, k := range keys { + if _, ok := srv.store.inspectCache[k]; !ok { + t.Fatalf("refreshing %q evicted live key %q", newest, k) + } + } +} + +func TestRegionObsCacheWiring_Bounded(t *testing.T) { + srv, _ := setupTestServer(t) + s := srv.store + const n = regionObsCacheMaxEntries + 32 + for i := 0; i < n; i++ { + s.resolveRegionObservers(fmt.Sprintf("R%06d", i)) + s.regionObsMu.Lock() + got := len(s.regionObsCache) + s.regionObsMu.Unlock() + if got > regionObsCacheMaxEntries { + t.Fatalf("after %d regions len=%d exceeds ceiling %d", i+1, got, regionObsCacheMaxEntries) + } + } + s.regionObsMu.Lock() + got := len(s.regionObsCache) + keys := make([]string, 0, got) + for k := range s.regionObsCache { + keys = append(keys, k) + } + s.regionObsMu.Unlock() + if got != regionObsCacheMaxEntries { + t.Fatalf("len=%d want %d", got, regionObsCacheMaxEntries) + } + // A repeat lookup of a present region is served from the map and never + // evicts another key. + sort.Strings(keys) + s.resolveRegionObservers(keys[len(keys)-1]) + s.regionObsMu.Lock() + defer s.regionObsMu.Unlock() + for _, k := range keys { + if _, ok := s.regionObsCache[k]; !ok { + t.Fatalf("repeat lookup evicted live key %q", k) + } + } +} + +// analyticsWorkProbe snapshots every observable side effect of the expensive +// analytics paths: analytics cache hits/misses (every Get… wrapper bumps one), +// region observer resolution, the lazy distance-index build, the wardriving +// stats query and the neighbor-graph compute. A rejected request must leave +// it unchanged; the positive controls prove each handler moves it. +type analyticsWorkProbe struct { + hits, misses int64 + regionObs int + distTriggered bool + wardriving int + neighborGraphs int +} + +func probeAnalyticsWork(srv *Server, neighborCalls *int) analyticsWorkProbe { + s := srv.store + var p analyticsWorkProbe + s.cacheMu.Lock() + p.hits, p.misses = s.cacheHits, s.cacheMisses + s.cacheMu.Unlock() + s.regionObsMu.Lock() + p.regionObs = len(s.regionObsCache) + s.regionObsMu.Unlock() + s.distLazyMu.Lock() + p.distTriggered = s.distLazyBuilding || s.distLazyBuilt + s.distLazyMu.Unlock() + srv.wardrivingStatsMu.Lock() + p.wardriving = len(srv.wardrivingStatsCache) + srv.wardrivingStatsMu.Unlock() + p.neighborGraphs = *neighborCalls + return p +} + +func TestAnalyticsHandlers_RejectBeforeExpensiveWork(t *testing.T) { + long := url.QueryEscape(strings.Repeat("A", analyticsFilterMaxLen+1)) + regionArea := []string{"region=" + long, "area=" + long, "area=atlantis"} + regionOnly := []string{"region=" + long} + cases := []struct { + name string + path string + reject []string + valid string // positive control: a legitimate request that does work + }{ + {"rf", "/api/analytics/rf", regionArea, "region=SJC"}, + {"topology", "/api/analytics/topology", regionArea, "region=SJC"}, + {"channels", "/api/analytics/channels", regionArea, "region=SJC"}, + {"distance", "/api/analytics/distance", regionArea, "region=SJC"}, + {"hash-sizes", "/api/analytics/hash-sizes", regionArea, "region=SJC"}, + {"hash-collisions", "/api/analytics/hash-collisions", regionArea, "region=SJC"}, + {"subpaths", "/api/analytics/subpaths", regionOnly, "region=SJC"}, + {"subpaths-bulk", "/api/analytics/subpaths-bulk", []string{"region=" + long + "&groups=2-2:5"}, "region=SJC&groups=2-2:5"}, + {"wardriving", "/api/analytics/wardriving", []string{"window=24h&channel=" + url.QueryEscape(strings.Repeat("x", analyticsFilterMaxLen+1))}, "window=24h&channel=%23wardriving"}, + {"neighbor-graph", "/api/analytics/neighbor-graph", regionOnly, "region=SJC"}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + srv, router := setupTestServer(t) + srv.cfg.Areas = map[string]AreaEntry{"fyn": {}} + neighborCalls := 0 + srv.computeNeighborGraphResponseFn = func(minCount int, minScore float64, region, role string) NeighborGraphResponse { + neighborCalls++ + return NeighborGraphResponse{} + } + do := func(q string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, c.path+"?"+q, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w + } + for _, q := range c.reject { + before := probeAnalyticsWork(srv, &neighborCalls) + w := do(q) + if w.Code != 400 { + t.Fatalf("%.40s…: want 400, got %d", q, w.Code) + } + if after := probeAnalyticsWork(srv, &neighborCalls); after != before { + t.Fatalf("%.40s…: rejected request still did work: before %+v after %+v", q, before, after) + } + } + before := probeAnalyticsWork(srv, &neighborCalls) + if w := do(c.valid); w.Code == 400 { + t.Fatalf("valid %q rejected: %s", c.valid, w.Body.String()) + } + if after := probeAnalyticsWork(srv, &neighborCalls); after == before { + t.Fatalf("positive control %q moved no probe (%+v): the probe cannot see this handler's work", c.valid, after) + } + }) + } +} + +// TestHandleAnalyticsSubpaths_MaxLenFirmwareCeiling: the firmware encodes the +// hop count in bits 0-5 of path_length (firmware/docs/packet_format.md), so no +// path exceeds 63 hops. A 63-hop subpath must stay reachable, and any larger +// ?maxLen= must give the same result through the same cache key. +func TestHandleAnalyticsSubpaths_MaxLenFirmwareCeiling(t *testing.T) { + srv, router := setupTestServer(t) + hops := make([]string, 63) + for i := range hops { + hops[i] = fmt.Sprintf("%02x", i) + } + srv.store.mu.Lock() + srv.store.spIndex[strings.Join(hops, ",")] = 1 + srv.store.mu.Unlock() + + get := func(maxLen string) []interface{} { + req := httptest.NewRequest(http.MethodGet, "/api/analytics/subpaths?minLen=63&maxLen="+maxLen, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("maxLen=%s: status %d", maxLen, w.Code) + } + var body struct { + Subpaths []interface{} `json:"subpaths"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + return body.Subpaths + } + for _, ml := range []string{"63", "64", "1000"} { + if got := get(ml); len(got) != 1 { + t.Fatalf("maxLen=%s: want the 63-hop subpath, got %d subpaths", ml, len(got)) + } + } + srv.store.cacheMu.Lock() + defer srv.store.cacheMu.Unlock() + n := 0 + for k := range srv.store.subpathCache { + if strings.HasPrefix(k, "|63|") { + n++ + } + } + if n != 1 { + t.Fatalf("maxLen 63/64/1000 produced %d cache keys, want 1 (clamped)", n) + } +} + +// TestEvictStatsCacheLocked_TieBreakSmallestKey pins the deterministic victim: +// with every entry stored at the same instant, the smallest key goes. +func TestEvictStatsCacheLocked_TieBreakSmallestKey(t *testing.T) { + const max = 4 + now := time.Now() + cache := map[string]*int{} + at := map[string]time.Time{} + for _, k := range []string{"d", "b", "a", "c"} { + v := 0 + cache[k] = &v + at[k] = now + } + evictStatsCacheLocked(cache, at, "z", now, time.Hour, max) + if _, ok := cache["a"]; ok { + t.Fatalf("tie: smallest key %q should be the victim, cache=%v", "a", at) + } + if len(cache) != max-1 || len(at) != max-1 { + t.Fatalf("want one victim, len(cache)=%d len(at)=%d", len(cache), len(at)) + } +} diff --git a/cmd/server/neighbor_api.go b/cmd/server/neighbor_api.go index 57960a3a5..b0d0bd815 100644 --- a/cmd/server/neighbor_api.go +++ b/cmd/server/neighbor_api.go @@ -243,6 +243,9 @@ func (s *Server) handleNeighborGraph(w http.ResponseWriter, r *http.Request) { } } region := r.URL.Query().Get("region") + if s.rejectAnalyticsFilters(w, region, "") { + return + } roleFilter := strings.ToLower(r.URL.Query().Get("role")) // #1481 P0-1: serve the default-shape request from the atomic-pointer diff --git a/cmd/server/path_inspect.go b/cmd/server/path_inspect.go index fc676f890..755c0ca10 100644 --- a/cmd/server/path_inspect.go +++ b/cmd/server/path_inspect.go @@ -283,11 +283,11 @@ func (s *Server) handlePathInspect(w http.ResponseWriter, r *http.Request) { s.store.inspectCache = make(map[string]*inspectCachedResult) } now2 := time.Now() - for k, v := range s.store.inspectCache { - if now2.After(v.expiresAt) { - delete(s.store.inspectCache, k) - } - } + // Entry ceiling (#370): the key is caller-controlled (path prefixes, + // observer, since/until), so expiry alone is not a bound. Drop expired + // entries, then — for a new key still at the cap — a deterministic + // victim. A refresh of an existing key never evicts another entry. + evictInspectCacheLocked(s.store.inspectCache, cacheKey, now2) s.store.inspectCache[cacheKey] = &inspectCachedResult{ data: resp, expiresAt: now2.Add(inspectCacheTTL), diff --git a/cmd/server/relay_airtime_share.go b/cmd/server/relay_airtime_share.go index 546e0ba86..80ddbeccc 100644 --- a/cmd/server/relay_airtime_share.go +++ b/cmd/server/relay_airtime_share.go @@ -493,7 +493,7 @@ func (s *PacketStore) GetRelayAirtimeShareWithWindow(window TimeWindow) map[stri } s.cacheMu.Lock() - s.rfCache[cacheKey] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)} + putCachedResultLocked(s.rfCache, cacheKey, &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}) s.cacheMu.Unlock() return result diff --git a/cmd/server/routes.go b/cmd/server/routes.go index da65442db..84e763cbc 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2845,6 +2845,9 @@ func (s *Server) handleFleetClockSkew(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAnalyticsRF(w http.ResponseWriter, r *http.Request) { region := r.URL.Query().Get("region") area := r.URL.Query().Get("area") + if s.rejectAnalyticsFilters(w, region, area) { + return + } window := ParseTimeWindow(r) if s.store != nil { // Issue #1659: gate the default-shape request behind first-pass @@ -2895,6 +2898,9 @@ func (s *Server) handleAnalyticsRelayAirtimeShare(w http.ResponseWriter, r *http func (s *Server) handleAnalyticsTopology(w http.ResponseWriter, r *http.Request) { region := r.URL.Query().Get("region") area := r.URL.Query().Get("area") + if s.rejectAnalyticsFilters(w, region, area) { + return + } window := ParseTimeWindow(r) if s.store != nil { // #1659 warmup gate (see handleAnalyticsRF for rationale). @@ -2932,6 +2938,9 @@ func (s *Server) handleAnalyticsChannels(w http.ResponseWriter, r *http.Request) if s.store != nil { region := r.URL.Query().Get("region") area := r.URL.Query().Get("area") + if s.rejectAnalyticsFilters(w, region, area) { + return + } window := ParseTimeWindow(r) // #1659 warmup gate (see handleAnalyticsRF for rationale). if region == "" && area == "" && window.IsZero() { @@ -2963,6 +2972,9 @@ func (s *Server) handleAnalyticsChannels(w http.ResponseWriter, r *http.Request) func (s *Server) handleAnalyticsDistance(w http.ResponseWriter, r *http.Request) { region := r.URL.Query().Get("region") area := r.URL.Query().Get("area") + if s.rejectAnalyticsFilters(w, region, area) { + return + } if s.store != nil { // Lazy build (#1011): distance index is not built at startup. // First request triggers an async build and gets 202 + @@ -2994,6 +3006,9 @@ func (s *Server) handleAnalyticsHashSizes(w http.ResponseWriter, r *http.Request if s.store != nil { region := r.URL.Query().Get("region") area := r.URL.Query().Get("area") + if s.rejectAnalyticsFilters(w, region, area) { + return + } writeJSON(w, s.store.GetAnalyticsHashSizes(region, area)) return } @@ -3011,6 +3026,9 @@ func (s *Server) handleAnalyticsHashCollisions(w http.ResponseWriter, r *http.Re if s.store != nil { region := r.URL.Query().Get("region") area := r.URL.Query().Get("area") + if s.rejectAnalyticsFilters(w, region, area) { + return + } writeJSON(w, s.store.GetAnalyticsHashCollisions(region, area)) return } @@ -3027,11 +3045,19 @@ func (s *Server) handleAnalyticsSubpaths(w http.ResponseWriter, r *http.Request) return } region := r.URL.Query().Get("region") + if s.rejectAnalyticsFilters(w, region, "") { + return + } minLen := queryInt(r, "minLen", 2) if minLen < 2 { minLen = 2 } maxLen := queryInt(r, "maxLen", 8) + // Clamp maxLen so an unbounded value cannot mint unbounded distinct + // subpathCache keys (the ceiling still backstops it, #370). + if maxLen > subpathMaxLenCeiling { + maxLen = subpathMaxLenCeiling + } limit := queryLimit(r, 100, s.cfg.ListLimits.AnalyticsMax) // Issue #1217: honor the Time window filter on Route Patterns. window := ParseTimeWindow(r) @@ -3058,6 +3084,9 @@ func (s *Server) handleAnalyticsSubpathsBulk(w http.ResponseWriter, r *http.Requ return } region := r.URL.Query().Get("region") + if s.rejectAnalyticsFilters(w, region, "") { + return + } groupsParam := r.URL.Query().Get("groups") if groupsParam == "" { writeJSON(w, ErrorResp{Error: "groups parameter required (e.g. groups=2-2:50,3-3:30)"}) @@ -4644,6 +4673,13 @@ func (s *Server) handleWardrivingStats(w http.ResponseWriter, r *http.Request) { if channel == "" { channel = "#wardriving" } + // Reject an overlong channel before GetWardrivingStats and before it enters + // the cache key, so a client cannot drive expensive work or unbounded + // distinct wardrivingStatsCache keys with a free-text filter (#370). + if len(channel) > analyticsFilterMaxLen { + writeError(w, 400, "channel filter too long") + return + } cacheKey := window + "|" + channel s.wardrivingStatsMu.Lock() @@ -4694,6 +4730,7 @@ func (s *Server) handleWardrivingStats(w http.ResponseWriter, r *http.Request) { s.wardrivingStatsCache = make(map[string]*WardrivingStatsResponse) s.wardrivingStatsCachedAt = make(map[string]time.Time) } + evictStatsCacheLocked(s.wardrivingStatsCache, s.wardrivingStatsCachedAt, cacheKey, time.Now(), wardrivingStatsTTL, wardrivingStatsMaxEntries) s.wardrivingStatsCache[cacheKey] = resp s.wardrivingStatsCachedAt[cacheKey] = time.Now() s.wardrivingStatsMu.Unlock() diff --git a/cmd/server/store.go b/cmd/server/store.go index 8704f9ce2..811062627 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -4227,6 +4227,8 @@ func (s *PacketStore) fetchAndCacheRegionObs(region string) map[string]bool { if m, ok := s.regionObsCache[region]; ok { return m } + // Bound the map before storing a new region key (#370). + s.evictRegionObsLocked(region) ids, err := s.db.GetObserverIdsForRegion(region) if err != nil || len(ids) == 0 { s.regionObsCache[region] = nil @@ -6713,7 +6715,7 @@ func (s *PacketStore) GetAnalyticsChannelsWithWindow(region, area string, window result := s.computeAnalyticsChannels(region, area, window) s.cacheMu.Lock() - s.chanCache[cacheKey] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)} + putCachedResultLocked(s.chanCache, cacheKey, &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}) s.cacheMu.Unlock() return result @@ -7015,7 +7017,7 @@ func (s *PacketStore) GetAnalyticsRFWithWindow(region, area string, window TimeW result := s.computeAnalyticsRF(region, area, window) s.cacheMu.Lock() - s.rfCache[cacheKey] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)} + putCachedResultLocked(s.rfCache, cacheKey, &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}) s.cacheMu.Unlock() return result @@ -8127,7 +8129,7 @@ func (s *PacketStore) GetAnalyticsTopologyWithWindow(region, area string, window result := s.computeAnalyticsTopology(region, area, window) s.cacheMu.Lock() - s.topoCache[cacheKey] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)} + putCachedResultLocked(s.topoCache, cacheKey, &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}) s.cacheMu.Unlock() return result @@ -8728,7 +8730,7 @@ func (s *PacketStore) GetAnalyticsDistance(region, area string) map[string]inter result := s.computeAnalyticsDistance(region, area) s.cacheMu.Lock() - s.distCache[cacheKey] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)} + putCachedResultLocked(s.distCache, cacheKey, &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}) s.cacheMu.Unlock() return result @@ -9075,7 +9077,7 @@ func (s *PacketStore) GetAnalyticsHashSizes(region, area string) map[string]inte result := s.computeAnalyticsHashSizesWithCapability(region, area) s.cacheMu.Lock() - s.hashCache[cacheKey] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)} + putCachedResultLocked(s.hashCache, cacheKey, &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}) s.cacheMu.Unlock() return result @@ -9476,7 +9478,7 @@ func (s *PacketStore) GetAnalyticsHashCollisions(region, area string) map[string result := s.computeHashCollisions(region, area) s.cacheMu.Lock() - s.collisionCache[cacheKey] = &cachedResult{data: result, expiresAt: time.Now().Add(s.collisionCacheTTL)} + putCachedResultLocked(s.collisionCache, cacheKey, &cachedResult{data: result, expiresAt: time.Now().Add(s.collisionCacheTTL)}) s.cacheMu.Unlock() return result @@ -10976,7 +10978,7 @@ func (s *PacketStore) GetAnalyticsSubpathsWithWindow(region string, minLen, maxL result := s.computeAnalyticsSubpathsWindowed(region, minLen, maxLen, limit, window) s.cacheMu.Lock() - s.subpathCache[cacheKey] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)} + putCachedResultLocked(s.subpathCache, cacheKey, &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}) s.cacheMu.Unlock() return result @@ -11076,7 +11078,7 @@ func (s *PacketStore) GetAnalyticsSubpaths(region string, minLen, maxLen, limit result := s.computeAnalyticsSubpaths(region, minLen, maxLen, limit) s.cacheMu.Lock() - s.subpathCache[cacheKey] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)} + putCachedResultLocked(s.subpathCache, cacheKey, &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}) s.cacheMu.Unlock() return result @@ -11200,7 +11202,7 @@ func (s *PacketStore) GetAnalyticsSubpathsBulk(region string, groups []subpathGr s.cacheMu.Lock() for i, g := range groups { cacheKey := fmt.Sprintf("|%d|%d|%d", g.MinLen, g.MaxLen, g.Limit) - s.subpathCache[cacheKey] = &cachedResult{data: results[i], expiresAt: time.Now().Add(s.rfCacheTTL)} + putCachedResultLocked(s.subpathCache, cacheKey, &cachedResult{data: results[i], expiresAt: time.Now().Add(s.rfCacheTTL)}) } s.cacheMu.Unlock()