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.
This commit is contained in:
1 parent
602dbfe3d9
commit
00163f2c2b
9 files changed
+374
-1
No files matched your search
@@ -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 <index>`
|
||||
|
||||
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>`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,10 +2,82 @@
|
||||
|
||||
#include "FloodAdvertLimiter.h"
|
||||
#include <Utils.h>
|
||||
#include <stdio.h>
|
||||
|
||||
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 <index>");
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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) {}
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <helpers/FloodAdvertLimiter.h>
|
||||
#include <helpers/FloodAdvertCLI.h>
|
||||
#include <helpers/RemoteCliReplyCache.h>
|
||||
#include <array>
|
||||
|
||||
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);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <Ed25519.h>
|
||||
#include <Mesh.h>
|
||||
#include <helpers/ClockSyncUtils.h>
|
||||
#include <helpers/FloodAdvertCLI.h>
|
||||
#include <helpers/StaticPoolPacketManager.h>
|
||||
#include <helpers/ota/OtaFormat.h>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user