From bd0a58e14cfefdb3cb2e0342badcbc47ec97286b Mon Sep 17 00:00:00 2001 From: "Michael J. Arcan" Date: Tue, 7 Jul 2026 09:12:44 +0200 Subject: [PATCH] feat(api): add unscoped_relay_count_24h per-node field (#1823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Adds a per-node API field `unscoped_relay_count_24h` on repeater/room nodes: the number of the node's 24h relay-hops that were unscoped floods (route_type == ROUTE_TYPE_FLOOD). A strict subset of relay_count_24h. ## Why A well-configured repeater runs `flood.max.unscoped 0` and should not rebroadcast unscoped floods — each one is re-sent by every repeater that hears it, so one packet turns into mesh-wide traffic. Exposing this lets clients (the ArcScope repeater advisor) detect and flag that base-config problem from observed packets. ## How Computed like relay_count_24h in both paths (bulk /api/nodes + per-node detail) with a route_type==FLOOD filter; reuses the byPathHop index, no migration. Wired into both handlers + OpenAPI schema + unit tests (per-node and bulk). Co-authored-by: Waydroid Builder --- cmd/server/openapi.go | 1 + cmd/server/openapi_node_schema_test.go | 2 +- cmd/server/repeater_enrich_bulk.go | 3 ++ cmd/server/repeater_liveness.go | 23 +++++++- cmd/server/repeater_liveness_test.go | 75 ++++++++++++++++++++++++++ cmd/server/routes.go | 2 + 6 files changed, 104 insertions(+), 2 deletions(-) diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 7f337944..7e116203 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -172,6 +172,7 @@ func componentSchemas() map[string]interface{} { "relay_active": map[string]interface{}{"type": "boolean", "description": "Repeater/room only: relayed traffic within the active window."}, "relay_count_1h": map[string]interface{}{"type": "integer", "description": "Repeater/room only: relay-hop appearances in the last hour."}, "relay_count_24h": map[string]interface{}{"type": "integer", "description": "Repeater/room only: relay-hop appearances in the last 24 hours."}, + "unscoped_relay_count_24h": map[string]interface{}{"type": "integer", "description": "Repeater/room only: subset of relay_count_24h that were unscoped floods (route_type FLOOD). A well-configured repeater sets flood.max.unscoped 0, so a non-trivial count flags a base-config problem."}, "last_relayed": str("Repeater/room only: RFC3339 time this node last appeared as a relay hop."), "relay_window_hours": map[string]interface{}{"type": "integer", "description": "Repeater/room only, /api/nodes/{pubkey} detail endpoint only: width (hours) of the relay-activity window the relay_count_* values cover."}, "traffic_share_score": score01("#672 Traffic axis: share of non-advert traffic relayed through this repeater. Repeater/room only."), diff --git a/cmd/server/openapi_node_schema_test.go b/cmd/server/openapi_node_schema_test.go index 40b9270e..aca55b45 100644 --- a/cmd/server/openapi_node_schema_test.go +++ b/cmd/server/openapi_node_schema_test.go @@ -75,7 +75,7 @@ func TestOpenAPINodeSchema_Metrics(t *testing.T) { } // Relay-activity fields are documented too. - for _, field := range []string{"relay_active", "relay_count_1h", "relay_count_24h", "last_relayed"} { + for _, field := range []string{"relay_active", "relay_count_1h", "relay_count_24h", "unscoped_relay_count_24h", "last_relayed"} { if _, ok := props[field]; !ok { t.Errorf("Node.properties.%s missing", field) } diff --git a/cmd/server/repeater_enrich_bulk.go b/cmd/server/repeater_enrich_bulk.go index e063cd4d..220d4720 100644 --- a/cmd/server/repeater_enrich_bulk.go +++ b/cmd/server/repeater_enrich_bulk.go @@ -163,6 +163,9 @@ func (s *PacketStore) computeRepeaterRelayInfoMap(windowHours float64) map[strin } if p.t.After(cutoff24h) { info.RelayCount24h++ + if tx.RouteType != nil && *tx.RouteType == routeTypeFlood { + info.UnscopedRelayCount24h++ + } if p.t.After(cutoff1h) { info.RelayCount1h++ } diff --git a/cmd/server/repeater_liveness.go b/cmd/server/repeater_liveness.go index 8238d5f1..e2d44152 100644 --- a/cmd/server/repeater_liveness.go +++ b/cmd/server/repeater_liveness.go @@ -27,6 +27,11 @@ type RepeaterRelayInfo struct { // RelayCount24h is the count of distinct non-advert packets where this // pubkey appeared as a relay hop in the last 24 hours. RelayCount24h int `json:"relayCount24h"` + // UnscopedRelayCount24h is the subset of RelayCount24h that were UNSCOPED + // floods (route_type == ROUTE_TYPE_FLOOD). A well-configured repeater runs + // `flood.max.unscoped 0` and should not forward these, so a non-trivial + // count flags a base-config problem (consumed by the ArcScope advisor). + UnscopedRelayCount24h int `json:"unscopedRelayCount24h"` // TransportedScopes is the deduplicated, sorted set of region scope // names (transmissions.scope_name) across ALL non-advert packets in // which this pubkey appears as a path hop. Unlike RelayCount1h/24h this @@ -69,6 +74,12 @@ func sortedCappedScopes(set map[string]struct{}) []string { // is forwarding traffic for other nodes. const payloadTypeAdvert = 4 +// routeTypeFlood is ROUTE_TYPE_FLOOD from the MeshCore packet header (the low 2 +// bits of the header byte). Equal to packetpath.RouteFlood; kept as a local +// literal to avoid importing packetpath here. An "unscoped flood" is a +// route-type-FLOOD packet — the traffic `flood.max.unscoped` governs. +const routeTypeFlood = 1 + // parseRelayTS attempts to parse a packet first-seen timestamp using the // formats CoreScope writes in practice. Returns zero time and false on // failure. Accepted (in order): @@ -97,6 +108,9 @@ func parseRelayTS(ts string) (time.Time, bool) { type relayEntry struct { ts string pt int + // rt is the tx route type (transmissions.route_type), or -1 when absent. + // rt == routeTypeFlood marks an unscoped flood (UnscopedRelayCount24h). + rt int // scope is the tx's region scope name (transmissions.scope_name). // Empty when absent / on older schemas. Used for TransportedScopes (#1751). scope string @@ -143,7 +157,11 @@ func (s *PacketStore) collectRelayEntriesLocked(key string) []relayEntry { if tx.PayloadType != nil { pt = *tx.PayloadType } - entries = append(entries, relayEntry{ts: tx.FirstSeen, pt: pt, scope: tx.ScopeName}) + rt := -1 + if tx.RouteType != nil { + rt = *tx.RouteType + } + entries = append(entries, relayEntry{ts: tx.FirstSeen, pt: pt, rt: rt, scope: tx.ScopeName}) } } collect(txList) @@ -187,6 +205,9 @@ func computeRelayInfoFromEntries(entries []relayEntry, windowHours float64) Repe } if t.After(cutoff24h) { info.RelayCount24h++ + if e.rt == routeTypeFlood { + info.UnscopedRelayCount24h++ + } if t.After(cutoff1h) { info.RelayCount1h++ } diff --git a/cmd/server/repeater_liveness_test.go b/cmd/server/repeater_liveness_test.go index 542b8d4f..71c456dc 100644 --- a/cmd/server/repeater_liveness_test.go +++ b/cmd/server/repeater_liveness_test.go @@ -52,6 +52,43 @@ func TestRepeaterRelayActivity_Active(t *testing.T) { } } +// TestRepeaterUnscopedRelayCount verifies that UnscopedRelayCount24h counts only +// route_type==FLOOD (unscoped) relay hops, as a subset of RelayCount24h. +func TestRepeaterUnscopedRelayCount(t *testing.T) { + db := setupCapabilityTestDB(t) + defer db.conn.Close() + + pubkey := "aabbccdd11223344" + db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)", + pubkey, "RepUnscoped", "repeater", recentTS(1)) + + store := NewPacketStore(db, nil) + + pt := 1 // non-advert (TXT_MSG) + flood := routeTypeFlood + direct := 2 // ROUTE_TYPE_DIRECT — scoped/directed, must NOT count as unscoped + mk := func(hash string, rt int) *StoreTx { + return &StoreTx{RawHex: "0100", PayloadType: &pt, RouteType: &rt, PathJSON: `["aa"]`, FirstSeen: recentTS(2), Hash: hash} + } + store.mu.Lock() + for _, tx := range []*StoreTx{mk("unscoped-1", flood), mk("direct-1", direct)} { + tx.ID = len(store.packets) + 1 + store.packets = append(store.packets, tx) + store.byHash[tx.Hash] = tx + store.byTxID[tx.ID] = tx + store.byPathHop[pubkey] = append(store.byPathHop[pubkey], tx) + } + store.mu.Unlock() + + info := store.GetRepeaterRelayInfo(pubkey, 24) + if info.RelayCount24h != 2 { + t.Errorf("expected RelayCount24h=2 (both hops), got %d", info.RelayCount24h) + } + if info.UnscopedRelayCount24h != 1 { + t.Errorf("expected UnscopedRelayCount24h=1 (only the FLOOD hop), got %d", info.UnscopedRelayCount24h) + } +} + // TestRepeaterRelayActivity_Idle verifies that a repeater whose pubkey // has not appeared as a relay hop reports an empty LastRelayed and // relayActive=false. @@ -261,3 +298,41 @@ func TestRepeaterRelayActivity_DedupAcrossPrefixAndFullKey(t *testing.T) { t.Errorf("expected RelayActive=true, got false (LastRelayed=%s)", info.LastRelayed) } } + +// TestRepeaterUnscopedRelayCount_Bulk verifies the bulk /api/nodes path +// (computeRepeaterRelayInfoMap) counts unscoped floods identically to the +// per-node path: only route_type==FLOOD hops, as a subset of RelayCount24h. +func TestRepeaterUnscopedRelayCount_Bulk(t *testing.T) { + db := setupCapabilityTestDB(t) + defer db.conn.Close() + + pubkey := "aabbccdd11223344" + db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)", + pubkey, "RepUnscopedBulk", "repeater", recentTS(1)) + + store := NewPacketStore(db, nil) + + pt := 1 // non-advert + flood := routeTypeFlood + direct := 2 // ROUTE_TYPE_DIRECT — must NOT count as unscoped + mk := func(hash string, rt int) *StoreTx { + return &StoreTx{RawHex: "0100", PayloadType: &pt, RouteType: &rt, PathJSON: `["aa"]`, FirstSeen: recentTS(2), Hash: hash} + } + store.mu.Lock() + for _, tx := range []*StoreTx{mk("bulk-unscoped-1", flood), mk("bulk-direct-1", direct)} { + tx.ID = len(store.packets) + 1 + store.packets = append(store.packets, tx) + store.byHash[tx.Hash] = tx + store.byTxID[tx.ID] = tx + store.byPathHop[pubkey] = append(store.byPathHop[pubkey], tx) + } + store.mu.Unlock() + + info := store.computeRepeaterRelayInfoMap(24)[pubkey] + if info.RelayCount24h != 2 { + t.Errorf("expected RelayCount24h=2 (both hops), got %d", info.RelayCount24h) + } + if info.UnscopedRelayCount24h != 1 { + t.Errorf("expected UnscopedRelayCount24h=1 (only the FLOOD hop), got %d", info.UnscopedRelayCount24h) + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index bc64d01c..eee8c44d 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -1399,6 +1399,7 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { node["relay_active"] = info.RelayActive node["relay_count_1h"] = info.RelayCount1h node["relay_count_24h"] = info.RelayCount24h + node["unscoped_relay_count_24h"] = info.UnscopedRelayCount24h // #1751: region scopes this repeater has transported. // Set only when non-empty so the field is absent for // nodes without scopes / on older schemas. @@ -1596,6 +1597,7 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) { node["relay_window_hours"] = info.WindowHours node["relay_count_1h"] = info.RelayCount1h node["relay_count_24h"] = info.RelayCount24h + node["unscoped_relay_count_24h"] = info.UnscopedRelayCount24h // #1751: region scopes this repeater has transported. Set only // when non-empty (absent for no-scope nodes / older schemas). if len(info.TransportedScopes) > 0 {