fix(#1181): hide nodes whose name starts with a configured prefix (#1655)

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:
Kpa-clawbot
2026-06-11 10:10:12 -07:00
committed by GitHub
parent e04c7113cb
commit 825b26485c
7 changed files with 543 additions and 13 deletions
+89
View File
@@ -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()
}
+139
View File
@@ -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())
}
}
+8
View File
@@ -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,
+4
View File
@@ -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
View File
@@ -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
}
+2
View File
@@ -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": {