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.
This commit is contained in:
dborup
2026-07-22 18:08:32 +02:00
parent 83931e8ec3
commit 54fef113db
3 changed files with 177 additions and 5 deletions
@@ -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")
})
}
+2
View File
@@ -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"},
+76 -5
View File
@@ -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 {