feat(api): add unscoped_relay_count_24h per-node field (#1823)

## 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 <build@waydroid.local>
This commit is contained in:
Michael J. Arcan
2026-07-07 09:12:44 +02:00
committed by GitHub
parent ba68069c23
commit bd0a58e14c
6 changed files with 104 additions and 2 deletions
+1
View File
@@ -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."),
+1 -1
View File
@@ -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)
}
+3
View File
@@ -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++
}
+22 -1
View File
@@ -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++
}
+75
View File
@@ -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)
}
}
+2
View File
@@ -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 {