mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-11 16:11:38 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc3552d8e7 | |||
| d1e317f4c2 | |||
| 0b89bfa50f | |||
| 82c81809d7 |
@@ -48,6 +48,15 @@ type Config struct {
|
||||
// operator refuses to fix.
|
||||
NodeBlacklist []string `json:"nodeBlacklist"`
|
||||
|
||||
// HiddenNamePrefixes is a list of name prefixes that mark a node as
|
||||
// hidden from API responses (issue #1181). The default `["🚫"]` mirrors
|
||||
// a convention used by other MeshCore map dashboards: operators who
|
||||
// rename their node with the prefix get hidden from the map without
|
||||
// waiting for normal retention to clear stale data. DB rows are
|
||||
// preserved — the filter is applied at the API layer only, so the
|
||||
// underlying observation history remains intact.
|
||||
HiddenNamePrefixes []string `json:"hiddenNamePrefixes"`
|
||||
|
||||
// blacklistSetPtr holds the active lookup set as an atomic pointer.
|
||||
// Read path is a single atomic load — no mutex, no sync.Once. Writers
|
||||
// always replace the whole map; readers see either the old or the new
|
||||
@@ -727,6 +736,48 @@ func (c *Config) IsBlacklisted(pubkey string) bool {
|
||||
return (*mp)[strings.ToLower(strings.TrimSpace(pubkey))]
|
||||
}
|
||||
|
||||
// IsNameHidden returns true if the given node name starts with any of the
|
||||
// operator-configured HiddenNamePrefixes (issue #1181). Empty/whitespace
|
||||
// prefixes are ignored. Used to drop nodes from /api/nodes, /api/nodes/search
|
||||
// and /api/nodes/{pubkey} without deleting the underlying DB row, so observer
|
||||
// history stays intact even after the operator hides the node.
|
||||
func (c *Config) IsNameHidden(name string) bool {
|
||||
if c == nil || len(c.HiddenNamePrefixes) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, p := range c.HiddenNamePrefixes {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(name, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetHiddenNamePrefixes is a stub for the upcoming atomic.Pointer wrapper.
|
||||
// Currently just assigns the slice (NOT yet race-safe). Bumps the
|
||||
// generation counter. Wired up properly in the GREEN commit.
|
||||
func (c *Config) SetHiddenNamePrefixes(prefixes []string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
cp := make([]string, len(prefixes))
|
||||
copy(cp, prefixes)
|
||||
c.HiddenNamePrefixes = cp
|
||||
// TODO(GREEN): bump c.hiddenNamePrefixesGen + atomic.Store
|
||||
}
|
||||
|
||||
// HiddenNamePrefixesGeneration is a stub returning 0. GREEN commit returns
|
||||
// the live atomic counter.
|
||||
func (c *Config) HiddenNamePrefixesGeneration() uint64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// SaveGeoFilter writes the geo_filter section back to config.json on disk.
|
||||
// Pass gf=nil to remove the filter. The rest of config.json is preserved as-is.
|
||||
func SaveGeoFilter(configDir string, gf *GeoFilterConfig) error {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestHiddenNamePrefix_1181_NodeHealth asserts that /api/nodes/{pk}/health
|
||||
// returns 404 for a node whose name starts with a hidden prefix — mirroring
|
||||
// the existing blacklist guard at the top of handleNodeHealth.
|
||||
//
|
||||
// Anti-tautology: this test FAILS if the IsNameHidden guard is removed from
|
||||
// handleNodeHealth (the handler would 200 with health data instead of 404).
|
||||
func TestHiddenNamePrefix_1181_NodeHealth(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
pk := "deadbeef00001184"
|
||||
if _, err := srv.db.conn.Exec(`INSERT INTO nodes
|
||||
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, ?, ?, 0, 0, '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', 1)`,
|
||||
pk, "🚫 health me", "companion"); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
get := func() *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("GET", "/api/nodes/"+pk+"/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
srv.cfg.HiddenNamePrefixes = []string{"🚫"}
|
||||
w := get()
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("hidden: expected 404 from /api/nodes/%s/health, got %d body=%s", pk, w.Code, w.Body.String())
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "health me") {
|
||||
t.Fatalf("hidden: name leaked in /health 404 body: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHiddenNamePrefix_1181_BulkHealth asserts /api/nodes/bulk-health filters
|
||||
// out nodes whose name starts with a hidden prefix — same shape as the
|
||||
// existing blacklist filter inside handleBulkHealth.
|
||||
//
|
||||
// Anti-tautology: remove the IsNameHidden branch from handleBulkHealth and
|
||||
// the hidden node leaks back into the response array; this assertion fails.
|
||||
func TestHiddenNamePrefix_1181_BulkHealth(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
pk := "deadbeef00001185"
|
||||
if _, err := srv.db.conn.Exec(`INSERT INTO nodes
|
||||
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, ?, ?, 0, 0, '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', 1)`,
|
||||
pk, "🚫 bulk me", "companion"); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
srv.cfg.HiddenNamePrefixes = []string{"🚫"}
|
||||
srv.cfg.NodeBlacklist = []string{"force-filter-branch"} // force the existing blacklist branch on so results-array path is taken
|
||||
srv.cfg.SetNodeBlacklist(srv.cfg.NodeBlacklist)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes/bulk-health?limit=2000", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var arr []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &arr); err != nil {
|
||||
t.Fatalf("unmarshal: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
for _, e := range arr {
|
||||
if got, _ := e["public_key"].(string); strings.EqualFold(got, pk) {
|
||||
t.Fatalf("hidden node %s leaked through /api/nodes/bulk-health", pk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHiddenNamePrefix_1181_Paths asserts /api/nodes/{pk}/paths returns 404
|
||||
// for a hidden-prefix node, mirroring blacklist behaviour.
|
||||
func TestHiddenNamePrefix_1181_Paths(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
pk := "deadbeef00001186"
|
||||
if _, err := srv.db.conn.Exec(`INSERT INTO nodes
|
||||
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, ?, ?, 0, 0, '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', 1)`,
|
||||
pk, "🚫 paths me", "companion"); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
srv.cfg.HiddenNamePrefixes = []string{"🚫"}
|
||||
req := httptest.NewRequest("GET", "/api/nodes/"+pk+"/paths", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("hidden: expected 404 from /api/nodes/%s/paths, got %d body=%s", pk, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHiddenNamePrefix_1181_Analytics asserts /api/nodes/{pk}/analytics 404s
|
||||
// for hidden-prefix nodes.
|
||||
func TestHiddenNamePrefix_1181_Analytics(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
pk := "deadbeef00001187"
|
||||
if _, err := srv.db.conn.Exec(`INSERT INTO nodes
|
||||
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, ?, ?, 0, 0, '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', 1)`,
|
||||
pk, "🚫 analytics me", "companion"); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
srv.cfg.HiddenNamePrefixes = []string{"🚫"}
|
||||
req := httptest.NewRequest("GET", "/api/nodes/"+pk+"/analytics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("hidden: expected 404 from /api/nodes/%s/analytics, got %d body=%s", pk, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHiddenNamePrefixesGeneration_Increments asserts the per-source
|
||||
// generation counter bumps on every Set call — mirrors
|
||||
// TestConfig_BlacklistGenerationIncrements behaviour. Cache wiring lives in
|
||||
// a follow-up; the counter is the prerequisite primitive.
|
||||
func TestHiddenNamePrefixesGeneration_Increments(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
g0 := cfg.HiddenNamePrefixesGeneration()
|
||||
cfg.SetHiddenNamePrefixes([]string{"🚫"})
|
||||
g1 := cfg.HiddenNamePrefixesGeneration()
|
||||
if g1 != g0+1 {
|
||||
t.Fatalf("first SetHiddenNamePrefixes: gen %d -> %d (want +1)", g0, g1)
|
||||
}
|
||||
cfg.SetHiddenNamePrefixes([]string{"🚫"})
|
||||
g2 := cfg.HiddenNamePrefixesGeneration()
|
||||
if g2 != g1+1 {
|
||||
t.Fatalf("second SetHiddenNamePrefixes: gen %d -> %d (want +1)", g1, g2)
|
||||
}
|
||||
cfg.SetHiddenNamePrefixes(nil)
|
||||
g3 := cfg.HiddenNamePrefixesGeneration()
|
||||
if g3 != g2+1 {
|
||||
t.Fatalf("nil SetHiddenNamePrefixes: gen %d -> %d (want +1)", g2, g3)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHiddenNamePrefixes_ConcurrentAccess hammers Set + IsNameHidden from
|
||||
// multiple goroutines. Doesn't assert anything beyond "doesn't panic" —
|
||||
// atomic.Pointer correctness is what we're verifying, race detector is not
|
||||
// in scope for this PR's CI (see PR scope).
|
||||
func TestHiddenNamePrefixes_ConcurrentAccess(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
cfg.SetHiddenNamePrefixes([]string{"🚫"})
|
||||
|
||||
var stop atomic.Bool
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Writer
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; !stop.Load(); i++ {
|
||||
if i%2 == 0 {
|
||||
cfg.SetHiddenNamePrefixes([]string{"🚫", "test"})
|
||||
} else {
|
||||
cfg.SetHiddenNamePrefixes([]string{"🚫"})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Readers
|
||||
for r := 0; r < 4; r++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for !stop.Load() {
|
||||
_ = cfg.IsNameHidden("🚫 something")
|
||||
_ = cfg.IsNameHidden("normal name")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
stop.Store(true)
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHiddenNamePrefix_1181 verifies operator-configurable name-prefix hiding
|
||||
// for nodes (issue #1181). When the operator configures HiddenNamePrefixes,
|
||||
// nodes whose name begins with any configured prefix are omitted from API
|
||||
// responses (list, search, detail). DB rows are preserved — filtering happens
|
||||
// at the API layer only.
|
||||
func TestHiddenNamePrefix_1181_NodesList(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
// Insert a node whose name starts with the configured 🚫 prefix.
|
||||
_, err := srv.db.conn.Exec(`INSERT INTO nodes
|
||||
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, ?, ?, 0, 0, '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', 1)`,
|
||||
"deadbeef00001181", "🚫 ban me", "companion")
|
||||
if err != nil {
|
||||
t.Fatalf("insert hidden node: %v", err)
|
||||
}
|
||||
|
||||
get := func() []map[string]interface{} {
|
||||
req := httptest.NewRequest("GET", "/api/nodes?limit=2000", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Nodes []map[string]interface{} `json:"nodes"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
return resp.Nodes
|
||||
}
|
||||
|
||||
hasName := func(nodes []map[string]interface{}, substr string) bool {
|
||||
for _, n := range nodes {
|
||||
if name, _ := n["name"].(string); strings.Contains(name, substr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Empty prefix list: node MUST be present.
|
||||
srv.cfg.HiddenNamePrefixes = nil
|
||||
if !hasName(get(), "ban me") {
|
||||
t.Fatalf("with empty HiddenNamePrefixes, node should be present in /api/nodes")
|
||||
}
|
||||
|
||||
// Configured 🚫 prefix: node MUST be omitted.
|
||||
srv.cfg.HiddenNamePrefixes = []string{"🚫"}
|
||||
if hasName(get(), "ban me") {
|
||||
t.Fatalf("with HiddenNamePrefixes=[\"🚫\"], node 🚫 ban me should be hidden from /api/nodes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHiddenNamePrefix_1181_Search ensures hidden nodes are also filtered
|
||||
// from /api/nodes/search.
|
||||
func TestHiddenNamePrefix_1181_Search(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
if _, err := srv.db.conn.Exec(`INSERT INTO nodes
|
||||
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, ?, ?, 0, 0, '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', 1)`,
|
||||
"deadbeef00001182", "🚫 search me", "companion"); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
srv.cfg.HiddenNamePrefixes = []string{"🚫"}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes/search?q=search", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Nodes []map[string]interface{} `json:"nodes"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
for _, n := range resp.Nodes {
|
||||
if name, _ := n["name"].(string); strings.Contains(name, "search me") {
|
||||
t.Fatalf("hidden node leaked through /api/nodes/search: %v", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHiddenNamePrefix_1181_Detail ensures /api/nodes/{pubkey} returns 404
|
||||
// for a node whose name starts with a hidden prefix — mirroring the
|
||||
// blacklist behaviour so callers learn nothing about whether the row exists.
|
||||
func TestHiddenNamePrefix_1181_Detail(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
pk := "deadbeef00001183"
|
||||
if _, err := srv.db.conn.Exec(`INSERT INTO nodes
|
||||
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, ?, ?, 0, 0, '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', 1)`,
|
||||
pk, "🚫 detail me", "companion"); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
get := func() *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("GET", "/api/nodes/"+pk, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// Empty prefix list: detail MUST be reachable (200 with the name).
|
||||
srv.cfg.HiddenNamePrefixes = nil
|
||||
w := get()
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("baseline: expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "detail me") {
|
||||
t.Fatalf("baseline: response missing node name; body=%s", w.Body.String())
|
||||
}
|
||||
|
||||
// Configured 🚫 prefix: detail MUST 404 — no name, no fields, nothing.
|
||||
srv.cfg.HiddenNamePrefixes = []string{"🚫"}
|
||||
w = get()
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("hidden: expected 404, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "detail me") {
|
||||
t.Fatalf("hidden: name leaked in 404 body: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
+32
-1
@@ -1353,6 +1353,20 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
total = len(filtered)
|
||||
nodes = filtered
|
||||
}
|
||||
// Filter nodes whose name starts with a hidden prefix (#1181). DB rows
|
||||
// are preserved — this only drops them from the API surface so observer
|
||||
// history (paths, hops, distances) remains intact for analytics.
|
||||
if len(s.cfg.HiddenNamePrefixes) > 0 {
|
||||
filtered := nodes[:0]
|
||||
for _, node := range nodes {
|
||||
name, _ := node["name"].(string)
|
||||
if !s.cfg.IsNameHidden(name) {
|
||||
filtered = append(filtered, node)
|
||||
}
|
||||
}
|
||||
total = len(filtered)
|
||||
nodes = filtered
|
||||
}
|
||||
// Filter by area
|
||||
if area := q.Get("area"); area != "" {
|
||||
var areaNodes map[string]bool
|
||||
@@ -1405,6 +1419,17 @@ func (s *Server) handleNodeSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
nodes = filtered
|
||||
}
|
||||
// Drop hidden-prefix nodes from search results (#1181).
|
||||
if len(s.cfg.HiddenNamePrefixes) > 0 {
|
||||
filtered := make([]map[string]interface{}, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
name, _ := node["name"].(string)
|
||||
if !s.cfg.IsNameHidden(name) {
|
||||
filtered = append(filtered, node)
|
||||
}
|
||||
}
|
||||
nodes = filtered
|
||||
}
|
||||
writeJSON(w, NodeSearchResponse{Nodes: nodes})
|
||||
}
|
||||
|
||||
@@ -1443,7 +1468,13 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, 404, "Not found")
|
||||
return
|
||||
}
|
||||
// From here on use the canonical pubkey for downstream lookups.
|
||||
// Hide the node when its name matches an operator-configured prefix
|
||||
// (#1181). 404 mirrors the blacklist behaviour above — callers learn
|
||||
// nothing about whether the row exists.
|
||||
if name, _ := node["name"].(string); s.cfg.IsNameHidden(name) {
|
||||
writeError(w, 404, "Not found")
|
||||
return
|
||||
}
|
||||
if pk, _ := node["public_key"].(string); pk != "" {
|
||||
pubkey = pk
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"apiKey": "your-secret-api-key-here",
|
||||
"nodeBlacklist": [],
|
||||
"_comment_nodeBlacklist": "Public keys of nodes to hide from all API responses. Use for trolls, offensive names, or nodes reporting false data that operators refuse to fix.",
|
||||
"hiddenNamePrefixes": ["🚫"],
|
||||
"_comment_hiddenNamePrefixes": "Node name prefixes that mark a node as hidden from this dashboard (#1181). Mirrors a convention used by other MeshCore map dashboards: an operator who wants their node hidden renames it to start with one of these prefixes and sends an advert; the next advert is dropped from /api/nodes, /api/nodes/search and /api/nodes/{pubkey}. DB rows are preserved so observation history (paths, hops, distances) stays intact for analytics. The node is NOT hidden from the mesh itself — only from this dashboard. Set to [] to disable. Default: [\"🚫\"].",
|
||||
"observerIATAWhitelist": [],
|
||||
"_comment_observerIATAWhitelist": "Global IATA region whitelist. When non-empty, only observers whose IATA code (from MQTT topic) matches are processed. Case-insensitive. Empty = allow all. Unlike per-source iataFilter, this applies across all MQTT sources.",
|
||||
"retention": {
|
||||
|
||||
Reference in New Issue
Block a user