Files
d2ef624c2e 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>
2026-07-08 22:14:41 -07:00

82 lines
3.3 KiB
Go

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
}