Compare commits

...

2 Commits

Author SHA1 Message Date
Kpa-clawbot e79a0d8d28 fix(#1181): hide nodes whose name starts with a configured prefix
Adds Config.IsNameHidden plus filters in handleNodes, handleNodeSearch
and handleNodeDetail that drop any node whose name starts with one of
the operator-configured HiddenNamePrefixes (default ["🚫"]).

Mirrors the IsBlacklisted pattern: the filter runs at the API layer
only — DB rows and observation history (paths, hops, distances) stay
intact, so the node simply re-appears if the operator clears the
prefix list. The node is NOT hidden from the mesh itself, only from
this dashboard.

Updates config.example.json with the new field and its operator-facing
comment per the AGENTS.md config documentation rule.

Fixes #1181.
2026-06-11 12:29:13 +00:00
Kpa-clawbot d09038528b test(#1181): failing test for HiddenNamePrefixes filter
Adds TestHiddenNamePrefix_1181_NodesList and _Search asserting that
nodes with names starting with a configured prefix (default 🚫) are
omitted from /api/nodes and /api/nodes/search, while DB rows are
preserved (no DB-level filtering).

Red commit: HiddenNamePrefixes field exists on Config (so the test
compiles), but no enforcement is wired into the API handlers yet,
so both subtests fail at the leak assertion. Green commit follows.
2026-06-11 12:25:35 +00:00
4 changed files with 160 additions and 1 deletions
+29
View File
@@ -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
@@ -720,6 +729,26 @@ 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
}
// 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,97 @@
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)
}
}
}
+32 -1
View File
@@ -1348,6 +1348,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
@@ -1400,6 +1414,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})
}
@@ -1438,7 +1463,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
}
+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": {