feat(api): flood_advert_count_7d on the node detail endpoint (#1831)

Adds, per node, how many distinct FLOOD adverts it originated in the
last 7 days. Zero-hop adverts (route_type DIRECT) are excluded, so a
nearby observer hearing a node's cheap local adverts does not inflate
the number - the existing advert_count mixes both kinds and cannot tell
a chatty flooder (mesh-wide airtime) from
  the recommended 240-minute zero-hop cadence (local only).

Consumers (the ArcScope repeater advisor) rate advert hygiene against
the community practice of one flood advert every ~49h; with the mixed
total, a correctly configured repeater looked chatty whenever an
observer sat within zero-hop range.

Implemented like the relay-liveness fields: a pure, unit-tested counter
over (first_seen, route_type, hash) entries with the same timestamp
parsing and hash dedup, fed by a from_pubkey-indexed query capped at the
2000 most recent advert rows. The flood route-type constant is named
advertRouteTypeFlood so this merges independently
  of the open unscoped-relay PR (#1823).

---------

Co-authored-by: Waydroid Builder <build@waydroid.local>
This commit is contained in:
Michael J. Arcan
2026-07-08 22:14:41 -07:00
committed by GitHub
co-authored by Waydroid Builder
parent 4f7bb245d4
commit d2ef624c2e
4 changed files with 225 additions and 20 deletions
+81
View File
@@ -0,0 +1,81 @@
package main
import "time"
// advertRouteTypeFlood is ROUTE_TYPE_FLOOD from the MeshCore packet header.
// Named distinctly from the equivalent constant in the (still open) unscoped-
// relay PR so the two changes merge independently.
const advertRouteTypeFlood = 1
// floodAdvertEntry is one advert transmission originated by a node, reduced to
// what the windowed flood-advert count needs: first-seen timestamp, route type
// and packet hash (for dedup across re-ingests / multi-observer rows).
type floodAdvertEntry struct {
ts string
rt int
hash string
}
// countFloodAdverts counts distinct flood adverts (route_type ==
// advertRouteTypeFlood) whose first-seen lies within the past windowHours. Entries
// with unparseable timestamps are skipped, matching relay-liveness behaviour;
// entries without a hash fall back to their timestamp as the dedup key.
func countFloodAdverts(entries []floodAdvertEntry, now time.Time, windowHours float64) int {
cutoff := now.Add(-time.Duration(windowHours * float64(time.Hour)))
seen := map[string]struct{}{}
for _, e := range entries {
if e.rt != advertRouteTypeFlood {
continue
}
t, ok := parseRelayTS(e.ts)
if !ok || !t.After(cutoff) {
continue
}
key := e.hash
if key == "" {
key = e.ts
}
seen[key] = struct{}{}
}
return len(seen)
}
// CountFloodAdvertsForNode returns how many distinct FLOOD adverts pubkey
// originated in the last windowHours - the mesh-wide-airtime kind. Zero-hop
// adverts (route_type DIRECT) are excluded, so a nearby observer hearing a
// node's cheap local adverts does not inflate the number.
//
// route_type is filtered in SQL so an advert-spamming node cannot truncate
// the flood count (review feedback on the earlier LIMIT approach). The time
// floor is a DATE-ONLY string with one day of slack: a date prefix compares
// lexically the same across every first_seen format parseRelayTS accepts
// ('T' and ' ' separators alike); the exact window check stays in Go.
//
// The row cap is a pure safety valve on per-request allocation: it applies to
// flood adverts inside the floor window only, and 50000 in ~8 days is ~4 per
// minute - any node past it is unambiguously a spammer whether the count
// saturates or not. (An exact COUNT cannot move into SQL because the precise
// window check needs parseRelayTS over the mixed first_seen formats.)
// floodAdvertRowCap is the production row cap; tests pass a smaller cap
// directly, so there is no mutable package state to race on.
const floodAdvertRowCap = 50000
func (db *DB) CountFloodAdvertsForNode(pubkey string, windowHours float64, rowCap int) (int, error) {
floor := time.Now().UTC().Add(-time.Duration(windowHours*float64(time.Hour))).AddDate(0, 0, -1).Format("2006-01-02")
rows, err := db.conn.Query(
"SELECT COALESCE(first_seen, ''), COALESCE(route_type, -1), COALESCE(hash, '') FROM transmissions WHERE from_pubkey = ? AND payload_type = ? AND route_type = ? AND first_seen >= ? ORDER BY id DESC LIMIT ?",
pubkey, payloadTypeAdvert, advertRouteTypeFlood, floor, rowCap)
if err != nil {
return 0, err
}
defer rows.Close()
var entries []floodAdvertEntry
for rows.Next() {
var e floodAdvertEntry
if err := rows.Scan(&e.ts, &e.rt, &e.hash); err != nil {
return 0, err
}
entries = append(entries, e)
}
return countFloodAdverts(entries, time.Now(), windowHours), nil
}
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"encoding/json"
"net/http/httptest"
"testing"
"time"
)
// insertAdvertTx seeds one advert transmission row - the single place that
// knows the INSERT column list, shared by every test in this file.
func insertAdvertTx(t *testing.T, db *DB, pubkey, hash string, rt int, ts time.Time) {
t.Helper()
if _, err := db.conn.Exec(
`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, from_pubkey) VALUES ('00', ?, ?, ?, ?, ?)`,
hash, ts.Format("2006-01-02T15:04:05.000Z"), rt, payloadTypeAdvert, pubkey); err != nil {
t.Fatalf("insert transmission: %v", err)
}
}
func advertTS(hoursAgo float64) string {
return time.Now().UTC().Add(-time.Duration(hoursAgo * float64(time.Hour))).Format("2006-01-02T15:04:05.000Z")
}
// Flood adverts inside the window count; zero-hop (DIRECT) and out-of-window
// ones do not; duplicate hashes collapse; broken timestamps are skipped.
func TestCountFloodAdverts(t *testing.T) {
now := time.Now()
entries := []floodAdvertEntry{
{ts: advertTS(1), rt: advertRouteTypeFlood, hash: "a1"},
{ts: advertTS(2), rt: advertRouteTypeFlood, hash: "a1"}, // dup hash: one advert, two rows
{ts: advertTS(3), rt: advertRouteTypeFlood, hash: "a2"},
{ts: advertTS(4), rt: 0, hash: "a3"}, // zero-hop (DIRECT): excluded
{ts: advertTS(9 * 24), rt: advertRouteTypeFlood, hash: "a4"}, // outside 7d window
{ts: "not-a-time", rt: advertRouteTypeFlood, hash: "a5"}, // unparseable: skipped
{ts: advertTS(5), rt: -1, hash: "a6"}, // route type absent: excluded
}
if got := countFloodAdverts(entries, now, 7*24); got != 2 {
t.Fatalf("want 2 flood adverts in window, got %d", got)
}
// Hash-less entries dedup by timestamp instead of collapsing into one.
hashless := []floodAdvertEntry{
{ts: advertTS(1), rt: advertRouteTypeFlood},
{ts: advertTS(2), rt: advertRouteTypeFlood},
}
if got := countFloodAdverts(hashless, now, 7*24); got != 2 {
t.Fatalf("want 2 hash-less flood adverts, got %d", got)
}
}
// The wire contract: GET /api/nodes/{pubkey} carries flood_advert_count_7d,
// counting recent flood adverts only (zero-hop and out-of-window excluded).
func TestNodeDetailIncludesFloodAdvertCount(t *testing.T) {
srv, router := setupTestServer(t)
now := time.Now().UTC()
ins := func(hash string, rt int, ts time.Time) {
insertAdvertTx(t, srv.db, "aabbccdd11223344", hash, rt, ts)
}
// The shared fixture may already seed adverts for this node, so assert the
// DELTA our inserts cause rather than an absolute count.
fetch := func() float64 {
t.Helper()
req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("bad JSON: %v", err)
}
node, ok := body["node"].(map[string]interface{})
if !ok {
t.Fatal("expected node object")
}
got, ok := node["flood_advert_count_7d"].(float64)
if !ok {
t.Fatalf("flood_advert_count_7d missing or not a number: %v", node["flood_advert_count_7d"])
}
return got
}
before := fetch()
ins("fa1", advertRouteTypeFlood, now.Add(-2*time.Hour))
ins("fa2", advertRouteTypeFlood, now.Add(-30*time.Hour))
ins("za1", 0, now.Add(-1*time.Hour)) // zero-hop: excluded
ins("fa3", advertRouteTypeFlood, now.Add(-9*24*time.Hour)) // outside the 7d window
// Inside the SQL date floor (window + 1d slack) but outside the exact 7d
// window - only the Go-side check rejects this one.
ins("fa4", advertRouteTypeFlood, now.Add(-time.Duration(7.5*24)*time.Hour))
if got := fetch(); got != before+2 {
t.Fatalf("want flood_advert_count_7d = %v+2, got %v", before, got)
}
}
// The row cap saturates the count instead of failing: with a cap of 2, three
// qualifying flood adverts count as 2 (newest rows win via ORDER BY id DESC).
func TestCountFloodAdvertsForNode_RowCapSaturates(t *testing.T) {
db := setupTestDB(t)
now := time.Now().UTC()
for i, h := range []string{"cap1", "cap2", "cap3"} {
insertAdvertTx(t, db, "capnode11223344", h, advertRouteTypeFlood, now.Add(-time.Duration(i+1)*time.Hour))
}
n, err := db.CountFloodAdvertsForNode("capnode11223344", 7*24, 2)
if err != nil {
t.Fatalf("count: %v", err)
}
if n != 2 {
t.Fatalf("want saturated count 2, got %d", n)
}
}
+21 -20
View File
@@ -159,27 +159,28 @@ func componentSchemas() map[string]interface{} {
"additionalProperties": true,
"description": "A mesh node. Repeater and room nodes additionally carry the issue #672 usefulness metrics and relay-activity fields below; those fields are absent on other roles. NOTE: coverage_score, redundancy_score and usefulness_grade ship only with the #672 4-axis scorer (PR #1762) and are absent on every build without it; until that lands usefulness_score is aliased to traffic_share_score. Only traffic_share_score and bridge_score ship today.",
"properties": map[string]interface{}{
"public_key": str("Node public key (hex)."),
"name": str("Node display name (most recent advert name)."),
"role": str("Node role (e.g. repeater, room, client, sensor)."),
"lat": map[string]interface{}{"type": "number", "nullable": true},
"lon": map[string]interface{}{"type": "number", "nullable": true},
"last_seen": str("RFC3339 timestamp of the most recent observation."),
"first_seen": str("RFC3339 timestamp of the first observation."),
"advert_count": map[string]interface{}{"type": "integer"},
"battery_mv": map[string]interface{}{"type": "integer", "nullable": true},
"temperature_c": map[string]interface{}{"type": "number", "nullable": true},
"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."},
"public_key": str("Node public key (hex)."),
"name": str("Node display name (most recent advert name)."),
"role": str("Node role (e.g. repeater, room, client, sensor)."),
"lat": map[string]interface{}{"type": "number", "nullable": true},
"lon": map[string]interface{}{"type": "number", "nullable": true},
"last_seen": str("RFC3339 timestamp of the most recent observation."),
"first_seen": str("RFC3339 timestamp of the first observation."),
"advert_count": map[string]interface{}{"type": "integer"},
"flood_advert_count_7d": map[string]interface{}{"type": "integer", "description": "Distinct FLOOD adverts originated in the last 7 days (zero-hop adverts excluded). Present on the node detail endpoint."},
"battery_mv": map[string]interface{}{"type": "integer", "nullable": true},
"temperature_c": map[string]interface{}{"type": "number", "nullable": true},
"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."),
"bridge_score": score01("#672 Bridge axis: normalized betweenness centrality (chokepoint importance). Repeater/room only."),
"coverage_score": score01("#672 Coverage axis: normalized harmonic reach centrality (how much of the mesh the node can reach). Repeater/room only."),
"redundancy_score": score01("#672 Redundancy axis: normalized articulation-point criticality — 1 means removing the node fragments the mesh, 0 means alternate paths exist. Repeater/room only."),
"usefulness_score": score01("#672 composite usefulness = 0.30·bridge + 0.25·coverage + 0.25·redundancy + 0.20·traffic. Until the 4-axis scorer ships (PR #1762) this is aliased to traffic_share_score. Repeater/room only."),
"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."),
"bridge_score": score01("#672 Bridge axis: normalized betweenness centrality (chokepoint importance). Repeater/room only."),
"coverage_score": score01("#672 Coverage axis: normalized harmonic reach centrality (how much of the mesh the node can reach). Repeater/room only."),
"redundancy_score": score01("#672 Redundancy axis: normalized articulation-point criticality — 1 means removing the node fragments the mesh, 0 means alternate paths exist. Repeater/room only."),
"usefulness_score": score01("#672 composite usefulness = 0.30·bridge + 0.25·coverage + 0.25·redundancy + 0.20·traffic. Until the 4-axis scorer ships (PR #1762) this is aliased to traffic_share_score. Repeater/room only."),
"usefulness_grade": map[string]interface{}{
"type": "string", "enum": []string{"A", "B", "C", "D", "F"},
"description": "Letter grade derived from usefulness_score. Repeater/room only.",
+10
View File
@@ -1626,6 +1626,16 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
// attribution is strict exact-match on the indexed from_pubkey column.
recentAdverts, _ := s.db.GetRecentTransmissionsForNode(pubkey, 20)
// Windowed flood-advert count (7d): only the mesh-wide-airtime advert kind,
// separated from zero-hop adverts so a nearby observer hearing a node's
// cheap local adverts does not inflate the number. Consumed by the ArcScope
// repeater advisor to rate advert hygiene.
if n, err := s.db.CountFloodAdvertsForNode(pubkey, 7*24, floodAdvertRowCap); err == nil {
node["flood_advert_count_7d"] = n
} else {
log.Printf("WARN CountFloodAdvertsForNode(%s): %v", pubkey, err)
}
writeJSON(w, NodeDetailResponse{
Node: node,
RecentAdverts: recentAdverts,