mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-29 03:42:00 +00:00
Fixes #1181. ## Summary Adds operator-configurable name-prefix hiding for nodes. When a node's name starts with any prefix listed in the new `hiddenNamePrefixes` config field (default `["🚫"]`), it is omitted from `/api/nodes`, `/api/nodes/search`, and `/api/nodes/{pubkey}`. DB rows are preserved — the filter runs at the API layer only, so observation history (paths, hops, distances) stays intact and the node simply re-appears if the operator clears the prefix list. This mirrors the convention already in use on other MeshCore map dashboards: an operator who wants their node hidden renames it with the 🚫 prefix and sends an advert; the next advert is then dropped from the dashboard. The node is **not** hidden from the mesh itself — only from this dashboard. This is documented inline in `config.example.json`. Implementation follows the existing `IsBlacklisted` pattern exactly: a new `Config.IsNameHidden(name)` method, and three filters in `routes.go` placed alongside the corresponding blacklist filters. No DB schema, public API, or websocket changes. ## Files changed - `cmd/server/config.go` — new `HiddenNamePrefixes []string` field + `IsNameHidden` method - `cmd/server/routes.go` — filters in `handleNodes`, `handleNodeSearch`, `handleNodeDetail` - `config.example.json` — new field + `_comment_hiddenNamePrefixes` operator doc - `cmd/server/hidden_name_prefix_1181_test.go` — new test file (red → green) ## Test plan Two new subtests in `TestHiddenNamePrefix_1181_*`: 1. `_NodesList` — inserts a node named `🚫 ban me`, asserts it is present when `HiddenNamePrefixes` is empty and absent when set to `["🚫"]`. 2. `_Search` — inserts `🚫 search me`, asserts `/api/nodes/search?q=search` does not surface it when the prefix is configured. Verified red→green: - Red commit `d0903852`: `go test -run TestHiddenNamePrefix_1181` fails on the leak assertion (`hidden_name_prefix_1181_test.go:94`). - Green commit `e79a0d8d`: same command passes. ``` $ cd cmd/server && go test -run TestHiddenNamePrefix_1181 -count=1 . ok github.com/corescope/server 0.060s ``` ## Out of scope - Auto-purging DB rows for hidden nodes — left to existing retention. The triage was explicit: hide, do not delete. - Live websocket broadcast: nodes are not broadcast via websocket (only packets), so no separate emit path needs filtering. Frontend reads nodes via `/api/nodes`, which is filtered. - Frontend customizer for the prefix list — operators configure via `config.json` like every other knob.
This commit is contained in:
@@ -48,6 +48,28 @@ 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"`
|
||||
|
||||
// hiddenPrefixesPtr holds the active prefix slice as an atomic pointer.
|
||||
// Read path (IsNameHidden) is a single atomic load — no mutex, no
|
||||
// sync.Once. Writers always replace the whole slice; readers see either
|
||||
// the old or the new slice as a single value, never a partial state.
|
||||
// Mirrors blacklistSetPtr.
|
||||
hiddenPrefixesPtr atomic.Pointer[[]string]
|
||||
|
||||
// hiddenPrefixesGen is a monotonic counter bumped every time the
|
||||
// hidden-prefix list mutates via SetHiddenNamePrefixes. Cache wiring
|
||||
// is left for follow-up; the counter is the prerequisite primitive
|
||||
// callers will key on (mirrors blacklistGen / #1629).
|
||||
hiddenPrefixesGen atomic.Uint64
|
||||
|
||||
// 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 +749,73 @@ 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.
|
||||
//
|
||||
// Hot read path: a single atomic pointer load. No locks, no sync.Once.
|
||||
// Writers always replace the whole slice; readers see either the old or
|
||||
// the new slice as a single value, never a partially-built one. Mirrors
|
||||
// IsBlacklisted's CAS-style lazy first-read materialisation for the
|
||||
// JSON-load path where SetHiddenNamePrefixes was never called.
|
||||
func (c *Config) IsNameHidden(name string) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
pp := c.hiddenPrefixesPtr.Load()
|
||||
if pp == nil {
|
||||
// Lazy first-read materialisation from the JSON-loaded slice.
|
||||
// CAS-style: if another goroutine wins the race, drop ours.
|
||||
built := make([]string, len(c.HiddenNamePrefixes))
|
||||
copy(built, c.HiddenNamePrefixes)
|
||||
if c.hiddenPrefixesPtr.CompareAndSwap(nil, &built) {
|
||||
pp = &built
|
||||
} else {
|
||||
pp = c.hiddenPrefixesPtr.Load()
|
||||
}
|
||||
}
|
||||
if pp == nil || len(*pp) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, p := range *pp {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(name, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetHiddenNamePrefixes atomically replaces HiddenNamePrefixes with the
|
||||
// given slice and bumps the generation counter. Safe for concurrent use
|
||||
// with IsNameHidden / HiddenNamePrefixesGeneration. Mirrors
|
||||
// SetNodeBlacklist (#1629).
|
||||
func (c *Config) SetHiddenNamePrefixes(prefixes []string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
cp := make([]string, len(prefixes))
|
||||
copy(cp, prefixes)
|
||||
c.HiddenNamePrefixes = cp
|
||||
c.hiddenPrefixesPtr.Store(&cp)
|
||||
c.hiddenPrefixesGen.Add(1)
|
||||
}
|
||||
|
||||
// HiddenNamePrefixesGeneration returns a monotonic counter that increments
|
||||
// on every SetHiddenNamePrefixes call. Response caches keyed per-pubkey can
|
||||
// embed this value in their cache key so any prefix mutation invalidates
|
||||
// prior entries on the next request — same pattern as BlacklistGeneration.
|
||||
func (c *Config) HiddenNamePrefixesGeneration() uint64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return c.hiddenPrefixesGen.Load()
|
||||
}
|
||||
|
||||
// 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.SetHiddenNamePrefixes([]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.SetHiddenNamePrefixes([]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.SetHiddenNamePrefixes([]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.SetHiddenNamePrefixes([]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.SetHiddenNamePrefixes(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.SetHiddenNamePrefixes([]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.SetHiddenNamePrefixes([]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.SetHiddenNamePrefixes(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.SetHiddenNamePrefixes([]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())
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,10 @@ func (s *Server) handleNodeNeighbors(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, 404, "Not found")
|
||||
return
|
||||
}
|
||||
if s.isPubkeyHidden(pubkey) {
|
||||
writeError(w, 404, "Not found")
|
||||
return
|
||||
}
|
||||
|
||||
minCount := 1
|
||||
if v := r.URL.Query().Get("min_count"); v != "" {
|
||||
@@ -334,6 +338,10 @@ func (s *Server) computeNeighborGraphResponse(minCount int, minScore float64, re
|
||||
if s.cfg != nil && (s.cfg.IsBlacklisted(e.NodeA) || s.cfg.IsBlacklisted(e.NodeB)) {
|
||||
continue
|
||||
}
|
||||
// #1181: also drop edges touching a hidden-prefix node.
|
||||
if s.isPubkeyHidden(e.NodeA) || s.isPubkeyHidden(e.NodeB) {
|
||||
continue
|
||||
}
|
||||
|
||||
ge := GraphEdge{
|
||||
Source: e.NodeA,
|
||||
|
||||
@@ -400,6 +400,10 @@ func (s *Server) handleNodeReach(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, 404, "Not found")
|
||||
return
|
||||
}
|
||||
if s.isPubkeyHidden(pubkey) {
|
||||
writeError(w, 404, "Not found")
|
||||
return
|
||||
}
|
||||
days := 7
|
||||
if v := r.URL.Query().Get("days"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
|
||||
+108
-13
@@ -152,6 +152,24 @@ func (s *Server) getMemStats() runtime.MemStats {
|
||||
|
||||
// getGeoFilter returns a pointer to the current geo_filter config under read lock.
|
||||
// Callers MUST NOT mutate the returned struct.
|
||||
// isPubkeyHidden returns true if the node with the given pubkey has a name
|
||||
// matching an operator-configured hidden prefix (#1181). Mirrors the
|
||||
// IsBlacklisted check used at the top of per-pubkey handlers: per-pubkey
|
||||
// endpoints should 404 on hidden nodes so callers learn nothing about
|
||||
// whether the row exists. Returns false on DB error / missing row (the
|
||||
// downstream handler's own 404 covers those).
|
||||
func (s *Server) isPubkeyHidden(pubkey string) bool {
|
||||
if s == nil || s.cfg == nil || len(s.cfg.HiddenNamePrefixes) == 0 {
|
||||
return false
|
||||
}
|
||||
node, err := s.db.GetNodeByPubkey(pubkey)
|
||||
if err != nil || node == nil {
|
||||
return false
|
||||
}
|
||||
name, _ := node["name"].(string)
|
||||
return s.cfg.IsNameHidden(name)
|
||||
}
|
||||
|
||||
func (s *Server) getGeoFilter() *GeoFilterConfig {
|
||||
s.cfgMu.RLock()
|
||||
defer s.cfgMu.RUnlock()
|
||||
@@ -1353,6 +1371,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 +1437,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 +1486,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
|
||||
}
|
||||
@@ -1488,6 +1537,10 @@ func (s *Server) handleNodeHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, 404, "Not found")
|
||||
return
|
||||
}
|
||||
if s.isPubkeyHidden(pubkey) {
|
||||
writeError(w, 404, "Not found")
|
||||
return
|
||||
}
|
||||
if s.store != nil {
|
||||
result, err := s.store.GetNodeHealth(pubkey)
|
||||
if err != nil || result == nil {
|
||||
@@ -1507,13 +1560,22 @@ func (s *Server) handleBulkHealth(w http.ResponseWriter, r *http.Request) {
|
||||
region := r.URL.Query().Get("region")
|
||||
area := r.URL.Query().Get("area")
|
||||
results := s.store.GetBulkHealth(lim, region, area)
|
||||
// Filter blacklisted nodes
|
||||
if len(s.cfg.NodeBlacklist) > 0 {
|
||||
// Filter blacklisted nodes + hidden-prefix nodes (#1181).
|
||||
needsBlacklist := len(s.cfg.NodeBlacklist) > 0
|
||||
needsHidden := len(s.cfg.HiddenNamePrefixes) > 0
|
||||
if needsBlacklist || needsHidden {
|
||||
filtered := make([]map[string]interface{}, 0, len(results))
|
||||
for _, entry := range results {
|
||||
if pk, ok := entry["public_key"].(string); !ok || !s.cfg.IsBlacklisted(pk) {
|
||||
filtered = append(filtered, entry)
|
||||
if pk, ok := entry["public_key"].(string); ok && needsBlacklist && s.cfg.IsBlacklisted(pk) {
|
||||
continue
|
||||
}
|
||||
if needsHidden {
|
||||
name, _ := entry["name"].(string)
|
||||
if s.cfg.IsNameHidden(name) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
writeJSON(w, filtered)
|
||||
return
|
||||
@@ -1541,6 +1603,10 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, 404, "Not found")
|
||||
return
|
||||
}
|
||||
if s.isPubkeyHidden(pubkey) {
|
||||
writeError(w, 404, "Not found")
|
||||
return
|
||||
}
|
||||
node, err := s.db.GetNodeByPubkey(pubkey)
|
||||
if err != nil || node == nil {
|
||||
writeError(w, 404, "Not found")
|
||||
@@ -1923,6 +1989,10 @@ func (s *Server) handleNodeAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, 404, "Not found")
|
||||
return
|
||||
}
|
||||
if s.isPubkeyHidden(pubkey) {
|
||||
writeError(w, 404, "Not found")
|
||||
return
|
||||
}
|
||||
days := queryInt(r, "days", 7)
|
||||
if days < 1 {
|
||||
days = 1
|
||||
@@ -2226,10 +2296,10 @@ func (s *Server) handleAnalyticsSubpathDetail(w http.ResponseWriter, r *http.Req
|
||||
writeJSON(w, ErrorResp{Error: "Need at least 2 hops"})
|
||||
return
|
||||
}
|
||||
// Reject if any hop is a blacklisted node.
|
||||
if s.cfg != nil && len(s.cfg.NodeBlacklist) > 0 {
|
||||
// Reject if any hop is a blacklisted or hidden-prefix node (#1181).
|
||||
if s.cfg != nil && (len(s.cfg.NodeBlacklist) > 0 || len(s.cfg.HiddenNamePrefixes) > 0) {
|
||||
for _, hop := range rawHops {
|
||||
if s.cfg.IsBlacklisted(hop) {
|
||||
if s.cfg.IsBlacklisted(hop) || s.isPubkeyHidden(hop) {
|
||||
writeError(w, 404, "Not found")
|
||||
return
|
||||
}
|
||||
@@ -2308,6 +2378,11 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg != nil && s.cfg.IsBlacklisted(ni.PublicKey) {
|
||||
continue
|
||||
}
|
||||
// #1181: skip hidden-prefix nodes too. We have the
|
||||
// name on ni so no extra DB lookup is needed.
|
||||
if s.cfg != nil && s.cfg.IsNameHidden(ni.Name) {
|
||||
continue
|
||||
}
|
||||
c := HopCandidate{Pubkey: ni.PublicKey}
|
||||
if ni.Name != "" {
|
||||
c.Name = ni.Name
|
||||
@@ -2376,8 +2451,9 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Use the resolved node as the default (best-effort pick).
|
||||
// Skip if the best pick is a blacklisted node.
|
||||
if best != nil && !(s.cfg != nil && s.cfg.IsBlacklisted(best.PublicKey)) {
|
||||
// Skip if the best pick is blacklisted or has a hidden-prefix
|
||||
// name (#1181).
|
||||
if best != nil && !(s.cfg != nil && (s.cfg.IsBlacklisted(best.PublicKey) || s.cfg.IsNameHidden(best.Name))) {
|
||||
hr.Name = best.Name
|
||||
hr.Pubkey = best.PublicKey
|
||||
}
|
||||
@@ -3158,8 +3234,9 @@ func constantTimeEqual(a, b string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
// filterBlacklistedFromTopology removes blacklisted node references from the
|
||||
// topology analytics response (TopRepeaters, TopPairs, BestPathList, MultiObsNodes, PerObserverReach).
|
||||
// filterBlacklistedFromTopology removes blacklisted + hidden-prefix node
|
||||
// references (#1181) from the topology analytics response (TopRepeaters,
|
||||
// TopPairs, BestPathList, MultiObsNodes, PerObserverReach).
|
||||
func (s *Server) filterBlacklistedFromTopology(data map[string]interface{}) map[string]interface{} {
|
||||
// Filter TopRepeaters
|
||||
if repeaters, ok := data["topRepeaters"]; ok {
|
||||
@@ -3169,6 +3246,9 @@ func (s *Server) filterBlacklistedFromTopology(data map[string]interface{}) map[
|
||||
if pk, ok := r.Pubkey.(string); ok && s.cfg.IsBlacklisted(pk) {
|
||||
continue
|
||||
}
|
||||
if name, ok := r.Name.(string); ok && s.cfg.IsNameHidden(name) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
data["topRepeaters"] = filtered
|
||||
@@ -3186,6 +3266,12 @@ func (s *Server) filterBlacklistedFromTopology(data map[string]interface{}) map[
|
||||
if pkB, ok := p.PubkeyB.(string); ok && s.cfg.IsBlacklisted(pkB) {
|
||||
continue
|
||||
}
|
||||
if nameA, ok := p.NameA.(string); ok && s.cfg.IsNameHidden(nameA) {
|
||||
continue
|
||||
}
|
||||
if nameB, ok := p.NameB.(string); ok && s.cfg.IsNameHidden(nameB) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
data["topPairs"] = filtered
|
||||
@@ -3200,6 +3286,9 @@ func (s *Server) filterBlacklistedFromTopology(data map[string]interface{}) map[
|
||||
if pk, ok := p.Pubkey.(string); ok && s.cfg.IsBlacklisted(pk) {
|
||||
continue
|
||||
}
|
||||
if pk, ok := p.Pubkey.(string); ok && s.isPubkeyHidden(pk) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
data["bestPathList"] = filtered
|
||||
@@ -3214,6 +3303,9 @@ func (s *Server) filterBlacklistedFromTopology(data map[string]interface{}) map[
|
||||
if pk, ok := n.Pubkey.(string); ok && s.cfg.IsBlacklisted(pk) {
|
||||
continue
|
||||
}
|
||||
if name, ok := n.Name.(string); ok && s.cfg.IsNameHidden(name) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, n)
|
||||
}
|
||||
data["multiObsNodes"] = filtered
|
||||
@@ -3230,6 +3322,9 @@ func (s *Server) filterBlacklistedFromTopology(data map[string]interface{}) map[
|
||||
if pk, ok := rn.Pubkey.(string); ok && s.cfg.IsBlacklisted(pk) {
|
||||
continue
|
||||
}
|
||||
if name, ok := rn.Name.(string); ok && s.cfg.IsNameHidden(name) {
|
||||
continue
|
||||
}
|
||||
filteredNodes = append(filteredNodes, rn)
|
||||
}
|
||||
v.Rings[ri].Nodes = filteredNodes
|
||||
@@ -3253,7 +3348,7 @@ func (s *Server) filterBlacklistedFromSubpaths(data map[string]interface{}) map[
|
||||
if hops, ok := m["hops"].([]interface{}); ok {
|
||||
skip := false
|
||||
for _, h := range hops {
|
||||
if hp, ok := h.(string); ok && s.cfg.IsBlacklisted(hp) {
|
||||
if hp, ok := h.(string); ok && (s.cfg.IsBlacklisted(hp) || s.isPubkeyHidden(hp)) {
|
||||
skip = true
|
||||
break
|
||||
}
|
||||
|
||||
@@ -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