List limited public-key prefixes, reasons, and wait times in bounded pages, with full-key details for targeted clearing. Queries preserve quotas and recovery timers. Validated with 71 native tests, Full repeater and room-server builds, and a 909.500 MHz RF test covering 10 forwarded adverts, 2 blocked adverts, and forwarding after targeted clear.
234 lines
9.0 KiB
C++
234 lines
9.0 KiB
C++
#include "FloodAdvertLimiter.h"
|
|
|
|
namespace mesh {
|
|
|
|
uint8_t FloodAdvertLimiter::allowance(uint8_t hops) {
|
|
static const uint8_t limits[] = {10, 10, 9, 8, 6, 5, 4, 3, 2};
|
|
return limits[hops < 8 ? hops : 8];
|
|
}
|
|
|
|
void FloodAdvertLimiter::reset() {
|
|
for (size_t i = 0; i < _capacity; ++i) _entries[i].flags = 0;
|
|
_last_sweep = 0;
|
|
}
|
|
|
|
FloodAdvertLimiter::Entry* FloodAdvertLimiter::find(const uint8_t* key) {
|
|
for (size_t i = 0; i < _capacity; ++i) {
|
|
if ((_entries[i].flags & ACTIVE) && memcmp(_entries[i].key, key, PUB_KEY_SIZE) == 0) {
|
|
return &_entries[i];
|
|
}
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
void FloodAdvertLimiter::clear(const uint8_t* full_key) {
|
|
Entry* entry = find(full_key);
|
|
if (entry) entry->flags = 0;
|
|
}
|
|
|
|
int FloodAdvertLimiter::hashIndex(const Entry& entry, const uint8_t* hash) {
|
|
for (uint8_t i = 0; i < entry.count; ++i) {
|
|
if (memcmp(entry.hashes[i], hash, MAX_HASH_SIZE) == 0) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
void FloodAdvertLimiter::advance(Entry& entry, uint32_t now) {
|
|
if (!(entry.flags & ACTIVE)) return;
|
|
if ((entry.flags & HAS_FORWARDED) && uint32_t(now - entry.last_forwarded) >= BAD_INTERVAL_MS) {
|
|
entry.flags &= ~HAS_FORWARDED;
|
|
}
|
|
uint32_t elapsed = now - entry.window_start;
|
|
if (elapsed >= WINDOW_MS) {
|
|
const bool over = entry.count > allowance(entry.min_hops);
|
|
if (over) {
|
|
// Recovery starts after the last over-limit window, not after the last
|
|
// forwarded packet. Suppressed RX traffic still counts as abuse.
|
|
entry.last_violation = entry.window_start + WINDOW_MS;
|
|
}
|
|
entry.flags &= ~PREVIOUS_OVER;
|
|
if (over && elapsed < 2 * WINDOW_MS) entry.flags |= PREVIOUS_OVER;
|
|
entry.window_start += (elapsed / WINDOW_MS) * WINDOW_MS;
|
|
entry.count = 0;
|
|
entry.forwarded = 0;
|
|
entry.min_hops = 255;
|
|
// Quiet ordinary sources need no history beyond their three-hour window.
|
|
// Keep a first strike through the next window; never evict a bad entry.
|
|
if (!(entry.flags & (BAD | PREVIOUS_OVER))) entry.flags = 0;
|
|
}
|
|
if ((entry.flags & BAD) && uint32_t(now - entry.last_violation) >= RECOVERY_MS) {
|
|
entry.flags &= ~BAD;
|
|
if (entry.count == 0 && !(entry.flags & PREVIOUS_OVER)) entry.flags = 0;
|
|
}
|
|
}
|
|
|
|
void FloodAdvertLimiter::refresh(uint32_t now) {
|
|
for (size_t i = 0; i < _capacity; ++i) advance(_entries[i], now);
|
|
_last_sweep = now;
|
|
}
|
|
|
|
void FloodAdvertLimiter::tick(uint32_t now) {
|
|
// Periodic expiry prevents stale history resurrecting after a millis wrap,
|
|
// even when no adverts arrive for weeks. Receive operations also expire it
|
|
// immediately at the exact window/recovery boundary.
|
|
if (uint32_t(now - _last_sweep) >= 60000UL) refresh(now);
|
|
}
|
|
|
|
bool FloodAdvertLimiter::needsShorterPath(const uint8_t* key, uint8_t hops, uint32_t now) {
|
|
refresh(now);
|
|
Entry* entry = find(key);
|
|
return entry && hops < entry->min_hops;
|
|
}
|
|
|
|
void FloodAdvertLimiter::noteKnownCopy(const uint8_t* key, const uint8_t* hash, uint32_t now) {
|
|
refresh(now);
|
|
Entry* entry = find(key);
|
|
// Only the exact retained hash of a previously verified payload can refresh
|
|
// last-heard without another signature check. Never trust just the key/path.
|
|
if (entry && hashIndex(*entry, hash) >= 0) entry->last_heard = now;
|
|
}
|
|
|
|
void FloodAdvertLimiter::observe(const uint8_t* key, const uint8_t* hash, uint8_t hops,
|
|
uint32_t now, bool previously_seen) {
|
|
refresh(now);
|
|
Entry* entry = find(key);
|
|
if (!entry) {
|
|
if (previously_seen) return;
|
|
uint32_t start = now;
|
|
// Keys sharing the first 12 hex characters share a normal quota AND its epoch.
|
|
for (size_t i = 0; i < _capacity; ++i) {
|
|
if ((_entries[i].flags & ACTIVE) && memcmp(_entries[i].key, key, PREFIX_BYTES) == 0) {
|
|
start = _entries[i].window_start;
|
|
if (_entries[i].min_hops < hops) hops = _entries[i].min_hops;
|
|
break;
|
|
}
|
|
}
|
|
Entry* oldest = nullptr;
|
|
for (size_t i = 0; i < _capacity; ++i) {
|
|
if (!(_entries[i].flags & ACTIVE)) {
|
|
entry = &_entries[i];
|
|
break;
|
|
}
|
|
// Preserve both bad-list entries and first-strike evidence. Evict only
|
|
// normal entries, least recently heard first, using rollover-safe ages.
|
|
const Entry& candidate = _entries[i];
|
|
if (!(candidate.flags & (BAD | PREVIOUS_OVER))
|
|
&& candidate.count <= allowance(candidate.min_hops)
|
|
&& (!oldest || uint32_t(now - candidate.last_heard) > uint32_t(now - oldest->last_heard))) {
|
|
oldest = &_entries[i];
|
|
}
|
|
}
|
|
if (!entry) entry = oldest;
|
|
if (!entry) return; // all slots protect abuse history: fail closed
|
|
memset(entry, 0, sizeof(*entry));
|
|
memcpy(entry->key, key, PUB_KEY_SIZE);
|
|
entry->window_start = start;
|
|
entry->min_hops = hops;
|
|
entry->flags = ACTIVE;
|
|
}
|
|
entry->last_heard = now;
|
|
// The normal level belongs to the prefix, including its shortest RX path.
|
|
// Keep that minimum on every colliding full key for consistent window-close
|
|
// abuse accounting, but never combine their received counts into a strike.
|
|
if (hops < entry->min_hops) entry->min_hops = hops;
|
|
for (size_t i = 0; i < _capacity; ++i) {
|
|
if ((_entries[i].flags & ACTIVE) && memcmp(_entries[i].key, key, PREFIX_BYTES) == 0
|
|
&& hops < _entries[i].min_hops) _entries[i].min_hops = hops;
|
|
}
|
|
if (previously_seen || hashIndex(*entry, hash) >= 0 || entry->count == HASH_SLOTS) return;
|
|
memcpy(entry->hashes[entry->count++], hash, MAX_HASH_SIZE);
|
|
if (entry->count > allowance(entry->min_hops)) {
|
|
entry->last_violation = now;
|
|
if (entry->flags & PREVIOUS_OVER) entry->flags |= BAD;
|
|
}
|
|
}
|
|
|
|
FloodAdvertLimiter::Decision FloodAdvertLimiter::check(const uint8_t* key, const uint8_t* hash,
|
|
uint32_t now) {
|
|
refresh(now);
|
|
Entry* entry = find(key);
|
|
if (!entry) return Decision::Capacity;
|
|
int idx = hashIndex(*entry, hash);
|
|
if (idx < 0) return Decision::Quota; // saturated receive history is fail-closed
|
|
if (entry->forwarded & (1U << idx)) return Decision::Duplicate;
|
|
if ((entry->flags & (BAD | HAS_FORWARDED)) == (BAD | HAS_FORWARDED)
|
|
&& uint32_t(now - entry->last_forwarded) < BAD_INTERVAL_MS) return Decision::BadList;
|
|
|
|
uint8_t hops = entry->min_hops;
|
|
unsigned forwarded = 0;
|
|
for (size_t i = 0; i < _capacity; ++i) {
|
|
const Entry& other = _entries[i];
|
|
if (!(other.flags & ACTIVE) || memcmp(other.key, key, PREFIX_BYTES) != 0) continue;
|
|
if (other.min_hops < hops) hops = other.min_hops;
|
|
for (uint16_t bits = other.forwarded; bits; bits &= bits - 1) ++forwarded;
|
|
}
|
|
return forwarded < allowance(hops) ? Decision::Allow : Decision::Quota;
|
|
}
|
|
|
|
void FloodAdvertLimiter::commit(const uint8_t* key, const uint8_t* hash, uint32_t now) {
|
|
// Caller has passed check() and ALL other forwarding gates, before appending
|
|
// its own path hop. Retries of this same advert are not new distinct adverts.
|
|
Entry* entry = find(key);
|
|
if (!entry) return;
|
|
int idx = hashIndex(*entry, hash);
|
|
if (idx < 0) return;
|
|
entry->forwarded |= uint16_t(1U << idx);
|
|
entry->last_forwarded = now;
|
|
entry->flags |= HAS_FORWARDED;
|
|
}
|
|
|
|
bool FloodAdvertLimiter::isBad(const uint8_t* key, uint32_t now) {
|
|
refresh(now);
|
|
Entry* entry = find(key);
|
|
return entry && (entry->flags & BAD);
|
|
}
|
|
|
|
size_t FloodAdvertLimiter::listLimited(uint32_t now, size_t offset,
|
|
LimitedEntry* dest, size_t count) {
|
|
refresh(now);
|
|
size_t total = 0;
|
|
for (size_t i = 0; i < _capacity; ++i) {
|
|
const Entry& entry = _entries[i];
|
|
if (!(entry.flags & ACTIVE)) continue;
|
|
unsigned forwarded = 0;
|
|
uint8_t hops = entry.min_hops;
|
|
for (size_t j = 0; j < _capacity; ++j) {
|
|
const Entry& other = _entries[j];
|
|
if (!(other.flags & ACTIVE) || memcmp(other.key, entry.key, PREFIX_BYTES) != 0) continue;
|
|
if (other.min_hops < hops) hops = other.min_hops;
|
|
for (uint16_t bits = other.forwarded; bits; bits &= bits - 1) ++forwarded;
|
|
}
|
|
const uint8_t quota = allowance(hops);
|
|
uint8_t reasons = 0;
|
|
if (forwarded >= quota) reasons |= WindowQuota;
|
|
// Once receive history fills, a new distinct payload is refused even if
|
|
// another forwarding filter prevented earlier payloads spending quota.
|
|
if (entry.count == HASH_SLOTS) reasons |= ReceiveHistory;
|
|
if (entry.flags & BAD) reasons |= BadListRule;
|
|
if (!reasons) continue;
|
|
|
|
if (dest && total >= offset && total - offset < count) {
|
|
LimitedEntry& out = dest[total - offset];
|
|
memcpy(out.key, entry.key, PUB_KEY_SIZE);
|
|
out.forwarded = forwarded;
|
|
out.quota = quota;
|
|
out.hops = hops;
|
|
out.reasons = reasons;
|
|
out.wait_ms = reasons & (WindowQuota | ReceiveHistory)
|
|
? WINDOW_MS - uint32_t(now - entry.window_start) : 0;
|
|
out.recovery_ms = 0;
|
|
if (reasons & BadListRule) {
|
|
out.recovery_ms = RECOVERY_MS - uint32_t(now - entry.last_violation);
|
|
if (entry.flags & HAS_FORWARDED) {
|
|
const uint32_t bad_wait = BAD_INTERVAL_MS - uint32_t(now - entry.last_forwarded);
|
|
if (bad_wait > out.wait_ms) out.wait_ms = bad_wait;
|
|
}
|
|
}
|
|
}
|
|
++total;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
} // namespace mesh
|