mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-14 07:26:07 +00:00
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:
co-authored by
Waydroid Builder
parent
4f7bb245d4
commit
d2ef624c2e
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user