From 00163f2c2be660839c70da404006015efefa2d80 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 10 Sep 2026 04:18:17 -0700 Subject: [PATCH] Add CLI listings for rate-limited flood adverts 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. --- docs/cli_commands.md | 35 ++++ docs/flood_filtering.md | 17 ++ src/helpers/CommonCLI.cpp | 1 + src/helpers/FloodAdvertCLI.h | 72 ++++++++ src/helpers/FloodAdvertLimiter.cpp | 47 +++++ src/helpers/FloodAdvertLimiter.h | 15 ++ test/README.md | 3 +- .../test_flood_advert_limiter.cpp | 164 ++++++++++++++++++ test/test_trace_retry/test_trace_retry.cpp | 21 +++ 9 files changed, 374 insertions(+), 1 deletion(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 420b5da6..eb0c7b1f 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -447,6 +447,41 @@ Elsewhere it replies `Err - neighbors not enabled in this build`. If a ## Statistics +### List Rate-Limited Flood Adverts + +**Usage:** `get flood.advert [page]` or `get flood.advert key ` + +Lists tracked advert origins currently subject to a forwarding limit. Page 1 +is the default, with three entries per page so replies fit both USB and admin +LoRa CLI. Each row shows its index, the first 12 public-key hex characters, +the limit reason, and the minimum wait before another new advert can pass +the automatic limiter. Ordinary forwarding filters still apply. + +```text +get flood.advert +get flood.advert 2 +get flood.advert key 1 +``` + +Reasons are `quota` (the prefix's three-hour forwarding allowance is spent), +`history` (distinct receive history is full), and `bad` (the full key is under +the stricter one-advert-per-12-hours rule). A `bad` key remains listed when +`wait=0s`: one forward is eligible, but its seven-day recovery is not complete. +If several limits apply, `bad` takes precedence over `history`, then `quota`; +the displayed wait accounts for all applicable limits. + +The `key` form returns the complete 64-hex public key, reason, wait, shared +prefix forwarding count/quota, shortest received hop count, and remaining +bad-list recovery time. Different full keys with the same 12-hex prefix remain +separate entries. Indices refer to the current live list and can change as +history expires; read the full key before using a targeted clear. + +Queries do not clear history, spend quota, or refresh abuse/recovery timers. +Elapsed history expires normally. Origins rejected only because every table +slot protects abuse history are not retained and cannot be enumerated. +Available on repeaters, room servers, and forwarding sensors through their +ordinary local/admin CLI. Companions do not have this table. + ### Clear Automatic Flood Advert History **Usage:** `clear flood.advert all` or `clear flood.advert <64-hex-full-public-key>` diff --git a/docs/flood_filtering.md b/docs/flood_filtering.md index 4dfc8b87..a8e4d676 100644 --- a/docs/flood_filtering.md +++ b/docs/flood_filtering.md @@ -69,6 +69,23 @@ physical distance. abuse can therefore extend the restriction indefinitely; it does not expire automatically seven days after the first offence. +### Inspect the current limits + +Use `get flood.advert` to list rate-limited origins, three per page. Continue +with `get flood.advert 2`, etc. Rows show a 12-hex public-key prefix, the reason +(`quota`, `history`, or `bad`), and the wait before another new advert is +eligible under the automatic limiter. `get flood.advert key 1` shows the full +key and counters for row 1, including the remaining bad-list recovery time. +A bad-listed key remains visible with `wait=0s` when its next single forward +is due. Prefix collisions appear as separate full-key entries. + +This is a live, read-only view: expiry can change row indices between queries, +but reading never resets quotas or extends recovery. Sources rejected solely +because the protected-history table is full are not retained, so they cannot +be listed. See the [CLI reference](cli_commands.md#list-rate-limited-flood-adverts). + +### Clear history + All timers use rollover-safe uptime, so setting the clock forward or backward cannot clear the restriction. This history is currently RAM-only: rebooting the relay clears it, and time while powered off is not tracked. A manual clear diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 13fbc27b..cd9da1a3 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -2461,6 +2461,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re // Observer-only top-level commands (ota check/update, tls.bundletest, alert test) // live in CommonCLI_Observer.cpp. if (handleObserverCommand(sender_timestamp, command, reply)) return; + if (mesh::cli::handleFloodAdvertGet(_callbacks->getFloodAdvertLimiter(), command, reply, millis())) return; if (mesh::cli::handleFloodAdvertClear(_callbacks->getFloodAdvertLimiter(), command, reply)) return; if (memcmp(command, "poweroff", 8) == 0 || memcmp(command, "shutdown", 8) == 0) { _board->powerOff(); // doesn't return diff --git a/src/helpers/FloodAdvertCLI.h b/src/helpers/FloodAdvertCLI.h index 5af3e58a..d3f9ec3d 100644 --- a/src/helpers/FloodAdvertCLI.h +++ b/src/helpers/FloodAdvertCLI.h @@ -2,10 +2,82 @@ #include "FloodAdvertLimiter.h" #include +#include namespace mesh { namespace cli { +inline const char* floodAdvertLimitReason(uint8_t reasons) { + if (reasons & FloodAdvertLimiter::BadListRule) return "bad"; + if (reasons & FloodAdvertLimiter::ReceiveHistory) return "history"; + return "quota"; +} + +// Keep pages inside the 160-byte reply shared by USB and authenticated LoRa CLI. +// Row numbers refer to the current filtered list, not permanent table slots. +inline bool handleFloodAdvertGet(FloodAdvertLimiter* limiter, const char* command, + char* reply, uint32_t now) { + static const char prefix[] = "get flood.advert"; + const size_t prefix_len = sizeof(prefix) - 1; + if (strncmp(command, prefix, prefix_len) != 0) return false; + const char* arg = command + prefix_len; + if (*arg && *arg != ' ' && *arg != '\t' && *arg != '\r' && *arg != '\n') return false; + while (*arg == ' ' || *arg == '\t') ++arg; + const bool detail = strncmp(arg, "key", 3) == 0 && (arg[3] == ' ' || arg[3] == '\t'); + if (detail) { + arg += 3; + while (*arg == ' ' || *arg == '\t') ++arg; + } + unsigned number = 1; + if (*arg >= '0' && *arg <= '9') { + number = 0; + do { + number = number * 10 + unsigned(*arg++ - '0'); + if (number > 65535) break; + } while (*arg >= '0' && *arg <= '9'); + } else if (detail) { + number = 0; + } + while (*arg == ' ' || *arg == '\t' || *arg == '\r' || *arg == '\n') ++arg; + if (*arg || number == 0 || number > 65535) { + strcpy(reply, "ERR: get flood.advert [page] | key "); + return true; + } + if (!limiter) { + strcpy(reply, "ERR: advert limiter unavailable on this role"); + return true; + } + + static constexpr size_t PAGE_SIZE = 3; + FloodAdvertLimiter::LimitedEntry rows[PAGE_SIZE]; + const size_t offset = detail ? number - 1 : (number - 1) * PAGE_SIZE; + const size_t total = limiter->listLimited(now, offset, rows, detail ? 1 : PAGE_SIZE); + if (total == 0 && !detail && number == 1) { + strcpy(reply, "> no rate-limited adverts"); + } else if (offset >= total) { + strcpy(reply, "ERR: advert list index out of range"); + } else if (detail) { + const auto& row = rows[0]; + char key[PUB_KEY_SIZE * 2 + 1]; + Utils::toHex(key, row.key, PUB_KEY_SIZE); + snprintf(reply, 160, "> %s\n%s wait=%lus sent=%u/%u hops=%u recovery=%lus", + key, floodAdvertLimitReason(row.reasons), (unsigned long)((row.wait_ms + 999) / 1000), + unsigned(row.forwarded), unsigned(row.quota), unsigned(row.hops), + (unsigned long)((row.recovery_ms + 999) / 1000)); + } else { + size_t used = snprintf(reply, 160, "> page %u/%u limited=%u", number, + unsigned((total + PAGE_SIZE - 1) / PAGE_SIZE), unsigned(total)); + for (size_t i = 0; i < PAGE_SIZE && offset + i < total && used < 159; ++i) { + char key[FloodAdvertLimiter::PREFIX_BYTES * 2 + 1]; + Utils::toHex(key, rows[i].key, FloodAdvertLimiter::PREFIX_BYTES); + used += snprintf(reply + used, 160 - used, "\n%u %s %s wait=%lus", + unsigned(offset + i + 1), key, floodAdvertLimitReason(rows[i].reasons), + (unsigned long)((rows[i].wait_ms + 999) / 1000)); + } + } + return true; +} + // Dispatch only after the role's ordinary CLI authorization checks. Requiring // an explicit "all" avoids treating missing/malformed keys as a global reset. inline bool handleFloodAdvertClear(FloodAdvertLimiter* limiter, const char* command, char* reply) { diff --git a/src/helpers/FloodAdvertLimiter.cpp b/src/helpers/FloodAdvertLimiter.cpp index 487f36de..a428205b 100644 --- a/src/helpers/FloodAdvertLimiter.cpp +++ b/src/helpers/FloodAdvertLimiter.cpp @@ -183,4 +183,51 @@ bool FloodAdvertLimiter::isBad(const uint8_t* key, uint32_t now) { 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 diff --git a/src/helpers/FloodAdvertLimiter.h b/src/helpers/FloodAdvertLimiter.h index 6d52e81e..2549c558 100644 --- a/src/helpers/FloodAdvertLimiter.h +++ b/src/helpers/FloodAdvertLimiter.h @@ -41,6 +41,17 @@ public: uint8_t flags; }; + enum LimitReason : uint8_t { WindowQuota = 1, ReceiveHistory = 2, BadListRule = 4 }; + struct LimitedEntry { + uint8_t key[PUB_KEY_SIZE]; + uint32_t wait_ms; + uint32_t recovery_ms; + uint16_t forwarded; + uint8_t quota; + uint8_t hops; + uint8_t reasons; + }; + void reset(); void clear(const uint8_t* full_key); void tick(uint32_t now); @@ -54,6 +65,10 @@ public: Decision check(const uint8_t* key, const uint8_t* hash, uint32_t now); void commit(const uint8_t* key, const uint8_t* hash, uint32_t now); bool isBad(const uint8_t* key, uint32_t now); + // Return the total number of limited full keys and copy the requested slice. + // Queries only expire elapsed history; they never consume quota, refresh + // last-heard, or add abuse evidence. Prefix collisions remain separate keys. + size_t listLimited(uint32_t now, size_t offset, LimitedEntry* dest, size_t count); protected: FloodAdvertLimiter(Entry* entries, size_t capacity) : _entries(entries), _capacity(capacity) {} diff --git a/test/README.md b/test/README.md index a114f145..7beba4ea 100644 --- a/test/README.md +++ b/test/README.md @@ -150,7 +150,8 @@ does not reflect the GoogleTest count -- run the built binary directly | `test_wifi_ota_seeder_policy` | `src/helpers/WiFiOtaSeederPolicy.h`, `WiFiOtaSeederStatus.h` | listener state versus network availability, serial/TCP folder ownership, detach detection, and bounded status formatting | | `test_tls_clock_validity` | `src/helpers/esp32/TlsClockValidity.h` | signed wall-clock minimum and the fresh-proof/WiFi/time conjunction required before certificate-validating downloads | | `test_ota` | `src/helpers/ota/` | v2 application/v3 bootloader parser separation; legacy XIAO, generic internal, and exact MeshTower V2 SD embedded identity, vector, capability, explicit-confirmation, codec-isolation, scratch-headroom/shared-slot no-EndF gates, and no-autofetch gates; container and EndF integrity; protocol codecs; transfer, resume, and layered apply safety; adaptive 2-to-4 block-request window growth and stall contraction; active-transfer priority classification | -| `test_trace_retry` | `src/Mesh.cpp`, `RTCClock`, `ClockSyncUtils.h`, retry and relay policy | app-v2 and boot-v3 traffic sharing `PAYLOAD_TYPE_OTA=0x0C` and the TempRadio suspend policy; opaque OTA relay behavior; background discovery priority; immediate primary transfer relay, receive-delay bypass, fast CAD retry, and no generic flood retry; trace and non-OTA flood retry timing; backward RTC correction; clock consensus/path policy and the 10-minute default drift threshold | +| `test_flood_advert_limiter` | `FloodAdvertLimiter.h/.cpp`, `FloodAdvertCLI.h` | hop-based quotas, verified duplicates, prefix collisions, abuse escalation/recovery, protected capacity, rollover, exact-key clearing, read-only listing, pagination, malformed selectors, full-key details, and USB/LoRa reply bounds | +| `test_trace_retry` | `src/Mesh.cpp`, `RTCClock`, `ClockSyncUtils.h`, retry and relay policy | app-v2 and boot-v3 traffic sharing `PAYLOAD_TYPE_OTA=0x0C` and the TempRadio suspend policy; opaque OTA relay behavior; background discovery priority; immediate primary transfer relay, receive-delay bypass, fast CAD retry, and no generic flood retry; trace and non-OTA flood retry timing; backward RTC correction; clock consensus/path policy and the 10-minute default drift threshold; advert receive/forward limits and matching read-only CLI listings | | `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) | ## Conventions (and how to add a suite) diff --git a/test/test_flood_advert_limiter/test_flood_advert_limiter.cpp b/test/test_flood_advert_limiter/test_flood_advert_limiter.cpp index 51a24240..f5e43528 100644 --- a/test/test_flood_advert_limiter/test_flood_advert_limiter.cpp +++ b/test/test_flood_advert_limiter/test_flood_advert_limiter.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include using Limiter = mesh::FloodAdvertLimiter; @@ -304,6 +305,169 @@ TEST_F(FloodAdvertLimit, CliInvalidSelectorsNeverClearAnything) { EXPECT_STREQ("ERR: advert limiter unavailable on this role", reply); } +TEST_F(FloodAdvertLimit, CliListsOnlyLimitedKeysAndReturnsFullKeyDetails) { + char reply[160] = {}; + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&limiter, "get flood.advert", reply, 0)); + EXPECT_STREQ("> no rate-limited adverts", reply); + receive(1, 8, 0); + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&limiter, "get flood.advert", reply, 0)); + EXPECT_STREQ("> no rate-limited adverts", reply); + receive(2, 8, 0); + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&limiter, "get flood.advert 1", reply, 1000)); + EXPECT_STREQ("> page 1/1 limited=1\n1 BADBEE5C0000 quota wait=10799s", reply); + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&limiter, "get flood.advert key 1", reply, 1000)); + char hex[PUB_KEY_SIZE * 2 + 1]; + mesh::Utils::toHex(hex, key.data(), PUB_KEY_SIZE); + EXPECT_EQ(std::string("> ") + hex + "\nquota wait=10799s sent=2/2 hops=8 recovery=0s", reply); + // Reading neither spends more quota nor resets it. + EXPECT_EQ(Decision::Quota, receive(3, 8, 1000)); + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&limiter, "get flood.advert", reply, W)); + EXPECT_STREQ("> no rate-limited adverts", reply); +} + +TEST_F(FloodAdvertLimit, CliPagesTheWholeListWithoutTruncationOrChangingIt) { + for (unsigned i = 0; i < 4; ++i) { + auto source = key; + source[0] += i; + receive(1, 8, 0, source.data()); + receive(2, 8, 0, source.data()); + } + struct { char before = '!'; char reply[160] = {}; char after = '?'; } guarded; + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&limiter, "get flood.advert", guarded.reply, 0)); + EXPECT_STREQ("> page 1/2 limited=4\n1 BADBEE5C0000 quota wait=10800s\n2 BBDBEE5C0000 quota wait=10800s\n3 BCDBEE5C0000 quota wait=10800s", guarded.reply); + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&limiter, "get flood.advert\t2\r\n", guarded.reply, 1)); + EXPECT_STREQ("> page 2/2 limited=4\n4 BDDBEE5C0000 quota wait=10800s", guarded.reply); + EXPECT_EQ('!', guarded.before); + EXPECT_EQ('?', guarded.after); + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&limiter, "get flood.advert 3", guarded.reply, 1)); + EXPECT_STREQ("ERR: advert list index out of range", guarded.reply); + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&limiter, "get flood.advert key 4", guarded.reply, 1)); + EXPECT_NE(nullptr, strstr(guarded.reply, "BDDBEE5C0000")); + EXPECT_EQ(4U, limiter.listLimited(1, 0, nullptr, 0)); +} + +TEST_F(FloodAdvertLimit, ListedPrefixCollisionsPreserveFullKeyIdentity) { + auto collision = key; + collision[31] = 1; + receive(1, 8, 0); + receive(1, 8, 1, collision.data()); + Limiter::LimitedEntry rows[2]; + ASSERT_EQ(2U, limiter.listLimited(100, 0, rows, 2)); + EXPECT_EQ(0, memcmp(rows[0].key, rows[1].key, Limiter::PREFIX_BYTES)); + EXPECT_NE(0, memcmp(rows[0].key, rows[1].key, PUB_KEY_SIZE)); + for (const auto& row : rows) { + EXPECT_EQ(Limiter::WindowQuota, row.reasons); + EXPECT_EQ(2, row.forwarded); + EXPECT_EQ(2, row.quota); + EXPECT_EQ(W - 100, row.wait_ms); + } + char reply[160]; + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&limiter, "get flood.advert key 2", reply, 100)); + char hex[PUB_KEY_SIZE * 2 + 1]; + mesh::Utils::toHex(hex, collision.data(), PUB_KEY_SIZE); + EXPECT_NE(nullptr, strstr(reply, hex)); +} + +TEST_F(FloodAdvertLimit, LargestSupportedTableFitsUsbAndRemoteReplies) { + mesh::StaticFloodAdvertLimiter<128> large; + for (unsigned i = 0; i < 128; ++i) { + auto source = key; + source[0] = i; + for (uint8_t seq = 0; seq < Limiter::HASH_SLOTS; ++seq) { + uint8_t hash[MAX_HASH_SIZE] = {seq}; + large.observe(source.data(), hash, 0, 0); + } + } + struct { char before = '!'; char reply[160] = {}; char after = '?'; } guarded; + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&large, "get flood.advert 34", guarded.reply, 0)); + EXPECT_STREQ("> page 34/43 limited=128\n100 63DBEE5C0000 history wait=10800s\n101 64DBEE5C0000 history wait=10800s\n102 65DBEE5C0000 history wait=10800s", guarded.reply); + EXPECT_LE(strlen(guarded.reply), mesh::RemoteCliReplyCache::MAX_REPLY_TEXT); + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&large, "get flood.advert key 128", guarded.reply, 0)); + EXPECT_NE(nullptr, strstr(guarded.reply, "history wait=10800s sent=0/10 hops=0 recovery=0s")); + EXPECT_LE(strlen(guarded.reply), mesh::RemoteCliReplyCache::MAX_REPLY_TEXT); + EXPECT_EQ('!', guarded.before); + EXPECT_EQ('?', guarded.after); +} + +TEST_F(FloodAdvertLimit, ListingUsesTheSameShortestPrefixPathAsForwarding) { + receive(1, 8, 0); + receive(2, 8, 1); + ASSERT_EQ(1U, limiter.listLimited(1, 0, nullptr, 0)); + uint8_t hash[MAX_HASH_SIZE] = {1}; + limiter.observe(key.data(), hash, 1, 2, true); + EXPECT_EQ(0U, limiter.listLimited(2, 0, nullptr, 0)); + EXPECT_EQ(Decision::Allow, receive(3, 8, 2)); +} + +TEST_F(FloodAdvertLimit, ListingShowsSaturatedReceiveHistoryWithoutSpentQuota) { + for (unsigned i = 0; i < Limiter::HASH_SLOTS; ++i) receive(i, 1, 0, nullptr, false); + Limiter::LimitedEntry row; + ASSERT_EQ(1U, limiter.listLimited(0, 0, &row, 1)); + EXPECT_EQ(Limiter::ReceiveHistory, row.reasons); + EXPECT_EQ(0, row.forwarded); + EXPECT_EQ(10, row.quota); + EXPECT_EQ(W, row.wait_ms); + char reply[160]; + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&limiter, "get flood.advert", reply, 0)); + EXPECT_NE(nullptr, strstr(reply, "history wait=10800s")); + EXPECT_EQ(Decision::Quota, receive(99, 1, 0)); +} + +TEST_F(FloodAdvertLimit, ListingReportsBadKeyCooldownAndDoesNotExtendRecovery) { + bad(); + Limiter::LimitedEntry row; + ASSERT_EQ(1U, limiter.listLimited(W, 0, &row, 1)); + EXPECT_TRUE(row.reasons & Limiter::BadListRule); + EXPECT_EQ(H12, row.wait_ms); + EXPECT_EQ(D7, row.recovery_ms); + char reply[160]; + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&limiter, "get flood.advert", reply, W)); + EXPECT_NE(nullptr, strstr(reply, "bad wait=43200s")); + // Bad-list membership is still visible when its next single forward is due. + ASSERT_EQ(1U, limiter.listLimited(W + H12, 0, &row, 1)); + EXPECT_TRUE(row.reasons & Limiter::BadListRule); + EXPECT_EQ(0U, row.wait_ms); + EXPECT_EQ(D7 - (H12 - W), row.recovery_ms); + for (uint32_t now = W + H12; now < 2 * W + D7; now += W) { + EXPECT_EQ(1U, limiter.listLimited(now, 0, nullptr, 0)); + } + EXPECT_EQ(0U, limiter.listLimited(2 * W + D7, 0, nullptr, 0)); + EXPECT_FALSE(limiter.isBad(key.data(), 2 * W + D7)); +} + +TEST_F(FloodAdvertLimit, ListingWaitsAndExpiryWorkAcrossMillisWrap) { + const uint32_t start = UINT32_MAX - W / 2; + bad(start); + Limiter::LimitedEntry row; + ASSERT_EQ(1U, limiter.listLimited(start + W + 1, 0, &row, 1)); + EXPECT_EQ(H12 - 1, row.wait_ms); + EXPECT_EQ(D7 - 1, row.recovery_ms); + EXPECT_EQ(0U, limiter.listLimited(start + 2 * W + D7, 0, &row, 1)); +} + +TEST_F(FloodAdvertLimit, CliInvalidListSelectorsDoNotChangeHistoryOrStealOtherCommands) { + bad(); + const char* invalid[] = { + "get flood.advert 0", "get flood.advert -1", "get flood.advert 65536", + "get flood.advert 99999999999999999999", "get flood.advert 1.5", + "get flood.advert all", "get flood.advert 1 extra", "get flood.advert key", + "get flood.advert key 0", "get flood.advert key -1", "get flood.advert key 1 extra" + }; + for (const char* command : invalid) { + char reply[160] = {}; + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&limiter, command, reply, W)); + EXPECT_EQ(0, strncmp(reply, "ERR:", 4)) << command; + EXPECT_TRUE(limiter.isBad(key.data(), W)); + } + char reply[160] = "unchanged"; + EXPECT_FALSE(mesh::cli::handleFloodAdvertGet(&limiter, "get flood.advert.interval", reply, W)); + EXPECT_FALSE(mesh::cli::handleFloodAdvertGet(&limiter, "get flood.advertisement", reply, W)); + EXPECT_FALSE(mesh::cli::handleFloodAdvertGet(&limiter, "clear flood.advert all", reply, W)); + EXPECT_STREQ("unchanged", reply); + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(nullptr, "get flood.advert", reply, W)); + EXPECT_STREQ("ERR: advert limiter unavailable on this role", reply); +} + #ifndef FLOOD_ADVERT_COMBINED_TEST int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); diff --git a/test/test_trace_retry/test_trace_retry.cpp b/test/test_trace_retry/test_trace_retry.cpp index 0f743124..e903b2fa 100644 --- a/test/test_trace_retry/test_trace_retry.cpp +++ b/test/test_trace_retry/test_trace_retry.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -1077,6 +1078,26 @@ TEST_F(AdvertReceiveLimit, StopsOnlyForwardingAndKeepsLocalAdvertCallbacks) { EXPECT_NE(ACTION_RELEASE, node.receivePacket(&message)); } +TEST_F(AdvertReceiveLimit, CliListsTheActualReceiveLimitWithoutChangingForwarding) { + char reply[160]; + ASSERT_NE(ACTION_RELEASE, receive(1)); + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&node.limiter, "get flood.advert", reply, clock.now)); + EXPECT_STREQ("> no rate-limited adverts", reply); + ASSERT_NE(ACTION_RELEASE, receive(2)); + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&node.limiter, "get flood.advert", reply, clock.now)); + EXPECT_NE(nullptr, strstr(reply, "BA0000000000 quota wait=10800s")); + ASSERT_EQ(ACTION_RELEASE, receive(3)); + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&node.limiter, "get flood.advert key 1", reply, clock.now)); + EXPECT_NE(nullptr, strstr(reply, "sent=2/2 hops=8")); + EXPECT_EQ(3U, node.advert_callbacks); + EXPECT_EQ(2U, node.forwarding_checks); + // An authenticated shorter duplicate changes the actual allowance and list. + ASSERT_EQ(ACTION_RELEASE, receive(1, 1, true)); + ASSERT_TRUE(mesh::cli::handleFloodAdvertGet(&node.limiter, "get flood.advert", reply, clock.now)); + EXPECT_STREQ("> no rate-limited adverts", reply); + EXPECT_NE(ACTION_RELEASE, receive(4)); +} + TEST_F(AdvertReceiveLimit, VerifiedShorterDuplicateRaisesAllowanceWithoutRelayingIt) { receive(1); receive(2);