mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-09 21:31:42 +00:00
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:
@@ -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."),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user