diff --git a/cmd/server/advert_stats.go b/cmd/server/advert_stats.go new file mode 100644 index 00000000..a67d790f --- /dev/null +++ b/cmd/server/advert_stats.go @@ -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 +} diff --git a/cmd/server/advert_stats_test.go b/cmd/server/advert_stats_test.go new file mode 100644 index 00000000..099fac2c --- /dev/null +++ b/cmd/server/advert_stats_test.go @@ -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) + } +} diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 7e116203..afd233db 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -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.", diff --git a/cmd/server/routes.go b/cmd/server/routes.go index eee8c44d..f857d654 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -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,