From 54fef113db098de371a6f2ce1e7f1283f7ee29fb Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 22 Jul 2026 18:08:32 +0200 Subject: [PATCH] feat: add hasScope/hashRegion filters to /api/nodes (closes upstream #1862) ?hasScope=true|false keeps only nodes that have/haven't ever transported a region-scoped packet; ?hashRegion=eu,be (comma-separated, leading # optional) keeps only nodes that have transported at least one of the given regions. Both combine with each other (AND) and with the existing ?role= filter, matching the suggested API shape from https://github.com/Kpa-clawbot/CoreScope/issues/1862. Implemented as two new predicates in the existing nodeListPostFilters (same post-SQL-LIMIT compensation-loop machinery geo_filter/blacklist/ area already use), backed by the same bulk relay-info map (GetRepeaterRelayInfoMap) the Scopes tab's "Repeaters Never Relaying Any Scope" section reads -- fetched once per request, not per DB page. --- cmd/server/nodes_hasscope_hashregion_test.go | 99 ++++++++++++++++++++ cmd/server/openapi.go | 2 + cmd/server/routes.go | 81 +++++++++++++++- 3 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 cmd/server/nodes_hasscope_hashregion_test.go diff --git a/cmd/server/nodes_hasscope_hashregion_test.go b/cmd/server/nodes_hasscope_hashregion_test.go new file mode 100644 index 00000000..8858c2e1 --- /dev/null +++ b/cmd/server/nodes_hasscope_hashregion_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + "testing" +) + +// TestHandleNodes_HasScopeAndHashRegion covers issue #1862's suggested +// filters: ?hasScope=true|false and ?hashRegion=#eu,#be on /api/nodes. +// Both read TransportedScopes off the bulk relay-info map (same source as +// the Scopes tab's "Repeaters Never Relaying Any Scope" section) rather +// than any DB column, so the test seeds that cache directly instead of +// reconstructing real path-hop parsing. +func TestHandleNodes_HasScopeAndHashRegion(t *testing.T) { + srv, router := setupTestServer(t) + if _, err := srv.db.conn.Exec(`DELETE FROM nodes`); err != nil { + t.Fatalf("clear nodes: %v", err) + } + + insert := func(pk, name, lastSeen string) { + if _, err := srv.db.conn.Exec(`INSERT INTO nodes + (public_key, name, role, lat, lon, last_seen, first_seen, advert_count) + VALUES (?, ?, 'repeater', 55.0, 10.0, ?, '2026-06-01T00:00:00Z', 1)`, + pk, name, lastSeen); err != nil { + t.Fatalf("insert %s: %v", name, err) + } + } + insert("pk0000000000000a", "RelaysEU", "2026-06-07T00:00:00Z") + insert("pk0000000000000b", "RelaysBoth", "2026-06-06T00:00:00Z") + insert("pk0000000000000c", "NeverRelays", "2026-06-05T00:00:00Z") + + // Seed the relay cache directly (same style as + // TestGetRepeaterRelayInfoMap_ServesStaleOnTTLExpiry) -- lookups are by + // lowercase pubkey. + srv.store.repeaterRelayCache = map[string]RepeaterRelayInfo{ + "pk0000000000000a": {TransportedScopes: []string{"#eu"}}, + "pk0000000000000b": {TransportedScopes: []string{"#eu", "#be"}}, + // pk0000000000000c intentionally absent -- zero-value on lookup. + } + + fetchNames := func(query string) []string { + req := httptest.NewRequest("GET", "/api/nodes?"+query, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("query %q: status=%d body=%s", query, 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("query %q: decode: %v body=%s", query, err, w.Body.String()) + } + names := make([]string, len(resp.Nodes)) + for i, n := range resp.Nodes { + names[i], _ = n["name"].(string) + } + return names + } + + assertNames := func(t *testing.T, query string, want ...string) { + t.Helper() + got := fetchNames(query) + gotSet := map[string]bool{} + for _, n := range got { + gotSet[n] = true + } + if len(got) != len(want) { + t.Errorf("query %q: got %v, want %v", query, got, want) + return + } + for _, w := range want { + if !gotSet[w] { + t.Errorf("query %q: got %v, missing %q", query, got, w) + } + } + } + + t.Run("hasScope=true", func(t *testing.T) { + assertNames(t, "hasScope=true", "RelaysEU", "RelaysBoth") + }) + t.Run("hasScope=false", func(t *testing.T) { + assertNames(t, "hasScope=false", "NeverRelays") + }) + t.Run("hashRegion single, with # and case-insensitive", func(t *testing.T) { + assertNames(t, fmt.Sprintf("hashRegion=%s", "%23EU"), "RelaysEU", "RelaysBoth") + }) + t.Run("hashRegion comma-separated OR match", func(t *testing.T) { + assertNames(t, "hashRegion=be", "RelaysBoth") + }) + t.Run("hasScope and hashRegion combine (AND)", func(t *testing.T) { + assertNames(t, "hasScope=true&hashRegion=be", "RelaysBoth") + }) + t.Run("no filter returns all", func(t *testing.T) { + assertNames(t, "", "RelaysEU", "RelaysBoth", "NeverRelays") + }) +} diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index bd0d1ad2..9ddaba40 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -81,6 +81,8 @@ func routeDescriptions() map[string]routeMeta { {Name: "role", Description: "Filter by node role", Type: "string"}, {Name: "status", Description: "Filter by status (active/stale/offline)", Type: "string"}, {Name: "geoFilter", Description: "Overrides the deployment's geo_filter node-list default for this one request: \"1\"/\"true\" excludes nodes outside the configured geo_filter (unless foreign_advert-tagged), \"0\"/\"false\" returns every node regardless. Any other value (including omitting it) uses the deployment default — geo_filter applies to the node list unless config.json sets geoFilterExemptNodeList=true.", Type: "string"}, + {Name: "hasScope", Description: "Issue #1862. \"true\" keeps only nodes that have ever transported at least one region-scoped (TRANSPORT_FLOOD/DIRECT) packet; \"false\" keeps only nodes that never have. Backed by the same relay-activity signal as the Scopes tab's \"Repeaters Never Relaying Any Scope\" section — pair with role=repeater to match its exact semantics.", Type: "string"}, + {Name: "hashRegion", Description: "Issue #1862. Comma-separated region scope name(s) (e.g. \"eu,be\"; leading \"#\" optional, case-insensitive) — keeps only nodes that have transported at least one of them. Combines with hasScope as AND, not OR.", Type: "string"}, }}, "GET /api/nodes/search": {Summary: "Search nodes", Description: "Search nodes by name or public key prefix.", Tag: "nodes", QueryParams: []paramMeta{{Name: "q", Description: "Search query", Type: "string", Required: true}}}, "GET /api/nodes/bulk-health": {Summary: "Bulk node health", Description: "Returns health status for all nodes in one call.", Tag: "nodes"}, diff --git a/cmd/server/routes.go b/cmd/server/routes.go index f23ba854..e33d7a87 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -1364,17 +1364,43 @@ func (s *Server) handlePostPacket(w http.ResponseWriter, r *http.Request) { // --- Node Handlers --- -// nodeListPostFilters bundles the four filters handleNodes applies AFTER +// nodeListPostFilters bundles the filters handleNodes applies AFTER // the SQL LIMIT/OFFSET page comes back from the DB: geo_filter (#730), -// node blacklist, hidden-name-prefix (#1181), and area membership. Kept -// together so handleNodes' pagination-compensation loop (see below) can -// apply the exact same predicate to every DB page it pulls. +// node blacklist, hidden-name-prefix (#1181), area membership, and (#1862) +// hasScope/hashRegions relay filters. Kept together so handleNodes' +// pagination-compensation loop (see below) can apply the exact same +// predicate to every DB page it pulls. type nodeListPostFilters struct { cfg *Config geoFilter *GeoFilterConfig // resolved once via s.getGeoFilter() — cfgMu guards this field, see routes.go:53 applyGeo bool areaNodes map[string]bool // nil = no area filter active areaRequested bool // true if ?area= was given but resolved to nothing + + // relayMap backs hasScope/hashRegions below — the bulk pubkey -> + // RepeaterRelayInfo map (see GetRepeaterRelayInfoMap), keyed by + // lowercase pubkey. nil when the in-memory store is unavailable (DB- + // only mode): lookups then degrade to a zero-value RepeaterRelayInfo + // (no known transported scopes), same as any pubkey missing from the map. + relayMap map[string]RepeaterRelayInfo + // hasScope implements ?hasScope=true|false (#1862): nil = not + // requested, otherwise a node must have (true) or must not have + // (false) at least one entry in TransportedScopes to match. + hasScope *bool + // hashRegions implements ?hashRegion=#eu,#be (#1862): normalized + // (lowercased, "#" stripped) region names, OR-matched against a + // node's TransportedScopes. nil/empty = not requested. + hashRegions map[string]bool +} + +// relayInfoFor looks up f.relayMap by lowercase pubkey, returning a +// zero-value RepeaterRelayInfo (empty TransportedScopes) for a pubkey with +// no relay activity or when relayMap itself is nil. +func (f nodeListPostFilters) relayInfoFor(pk string) RepeaterRelayInfo { + if f.relayMap == nil { + return RepeaterRelayInfo{} + } + return f.relayMap[strings.ToLower(pk)] } func (f nodeListPostFilters) apply(nodes []map[string]interface{}) []map[string]interface{} { @@ -1410,6 +1436,25 @@ func (f nodeListPostFilters) apply(nodes []map[string]interface{}) []map[string] continue } } + if f.hasScope != nil || len(f.hashRegions) > 0 { + pk, _ := node["public_key"].(string) + info := f.relayInfoFor(pk) + if f.hasScope != nil && (len(info.TransportedScopes) > 0) != *f.hasScope { + continue + } + if len(f.hashRegions) > 0 { + matched := false + for _, rs := range info.TransportedScopes { + if f.hashRegions[strings.ToLower(strings.TrimPrefix(rs, "#"))] { + matched = true + break + } + } + if !matched { + continue + } + } + } out = append(out, node) } return out @@ -1419,7 +1464,8 @@ func (f nodeListPostFilters) apply(nodes []map[string]interface{}) []map[string] // to skip the compensation loop entirely on the (common) unfiltered path. func (f nodeListPostFilters) active() bool { return (f.geoFilter != nil && f.applyGeo) || len(f.cfg.NodeBlacklist) > 0 || - len(f.cfg.HiddenNamePrefixes) > 0 || f.areaRequested + len(f.cfg.HiddenNamePrefixes) > 0 || f.areaRequested || + f.hasScope != nil || len(f.hashRegions) > 0 } func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { @@ -1452,6 +1498,31 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { } filters := nodeListPostFilters{cfg: s.cfg, geoFilter: s.getGeoFilter(), applyGeo: applyGeoFilter} + + // #1862: ?hasScope=true|false and ?hashRegion=#eu,#be filter the node + // list by relay activity — which repeaters have (or haven't) ever + // transported a region-scoped packet, and specifically which + // region(s). Both read the same bulk relay-info map TransportedScopes + // already backs elsewhere (Scopes tab's "Repeaters Never Relaying Any + // Scope"), fetched once here (not per-page) since it's independent of + // pagination. + if hs := q.Get("hasScope"); hs == "true" || hs == "false" { + v := hs == "true" + filters.hasScope = &v + } + if hr := q.Get("hashRegion"); hr != "" { + filters.hashRegions = make(map[string]bool) + for _, part := range strings.Split(hr, ",") { + part = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(part), "#"))) + if part != "" { + filters.hashRegions[part] = true + } + } + } + if s.store != nil && (filters.hasScope != nil || len(filters.hashRegions) > 0) { + filters.relayMap = s.store.GetRepeaterRelayInfoMap(s.cfg.GetHealthThresholds().RelayActiveHours) + } + if area := q.Get("area"); area != "" { filters.areaRequested = true if s.store != nil {