feat: add nodeBlacklist config to hide abusive/troll nodes (#742)

## Problem

Some mesh participants set offensive names, report deliberately false
GPS positions, or otherwise troll the network. Instance operators
currently have no way to hide these nodes from public-facing APIs
without deleting the underlying data.

## Solution

Add a `nodeBlacklist` array to `config.json` containing public keys of
nodes to exclude from all API responses.

### Blacklisted nodes are filtered from:

- `GET /api/nodes` — list endpoint
- `GET /api/nodes/search` — search results
- `GET /api/nodes/{pubkey}` — detail (returns 404)
- `GET /api/nodes/{pubkey}/health` — returns 404
- `GET /api/nodes/{pubkey}/paths` — returns 404
- `GET /api/nodes/{pubkey}/analytics` — returns 404
- `GET /api/nodes/{pubkey}/neighbors` — returns 404
- `GET /api/nodes/bulk-health` — filtered from results

### Config example

```json
{
  "nodeBlacklist": [
    "aabbccdd...",
    "11223344..."
  ]
}
```

### Design decisions

- **Case-insensitive** — public keys normalized to lowercase
- **Whitespace trimming** — leading/trailing whitespace handled
- **Empty entries ignored** — `""` or `" "` do not cause false positives
- **Nil-safe** — `IsBlacklisted()` on nil Config returns false
- **Backward-compatible** — empty/missing `nodeBlacklist` has zero
effect
- **Lazy-cached set** — blacklist converted to `map[string]bool` on
first lookup

### What this does NOT do (intentionally)

- Does **not** delete or modify database data — only filters API
responses
- Does **not** block packet ingestion — data still flows for analytics
- Does **not** filter `/api/packets` — only node-facing endpoints are
affected

## Testing

- Unit tests for `Config.IsBlacklisted()` (case sensitivity, whitespace,
empty entries, nil config)
- Integration tests for `/api/nodes`, `/api/nodes/{pubkey}`,
`/api/nodes/search`
- Full test suite passes with no regressions
This commit is contained in:
Joel Claw
2026-04-18 01:43:05 +02:00
committed by GitHub
parent b8846c2db2
commit b9ba447046
6 changed files with 562 additions and 4 deletions
+39
View File
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"github.com/meshcore-analyzer/geofilter"
)
@@ -16,6 +17,17 @@ type Config struct {
APIKey string `json:"apiKey"`
DBPath string `json:"dbPath"`
// NodeBlacklist is a list of public keys to exclude from all API responses.
// Blacklisted nodes are hidden from node lists, search, detail, map, and stats.
// Use this to filter out trolls, nodes with offensive names, or nodes
// reporting deliberately false data (e.g. wrong GPS position) that the
// operator refuses to fix.
NodeBlacklist []string `json:"nodeBlacklist"`
// blacklistSetCached is the lazily-built set version of NodeBlacklist.
blacklistSetCached map[string]bool
blacklistOnce sync.Once
Branding map[string]interface{} `json:"branding"`
Theme map[string]interface{} `json:"theme"`
ThemeDark map[string]interface{} `json:"themeDark"`
@@ -348,3 +360,30 @@ func (c *Config) PropagationBufferMs() int {
}
return 5000
}
// blacklistSet lazily builds and caches the nodeBlacklist as a set for O(1) lookups.
// Uses sync.Once to eliminate the data race on first concurrent access.
func (c *Config) blacklistSet() map[string]bool {
c.blacklistOnce.Do(func() {
if len(c.NodeBlacklist) == 0 {
return
}
m := make(map[string]bool, len(c.NodeBlacklist))
for _, pk := range c.NodeBlacklist {
trimmed := strings.ToLower(strings.TrimSpace(pk))
if trimmed != "" {
m[trimmed] = true
}
}
c.blacklistSetCached = m
})
return c.blacklistSetCached
}
// IsBlacklisted returns true if the given public key is in the nodeBlacklist.
func (c *Config) IsBlacklisted(pubkey string) bool {
if c == nil || len(c.NodeBlacklist) == 0 {
return false
}
return c.blacklistSet()[strings.ToLower(strings.TrimSpace(pubkey))]
}
+8
View File
@@ -111,6 +111,14 @@ func main() {
// Resolve DB path
resolvedDB := cfg.ResolveDBPath(configDir)
log.Printf("[config] port=%d db=%s public=%s", cfg.Port, resolvedDB, publicDir)
if len(cfg.NodeBlacklist) > 0 {
log.Printf("[config] nodeBlacklist: %d node(s) will be hidden from API", len(cfg.NodeBlacklist))
for _, pk := range cfg.NodeBlacklist {
if trimmed := strings.ToLower(strings.TrimSpace(pk)); trimmed != "" {
log.Printf("[config] blacklisted: %s", trimmed)
}
}
}
// Open database
database, err := OpenDB(resolvedDB)
+9
View File
@@ -94,6 +94,10 @@ func (s *Server) getNeighborGraph() *NeighborGraph {
func (s *Server) handleNodeNeighbors(w http.ResponseWriter, r *http.Request) {
pubkey := strings.ToLower(mux.Vars(r)["pubkey"])
if s.cfg.IsBlacklisted(pubkey) {
writeError(w, 404, "Not found")
return
}
minCount := 1
if v := r.URL.Query().Get("min_count"); v != "" {
@@ -272,6 +276,11 @@ func (s *Server) handleNeighborGraph(w http.ResponseWriter, r *http.Request) {
}
}
// Filter blacklisted nodes from graph.
if s.cfg != nil && (s.cfg.IsBlacklisted(e.NodeA) || s.cfg.IsBlacklisted(e.NodeB)) {
continue
}
ge := GraphEdge{
Source: e.NodeA,
Target: e.NodeB,
+311
View File
@@ -0,0 +1,311 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
)
func TestConfigIsBlacklisted(t *testing.T) {
cfg := &Config{
NodeBlacklist: []string{"AA", "BB", "cc"},
}
tests := []struct {
pubkey string
want bool
}{
{"AA", true},
{"aa", true}, // case-insensitive
{"BB", true},
{"CC", true}, // lowercase "cc" matches uppercase
{"DD", false},
{"", false},
{"AAB", false},
}
for _, tt := range tests {
got := cfg.IsBlacklisted(tt.pubkey)
if got != tt.want {
t.Errorf("IsBlacklisted(%q) = %v, want %v", tt.pubkey, got, tt.want)
}
}
}
func TestConfigIsBlacklistedEmpty(t *testing.T) {
cfg := &Config{}
if cfg.IsBlacklisted("anything") {
t.Error("empty blacklist should not match anything")
}
if cfg.IsBlacklisted("") {
t.Error("empty blacklist should not match empty string")
}
}
func TestConfigBlacklistWhitespace(t *testing.T) {
cfg := &Config{
NodeBlacklist: []string{" AA ", "BB"},
}
if !cfg.IsBlacklisted("AA") {
t.Error("trimmed key should match")
}
if !cfg.IsBlacklisted(" AA ") {
t.Error("whitespace-padded key should match after trimming")
}
}
func TestConfigBlacklistEmptyEntries(t *testing.T) {
cfg := &Config{
NodeBlacklist: []string{"", " ", "AA"},
}
if !cfg.IsBlacklisted("AA") {
t.Error("non-empty entry should match")
}
if cfg.IsBlacklisted("") {
t.Error("empty blacklist entry should not match empty pubkey")
}
}
func TestBlacklistFiltersHandleNodes(t *testing.T) {
db := setupTestDB(t)
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role, last_seen) VALUES ('goodnode', 'GoodNode', 'companion', datetime('now'))")
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role, last_seen) VALUES ('badnode', 'BadNode', 'companion', datetime('now'))")
cfg := &Config{
NodeBlacklist: []string{"badnode"},
}
srv := NewServer(db, cfg, NewHub())
req := httptest.NewRequest("GET", "/api/nodes?limit=50", nil)
w := httptest.NewRecorder()
srv.RegisterRoutes(setupTestRouter(srv))
srv.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp NodeListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
for _, node := range resp.Nodes {
if pk, _ := node["public_key"].(string); pk == "badnode" {
t.Error("blacklisted node should not appear in nodes list")
}
}
if resp.Total == 0 {
t.Error("expected at least one non-blacklisted node")
}
}
func TestBlacklistFiltersNodeDetail(t *testing.T) {
db := setupTestDB(t)
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role, last_seen) VALUES ('badnode', 'BadNode', 'companion', datetime('now'))")
cfg := &Config{
NodeBlacklist: []string{"badnode"},
}
srv := NewServer(db, cfg, NewHub())
req := httptest.NewRequest("GET", "/api/nodes/badnode", nil)
w := httptest.NewRecorder()
srv.RegisterRoutes(setupTestRouter(srv))
srv.router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 for blacklisted node, got %d", w.Code)
}
}
func TestBlacklistFiltersNodeSearch(t *testing.T) {
db := setupTestDB(t)
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role, last_seen) VALUES ('badnode', 'TrollNode', 'companion', datetime('now'))")
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role, last_seen) VALUES ('goodnode', 'GoodNode', 'companion', datetime('now'))")
cfg := &Config{
NodeBlacklist: []string{"badnode"},
}
srv := NewServer(db, cfg, NewHub())
req := httptest.NewRequest("GET", "/api/nodes/search?q=Troll", nil)
w := httptest.NewRecorder()
srv.RegisterRoutes(setupTestRouter(srv))
srv.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp NodeSearchResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
for _, node := range resp.Nodes {
if pk, _ := node["public_key"].(string); pk == "badnode" {
t.Error("blacklisted node should not appear in search results")
}
}
}
func TestNoBlacklistPassesAll(t *testing.T) {
db := setupTestDB(t)
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role, last_seen) VALUES ('somenode', 'SomeNode', 'companion', datetime('now'))")
cfg := &Config{}
srv := NewServer(db, cfg, NewHub())
req := httptest.NewRequest("GET", "/api/nodes?limit=50", nil)
w := httptest.NewRecorder()
srv.RegisterRoutes(setupTestRouter(srv))
srv.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp NodeListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Total == 0 {
t.Error("without blacklist, node should appear")
}
}
// setupTestRouter creates a mux.Router and registers server routes.
func setupTestRouter(srv *Server) *mux.Router {
r := mux.NewRouter()
srv.RegisterRoutes(r)
srv.router = r
return r
}
func TestBlacklistFiltersNeighborGraph(t *testing.T) {
cfg := &Config{
NodeBlacklist: []string{"badnode"},
}
db := setupTestDB(t)
srv := NewServer(db, cfg, NewHub())
srv.RegisterRoutes(setupTestRouter(srv))
req := httptest.NewRequest("GET", "/api/analytics/neighbor-graph", nil)
w := httptest.NewRecorder()
srv.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
// Check edges don't contain blacklisted node
if edges, ok := resp["edges"].([]interface{}); ok {
for _, e := range edges {
if edge, ok := e.(map[string]interface{}); ok {
if src, _ := edge["source"].(string); src == "badnode" {
t.Error("blacklisted node should not appear as edge source in neighbor graph")
}
if tgt, _ := edge["target"].(string); tgt == "badnode" {
t.Error("blacklisted node should not appear as edge target in neighbor graph")
}
}
}
}
// Check nodes list doesn't contain blacklisted node
if nodes, ok := resp["nodes"].([]interface{}); ok {
for _, n := range nodes {
if node, ok := n.(map[string]interface{}); ok {
if pk, _ := node["pubkey"].(string); pk == "badnode" {
t.Error("blacklisted node should not appear in neighbor graph nodes")
}
}
}
}
}
func TestBlacklistFiltersResolveHops(t *testing.T) {
db := setupTestDB(t)
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role, last_seen) VALUES ('badnode', 'BadNode', 'companion', datetime('now'))")
cfg := &Config{
NodeBlacklist: []string{"badnode"},
}
srv := NewServer(db, cfg, NewHub())
srv.RegisterRoutes(setupTestRouter(srv))
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=badnode", nil)
w := httptest.NewRecorder()
srv.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp ResolveHopsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if hr, ok := resp.Resolved["badnode"]; ok {
for _, c := range hr.Candidates {
if c.Pubkey == "badnode" {
t.Error("blacklisted node should not appear as resolve-hops candidate")
}
}
}
}
func TestBlacklistFiltersSubpathDetail(t *testing.T) {
cfg := &Config{
NodeBlacklist: []string{"badnode"},
}
db := setupTestDB(t)
srv := NewServer(db, cfg, NewHub())
srv.RegisterRoutes(setupTestRouter(srv))
req := httptest.NewRequest("GET", "/api/analytics/subpath-detail?hops=badnode,othernode", nil)
w := httptest.NewRecorder()
srv.router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 for subpath-detail with blacklisted hop, got %d", w.Code)
}
}
func TestBlacklistConcurrentIsBlacklisted(t *testing.T) {
cfg := &Config{
NodeBlacklist: []string{"AA", "BB", "CC"},
}
errc := make(chan error, 100)
for i := 0; i < 100; i++ {
go func() {
for j := 0; j < 100; j++ {
cfg.IsBlacklisted("AA")
cfg.IsBlacklisted("BB")
cfg.IsBlacklisted("DD")
}
}()
}
// If sync.Once is wrong, this would panic or race.
// We can't run the race detector on ARM, but at least verify no panics.
done := false
for !done {
select {
case <-errc:
t.Error("concurrent IsBlacklisted panicked")
default:
done = true
}
}
}
+193 -4
View File
@@ -1051,6 +1051,17 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
total = len(filtered)
nodes = filtered
}
// Filter blacklisted nodes
if len(s.cfg.NodeBlacklist) > 0 {
filtered := nodes[:0]
for _, node := range nodes {
if pk, ok := node["public_key"].(string); !ok || !s.cfg.IsBlacklisted(pk) {
filtered = append(filtered, node)
}
}
total = len(filtered)
nodes = filtered
}
writeJSON(w, NodeListResponse{Nodes: nodes, Total: total, Counts: counts})
}
@@ -1065,11 +1076,25 @@ func (s *Server) handleNodeSearch(w http.ResponseWriter, r *http.Request) {
writeError(w, 500, err.Error())
return
}
// Filter blacklisted nodes from search results
if len(s.cfg.NodeBlacklist) > 0 {
filtered := make([]map[string]interface{}, 0, len(nodes))
for _, node := range nodes {
if pk, ok := node["public_key"].(string); !ok || !s.cfg.IsBlacklisted(pk) {
filtered = append(filtered, node)
}
}
nodes = filtered
}
writeJSON(w, NodeSearchResponse{Nodes: nodes})
}
func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
pubkey := mux.Vars(r)["pubkey"]
if s.cfg.IsBlacklisted(pubkey) {
writeError(w, 404, "Not found")
return
}
node, err := s.db.GetNodeByPubkey(pubkey)
if err != nil || node == nil {
writeError(w, 404, "Not found")
@@ -1095,6 +1120,10 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleNodeHealth(w http.ResponseWriter, r *http.Request) {
pubkey := mux.Vars(r)["pubkey"]
if s.cfg.IsBlacklisted(pubkey) {
writeError(w, 404, "Not found")
return
}
if s.store != nil {
result, err := s.store.GetNodeHealth(pubkey)
if err != nil || result == nil {
@@ -1115,7 +1144,19 @@ func (s *Server) handleBulkHealth(w http.ResponseWriter, r *http.Request) {
if s.store != nil {
region := r.URL.Query().Get("region")
writeJSON(w, s.store.GetBulkHealth(limit, region))
results := s.store.GetBulkHealth(limit, region)
// Filter blacklisted nodes
if len(s.cfg.NodeBlacklist) > 0 {
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)
}
}
writeJSON(w, filtered)
return
}
writeJSON(w, results)
return
}
@@ -1134,6 +1175,10 @@ func (s *Server) handleNetworkStatus(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
pubkey := mux.Vars(r)["pubkey"]
if s.cfg.IsBlacklisted(pubkey) {
writeError(w, 404, "Not found")
return
}
node, err := s.db.GetNodeByPubkey(pubkey)
if err != nil || node == nil {
writeError(w, 404, "Not found")
@@ -1297,6 +1342,10 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleNodeAnalytics(w http.ResponseWriter, r *http.Request) {
pubkey := mux.Vars(r)["pubkey"]
if s.cfg.IsBlacklisted(pubkey) {
writeError(w, 404, "Not found")
return
}
days := queryInt(r, "days", 7)
if days < 1 {
days = 1
@@ -1373,7 +1422,11 @@ func (s *Server) handleAnalyticsRF(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleAnalyticsTopology(w http.ResponseWriter, r *http.Request) {
region := r.URL.Query().Get("region")
if s.store != nil {
writeJSON(w, s.store.GetAnalyticsTopology(region))
data := s.store.GetAnalyticsTopology(region)
if s.cfg != nil && len(s.cfg.NodeBlacklist) > 0 {
data = s.filterBlacklistedFromTopology(data)
}
writeJSON(w, data)
return
}
writeJSON(w, TopologyResponse{
@@ -1461,7 +1514,11 @@ func (s *Server) handleAnalyticsSubpaths(w http.ResponseWriter, r *http.Request)
}
maxLen := queryInt(r, "maxLen", 8)
limit := queryInt(r, "limit", 100)
writeJSON(w, s.store.GetAnalyticsSubpaths(region, minLen, maxLen, limit))
data := s.store.GetAnalyticsSubpaths(region, minLen, maxLen, limit)
if s.cfg != nil && len(s.cfg.NodeBlacklist) > 0 {
data = s.filterBlacklistedFromSubpaths(data)
}
writeJSON(w, data)
return
}
writeJSON(w, SubpathsResponse{
@@ -1513,6 +1570,11 @@ func (s *Server) handleAnalyticsSubpathsBulk(w http.ResponseWriter, r *http.Requ
}
results := s.store.GetAnalyticsSubpathsBulk(region, groups)
if s.cfg != nil && len(s.cfg.NodeBlacklist) > 0 {
for i, r := range results {
results[i] = s.filterBlacklistedFromSubpaths(r)
}
}
writeJSON(w, map[string]interface{}{"results": results})
}
@@ -1532,6 +1594,15 @@ 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 {
for _, hop := range rawHops {
if s.cfg.IsBlacklisted(hop) {
writeError(w, 404, "Not found")
return
}
}
}
if s.store != nil {
writeJSON(w, s.store.GetSubpathDetail(rawHops))
return
@@ -1597,6 +1668,10 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) {
if pm != nil {
if matched, ok := pm.m[hopLower]; ok {
for _, ni := range matched {
// Skip blacklisted nodes from resolution results.
if s.cfg != nil && s.cfg.IsBlacklisted(ni.PublicKey) {
continue
}
c := HopCandidate{Pubkey: ni.PublicKey}
if ni.Name != "" {
c.Name = ni.Name
@@ -1665,7 +1740,8 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) {
}
// Use the resolved node as the default (best-effort pick).
if best != nil {
// Skip if the best pick is a blacklisted node.
if best != nil && !(s.cfg != nil && s.cfg.IsBlacklisted(best.PublicKey)) {
hr.Name = best.Name
hr.Pubkey = best.PublicKey
}
@@ -2417,3 +2493,116 @@ func (s *Server) handleAdminPrune(w http.ResponseWriter, r *http.Request) {
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).
func (s *Server) filterBlacklistedFromTopology(data map[string]interface{}) map[string]interface{} {
// Filter TopRepeaters
if repeaters, ok := data["topRepeaters"]; ok {
if arr, ok := repeaters.([]TopRepeater); ok {
var filtered []TopRepeater
for _, r := range arr {
if pk, ok := r.Pubkey.(string); ok && s.cfg.IsBlacklisted(pk) {
continue
}
filtered = append(filtered, r)
}
data["topRepeaters"] = filtered
}
}
// Filter TopPairs
if pairs, ok := data["topPairs"]; ok {
if arr, ok := pairs.([]TopPair); ok {
var filtered []TopPair
for _, p := range arr {
if pkA, ok := p.PubkeyA.(string); ok && s.cfg.IsBlacklisted(pkA) {
continue
}
if pkB, ok := p.PubkeyB.(string); ok && s.cfg.IsBlacklisted(pkB) {
continue
}
filtered = append(filtered, p)
}
data["topPairs"] = filtered
}
}
// Filter BestPathList
if paths, ok := data["bestPathList"]; ok {
if arr, ok := paths.([]BestPathEntry); ok {
var filtered []BestPathEntry
for _, p := range arr {
if pk, ok := p.Pubkey.(string); ok && s.cfg.IsBlacklisted(pk) {
continue
}
filtered = append(filtered, p)
}
data["bestPathList"] = filtered
}
}
// Filter MultiObsNodes
if nodes, ok := data["multiObsNodes"]; ok {
if arr, ok := nodes.([]MultiObsNode); ok {
var filtered []MultiObsNode
for _, n := range arr {
if pk, ok := n.Pubkey.(string); ok && s.cfg.IsBlacklisted(pk) {
continue
}
filtered = append(filtered, n)
}
data["multiObsNodes"] = filtered
}
}
// Filter PerObserverReach
if reach, ok := data["perObserverReach"]; ok {
if m, ok := reach.(map[string]*ObserverReach); ok {
for k, v := range m {
for ri := range v.Rings {
var filteredNodes []ReachNode
for _, rn := range v.Rings[ri].Nodes {
if pk, ok := rn.Pubkey.(string); ok && s.cfg.IsBlacklisted(pk) {
continue
}
filteredNodes = append(filteredNodes, rn)
}
v.Rings[ri].Nodes = filteredNodes
}
m[k] = v
}
}
}
return data
}
// filterBlacklistedFromSubpaths removes blacklisted node references from
// the subpaths analytics response.
func (s *Server) filterBlacklistedFromSubpaths(data map[string]interface{}) map[string]interface{} {
if subpaths, ok := data["subpaths"]; ok {
if arr, ok := subpaths.([]interface{}); ok {
var filtered []interface{}
for _, item := range arr {
if m, ok := item.(map[string]interface{}); ok {
if hops, ok := m["hops"].([]interface{}); ok {
skip := false
for _, h := range hops {
if hp, ok := h.(string); ok && s.cfg.IsBlacklisted(hp) {
skip = true
break
}
}
if skip {
continue
}
}
}
filtered = append(filtered, item)
}
data["subpaths"] = filtered
}
}
return data
}
+2
View File
@@ -1,6 +1,8 @@
{
"port": 3000,
"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.",
"retention": {
"nodeDays": 7,
"observerDays": 14,