mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-11 15:11:43 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e79a0d8d28 | |||
| d09038528b |
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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