Add path-qualified channel scope buckets

This commit is contained in:
mikecarper committed 2026-07-31 01:18:26 -07:00
1 parent 5d7bf893f0
commit 20485d7278
8 files changed
+372 -63

No files matched your search

+39 -10
View File
@@ -1213,8 +1213,8 @@ del flood.channel.block.2
**Usage:**
- `get flood.channel.scope`
- `get flood.channel.scope.<n>`
- `set flood.channel.scope <channel|txt:*|login:*|other:*> <region> [tx=slow]`
- `set flood.channel.scope.<n> <channel|txt:*|login:*|other:*> <region> [tx=slow]`
- `set flood.channel.scope <channel|txt:*|login:*|other:*> <region> [path=blacklist|path=bucket:1-6] [tx=slow]`
- `set flood.channel.scope.<n> <channel|txt:*|login:*|other:*> <region> [path=blacklist|path=bucket:1-6] [tx=slow]`
- `del flood.channel.scope.<n>`
- `del flood.channel.scope all`
@@ -1235,6 +1235,16 @@ del flood.channel.block.2
raw custom. TRACE is deliberately exempt from forced-scope wildcards.
- `region`: Existing named region with a usable transport key. A unique region
name prefix is accepted; wildcard region `*` is not a scope target.
- `path=blacklist`: Optional. Require the received path to match the passive
`flood.filter.blacklist` ID table. No `flood.filter` drop row needs to be
enabled. One exact listed ID qualifies a 3-byte path. A 2-byte path requires
two matching received path entries, while a 1-byte path never qualifies.
- `path=bucket:<1-6>`: Optional alternative to `path=blacklist`. Match IDs in
the selected persistent `flood.retry.bucket`. Each bucket holds up to 17
three-byte IDs and remains usable when `flood.retry.bridge` is off. It uses
the same 3-byte, 2-byte, and 1-byte thresholds as `path=blacklist`.
`recent.repeater` freshness and `flood.retry.ignore` do not affect this
passive match.
- `tx=slow`: Optional. Use an effective inbound `rxdelay` base of
`max(2, configured rxdelay * 2)`, keep normal outbound queue priority, and
schedule retransmission with the maximum supported `txdelay` factor of
@@ -1248,8 +1258,10 @@ Remote ACL permission `4` (region/scope manager) can use all `get`, `set`, and
cannot change this table.
Without `.n`, `set` updates the row for the same exact channel key or wildcard
class, otherwise it uses the first empty slot. With `.n`, it replaces that
slot. The three wildcard classes are independent and consume one slot each.
class with the same path selector, otherwise it uses the first empty slot.
This permits an ordinary fallback and separate blacklist or bridge-bucket
rows for the same channel. With `.n`, it replaces that slot. The three
wildcard classes are independent and consume one slot each.
`get flood.channel.scope` reports active/total slot counts; use the numbered
form for row detail. Keyed rows are displayed by the first four bytes of their
derived channel hash because channel secrets are never returned.
@@ -1258,12 +1270,14 @@ This acts on received `ROUTE_TYPE_FLOOD` and
`ROUTE_TYPE_TRANSPORT_FLOOD` packets. An unscoped packet gains the configured
scope; an already-scoped packet has its existing transport codes replaced. For
`GRP_TXT` and `GRP_DATA`, all exact channel-key rows are tried first and must
validate the packet MAC/decryption. A row whose target region is missing or
unusable is skipped; later exact rows and then `txt:*` are tried. Exact keyed
rows with a usable target therefore beat `txt:*` regardless of slot number.
`login:*` and `other:*` select their non-overlapping outer-type families
without decrypting the payload. Duplicate rows within the same class are
permitted with numbered slots; the lowest usable slot wins.
validate the packet MAC/decryption. Matching path-qualified exact rows are
tried before ordinary exact fallback rows. A row whose target region is
missing or unusable is skipped; later exact rows and then `txt:*` are tried.
Exact keyed rows with a usable target therefore beat `txt:*` regardless of
slot number. Within each wildcard class, path-qualified rows similarly precede
ordinary fallback rows. `login:*` and `other:*` select their non-overlapping
outer-type families without decrypting the payload. The lowest usable slot
wins within each priority tier.
Standard traceroute is direct-routed and is therefore outside this flood-only
table. A custom flood-form `TRACE` is also left unchanged: no wildcard adds a
@@ -1357,6 +1371,21 @@ get flood.channel.scope.1
del flood.channel.scope.2
```
To use bridge bucket 1 to assign `east` to `public` packets whose received
3-byte path contains `7576FB`, while assigning `west` to every other
authenticated `public` packet:
```text
set flood.retry.bucket 1 7576FB
set flood.channel.scope public west
set flood.channel.scope public east path=bucket:1
```
Additional 3-byte IDs may be added to bucket 1 later; any one exact hit
qualifies the `east` row. This use is passive and does not require
`flood.retry.bridge` to be enabled. The separate blacklist selector remains
available for tables shared with `flood.filter path=blacklist` rules.
---
#### Require valid incoming scopes only on selected channels
+38 -6
View File
@@ -47,8 +47,8 @@ silently enable blocking.
the scope of a transport-scoped flood before this repeater forwards it:
```text
set flood.channel.scope <channel|txt:*|login:*|other:*> <region> [tx=slow]
set flood.channel.scope.<slot> <channel|txt:*|login:*|other:*> <region> [tx=slow]
set flood.channel.scope <channel|txt:*|login:*|other:*> <region> [path=blacklist|path=bucket:1-6] [tx=slow]
set flood.channel.scope.<slot> <channel|txt:*|login:*|other:*> <region> [path=blacklist|path=bucket:1-6] [tx=slow]
get flood.channel.scope
get flood.channel.scope.<slot>
del flood.channel.scope.<slot>
@@ -61,6 +61,21 @@ the one-byte channel hash carried in the packet, then validate the MAC by
decrypting with the configured channel key. A hash collision alone cannot
force a scope.
Add `path=blacklist` to make a channel-scope row eligible only when the
received path matches the passive `flood.filter.blacklist` ID table. It does
not require an enabled `flood.filter` drop row. With 3-byte paths, one exact
listed ID qualifies. With 2-byte paths, two received path entries must match
the first two bytes of listed IDs. A 1-byte path never qualifies.
Use `path=bucket:<1-6>` to match one of the existing
`flood.retry.bucket` tables instead. Each bridge bucket holds up to 17
three-byte IDs and remains usable by channel scoping while
`flood.retry.bridge` is off. Bucket matching uses the same thresholds as the
blacklist: one exact hit for 3-byte paths, two qualifying entries for 2-byte
paths, and no matches for 1-byte paths. Channel scoping reads the configured
IDs directly; `recent.repeater` freshness and `flood.retry.ignore` do not
change this match.
There are three independent wildcard classes:
- `txt:*` handles otherwise-unmatched `GRP_TXT` and `GRP_DATA`; plain `*` is
@@ -72,9 +87,25 @@ There are three independent wildcard classes:
`login:*` and `other:*` classify only the visible outer payload type; they do
not authenticate its contents. Exact channel rows with usable target regions
always take precedence over `txt:*`, even if that wildcard has a lower slot
number. A missing or unusable target is skipped, so later exact rows and then
the applicable wildcard are tried. Within a wildcard class, the lowest usable
duplicate row wins.
number. Within the exact class, matching path-qualified rows are tried before
ordinary fallback rows. The same qualified-then-fallback order applies within
each wildcard class. A missing or unusable target is skipped, so later rows
remain eligible. The lowest usable slot wins within each priority tier.
For example, this uses bridge bucket 1 to assign `east` to `public` packets
whose received 3-byte path contains `7576FB`, and assigns `west` to all other
authenticated `public` packets:
```text
set flood.retry.bucket 1 7576FB
set flood.channel.scope public west
set flood.channel.scope public east path=bucket:1
```
More 3-byte IDs can be added to bucket 1 later. Any one of them qualifies the
`east` row. Replacing or clearing that bucket changes which paths qualify but
leaves both channel-scope rows intact. Bridge retry does not need to be
enabled.
On a successful match, an unscoped route changes from `ROUTE_TYPE_FLOOD` to
`ROUTE_TYPE_TRANSPORT_FLOOD`; an already-scoped route remains transport-flood
@@ -510,7 +541,8 @@ other words, the controls combine as deny rules:
1. `flood.channel.scope.require` evaluates a listed group channel against the
original incoming scope; unlisted group channels bypass the later region
gate while the table is active.
2. `flood.channel.scope` adds or replaces the scope of a matching flood packet.
2. `flood.channel.scope` tries a path-qualified channel row before that
channel's ordinary fallback, then adds or replaces the scope.
3. A matching `flood.filter scope=` row may replace that result; its scope does
not require a region-list entry.
4. `repeat`, `flood.max*`, and the channel-data gate are checked.
+6 -1
View File
@@ -83,7 +83,7 @@ set flood.retry.ignore none
| `flood.channel.data.hops` | Separate hop gate used only when `flood.channel.data` is `off`; `all` blocks `GRP_DATA` at any hop count, `1`-`7` repeats at that hop count or lower and blocks longer paths. | `get flood.channel.data.hops`, `set flood.channel.data.hops <all|1-7>` | `set flood.channel.data.hops 7` |
| `flood.channel.block` | Blocks selected flood `GRP_TXT`/`GRP_DATA` channels when the key validates the packet. New repeater block lists start with editable/deletable `#wardriving h=4`. Add `h=<all|1-7|default>` for a per-channel hop override. | `get flood.channel.block`, `set flood.channel.block[.n] <key|#channel> [name] [h=...]`, `del flood.channel.block[.n]` | `set flood.channel.block #wardriving h=4` |
| `flood.channel.block.hops` | Limits keyed channel-block matches to short flood paths. `all` blocks matching packets at any hop count; `1`-`7` repeats packets at that hop count or lower and blocks longer matches. This does not restrict unkeyed `GRP_DATA`; use `flood.channel.data.hops` for that. | `get flood.channel.block.hops`, `set flood.channel.block.hops <all|1-7>` | `set flood.channel.block.hops 3` |
| `flood.channel.scope` | Adds a transport-region scope to received unscoped floods or replaces the scope of already-scoped floods. By default, a changed packet bypasses inbound `rxdelay` and is forwarded at the highest outbound queue priority with zero initial `txdelay`, so the selected scope can win at the next hop. `tx=slow` uses an effective inbound `rxdelay` base of `max(2, configured rxdelay * 2)`, retains normal queue priority, and forces the maximum `txdelay` factor of `2.0`; its actual randomized transmit delay ranges from zero through ten packet airtimes. An already-matching scope is a no-op. Exact channel keys beat `txt:*`; `login:*` covers the remote-login family, and `other:*` covers every remaining flood type except TRACE, including OTA. TRACE remains unchanged across scope boundaries. ACL permission `4` can manage the table. | `get flood.channel.scope[.n]`, `set flood.channel.scope[.n] <channel|txt:*|login:*|other:*> <region> [tx=slow]`, `del flood.channel.scope.<n>|all` | `set flood.channel.scope login:* west tx=slow` |
| `flood.channel.scope` | Adds a transport-region scope to received unscoped floods or replaces the scope of already-scoped floods. By default, a changed packet bypasses inbound `rxdelay` and is forwarded at the highest outbound queue priority with zero initial `txdelay`, so the selected scope can win at the next hop. `tx=slow` uses an effective inbound `rxdelay` base of `max(2, configured rxdelay * 2)`, retains normal queue priority, and forces the maximum `txdelay` factor of `2.0`; its actual randomized transmit delay ranges from zero through ten packet airtimes. `path=blacklist` and `path=bucket:<1-6>` make a row path-qualified; bridge buckets remain usable while bridge retry is off. An already-matching scope is a no-op. Exact channel keys beat `txt:*`; path-qualified rows beat the ordinary channel fallback. `login:*` covers the remote-login family, and `other:*` covers every remaining flood type except TRACE, including OTA. TRACE remains unchanged across scope boundaries. ACL permission `4` can manage the table. | `get flood.channel.scope[.n]`, `set flood.channel.scope[.n] <channel|txt:*|login:*|other:*> <region> [path=blacklist|path=bucket:1-6] [tx=slow]`, `del flood.channel.scope.<n>|all` | `set flood.channel.scope public east path=bucket:1` |
| `flood.channel.scope.require` | Switches group-channel region enforcement to opt-in when the table has entries. Listed authenticated `GRP_TXT`/`GRP_DATA` channels must arrive already scoped to a locally allowed region; unscoped, unknown, or denied incoming scopes are dropped before any rewrite can rescue them. Unlisted group channels bypass only the region gate and retain all other forwarding controls. An empty table preserves global region behavior; non-channel payloads are unchanged. ACL permission `4` can manage the table. | `get flood.channel.scope.require[.n]`, `set flood.channel.scope.require[.n] <public|#channel|key>`, `del flood.channel.scope.require.<n>|all` | `set flood.channel.scope.require #bot` |
| `flood.filter` | Persistent repeater-only rules for flood routes `0x00`/`0x01`, selected by payload type, optional received hop count/range (omitted means `all`), and optional unordered `path=blacklist`. The separate blacklist stores up to 255 full 3-byte repeater IDs on ESP32 and 18 on other builds; the path condition qualifies on at least one exact 3-byte hit, at least two 2-byte-prefix hits, and never on 1-byte paths. Rows normally drop matching retransmissions; `scope=<name>` instead adds or replaces a public hashtag-derived scope without requiring a region-list entry. Scope rewrites default to bypassing inbound `rxdelay` and using zero initial `txdelay` plus highest queue priority; `tx=slow` uses an effective inbound `rxdelay` base of `max(2, configured rxdelay * 2)`, retains normal queue priority, and forces `txdelay` factor `2.0`. Adding `require=region` makes that rewrite conditional on the original incoming packet already passing local region enforcement, so unknown or denied incoming scopes are not rescued. New tables seed slot 1 with `ota all suspend=tempradio`; only rows marked `suspend=tempradio` are skipped during temporary-radio operation. Login-capable `anon_req`/`path`/`response` drop actions begin at hop `7`; flood `txt_msg` drops begin at hop `5`. Standard direct traceroute, other direct routing, and local receive/logging are unchanged. | `get/set/del flood.filter.blacklist[.n]`, `get flood.filter[.n]`, `set flood.filter[.n] <type> [N|N+|N-M|all] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]`, `del flood.filter.<n>|all` | `set flood.filter grp_txt all scope=local tx=slow` |
| `flood.moderation` | Decrypts keyed `GRP_TXT` channels and applies drop, per-username messages/minute, and maximum-hop controls, optionally matched against the first 1-3 path hashes. Supports `public`, `#channel`, and 128/256-bit channel keys. Sender names and truncated path hashes are moderation hints, not authenticated identities. | `get flood.moderation[.n]`, `set flood.moderation[.n] <channel> <sender> <drop|rate=X/min|hops=N> [path=...]`, `del flood.moderation.<n>|all` | `set flood.moderation public "Noisy User" rate=5/min hops=4` |
@@ -288,6 +288,11 @@ the bucket rules below instead.
| `flood.retry.bucket.<n>` | Shows one bridge bucket. Buckets are numbered `1`-`6`. | `get flood.retry.bucket.<n>` | `get flood.retry.bucket.1` |
| `flood.retry.bucket` | Sets bridge bucket prefixes. | `set flood.retry.bucket <1-6> <prefixes/none/off>` | `set flood.retry.bucket 1 71CE82,C7618C` |
Bridge buckets may also be reused as passive `flood.channel.scope`
path selectors with `path=bucket:<1-6>`. This does not require
`flood.retry.bridge` to be enabled and does not depend on recent-repeater
freshness or the retry ignore list.
Forwarded neighbor adverts also use an automatic echo guard in every build. If this node hears a downstream, longer-path echo after transmitting an advert whose signed timestamp is less than six hours old, it will not forward that exact advert again during the six-hour age window. This guard works even when `flood.retry.advert` is off and does not affect self-originated adverts.
Self-originated advert retries are deliberately slow: the first retry waits at least one extra minute beyond the normal airtime-aware delay. Queueing a newer self advert retires queued and future retry attempts for older self adverts without disturbing other flood retry sequences. Companion firmware allows the one slow retry for its own adverts but not for neighbor adverts it relays.
+140 -40
View File
@@ -5305,7 +5305,9 @@ void MyMesh::loadFloodChannelScopes() {
bool success = file.read(magic, sizeof(magic)) == sizeof(magic);
bool version_1 = success && memcmp(magic, "FCS1", sizeof(magic)) == 0;
bool version_2 = success && memcmp(magic, "FCS2", sizeof(magic)) == 0;
success = (version_1 || version_2)
bool version_3 = success && memcmp(magic, "FCS3", sizeof(magic)) == 0;
bool version_4 = success && memcmp(magic, "FCS4", sizeof(magic)) == 0;
success = (version_1 || version_2 || version_3 || version_4)
&& file.read(&count, sizeof(count)) == sizeof(count);
uint8_t retained = count < FLOOD_CHANNEL_SCOPE_SLOTS ? count : FLOOD_CHANNEL_SCOPE_SLOTS;
size_t retained_bytes = (size_t)retained * sizeof(FloodChannelScopeEntry);
@@ -5316,8 +5318,14 @@ void MyMesh::loadFloodChannelScopes() {
}
for (int i = 0; success && i < retained; i++) {
auto& entry = flood_channel_scopes[i];
if (version_1
&& FloodFilterPolicy::scopeUsesSlowTiming(entry.selector)) {
if ((version_1
&& (FloodFilterPolicy::scopeUsesSlowTiming(entry.selector)
|| FloodFilterPolicy::scopeRequiresPath(entry.selector)))
|| (version_2
&& FloodFilterPolicy::scopeRequiresPath(entry.selector))
|| (version_3
&& FloodFilterPolicy::scopePathSelectorValue(entry.selector)
> FloodFilterPolicy::SCOPE_PATH_BLACKLIST)) {
success = false;
break;
}
@@ -5348,7 +5356,7 @@ bool MyMesh::saveFloodChannelScopes(bool empty_table) {
File file = openFloodChannelBlockWrite(_fs, FLOOD_CHANNEL_SCOPE_TEMP_FILE);
if (!file) return false;
const uint8_t magic[4] = {'F', 'C', 'S', '2'};
const uint8_t magic[4] = {'F', 'C', 'S', '4'};
uint8_t count = FLOOD_CHANNEL_SCOPE_SLOTS;
bool success = file.write(magic, sizeof(magic)) == sizeof(magic)
&& file.write(&count, sizeof(count)) == sizeof(count);
@@ -5514,6 +5522,12 @@ bool MyMesh::applyFloodChannelScopeTarget(mesh::Packet* packet,
bool MyMesh::applyFloodChannelScope(mesh::Packet* packet, bool& fast_track,
bool log_change) {
static_assert(FLOOD_RETRY_BRIDGE_BUCKETS
== FloodFilterPolicy::SCOPE_PATH_BRIDGE_BUCKET_COUNT,
"channel scope and bridge bucket counts must agree");
static_assert(FLOOD_RETRY_PREFIX_LEN
== FloodFilterPolicy::BLACKLIST_ID_SIZE,
"channel scope and bridge bucket ID sizes must agree");
fast_track = false;
if (packet == NULL || !packet->isRouteFlood()) return false;
uint8_t type = packet->getPayloadType();
@@ -5523,32 +5537,71 @@ bool MyMesh::applyFloodChannelScope(mesh::Packet* packet, bool& fast_track,
bool valid_channel_layout = (type == PAYLOAD_TYPE_GRP_TXT || type == PAYLOAD_TYPE_GRP_DATA)
&& packet->payload_len >= PATH_HASH_SIZE + CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE
&& ((packet->payload_len - PATH_HASH_SIZE - CIPHER_MAC_SIZE) % CIPHER_BLOCK_SIZE) == 0;
uint8_t path_match_checked = 0;
uint8_t path_match_results = 0;
auto path_selector_matches = [&](uint8_t path_selector) {
if (path_selector == FloodFilterPolicy::SCOPE_PATH_NONE) return true;
uint8_t match_bit = (uint8_t)(1U << (path_selector - 1U));
if ((path_match_checked & match_bit) == 0) {
bool matches = false;
if (path_selector == FloodFilterPolicy::SCOPE_PATH_BLACKLIST) {
matches = floodPacketFilterBlacklistMatches(packet);
} else {
uint8_t bucket = (uint8_t)(
path_selector - FloodFilterPolicy::SCOPE_PATH_BRIDGE_BUCKET_BASE);
if (bucket < FLOOD_RETRY_BRIDGE_BUCKETS) {
matches = FloodFilterPolicy::pathMatchesConfiguredIds(
packet,
(const uint8_t*)_prefs.flood_retry_bridge_buckets[bucket],
FLOOD_RETRY_BUCKET_PREFIXES);
}
}
path_match_checked |= match_bit;
if (matches) path_match_results |= match_bit;
}
return (path_match_results & match_bit) != 0;
};
auto matches_path_pass = [&](uint8_t selector, bool qualified_pass) {
uint8_t path_selector =
FloodFilterPolicy::scopePathSelectorValue(selector);
bool path_matches = path_selector_matches(path_selector);
return FloodFilterPolicy::scopePathPassMatches(
selector, qualified_pass, path_matches);
};
if (valid_channel_layout) {
uint8_t data[MAX_PACKET_PAYLOAD];
for (int i = 0; i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) {
const auto& entry = flood_channel_scopes[i];
if (entry.region_id == 0 || !isExactFloodChannelScopeSelector(entry.selector)
|| packet->payload[0] != entry.channel_hash) continue;
if (mesh::Utils::MACThenDecrypt(entry.secret, data, &packet->payload[PATH_HASH_SIZE],
packet->payload_len - PATH_HASH_SIZE) > 0) {
bool scope_changed;
if (applyFloodChannelScopeTarget(packet, entry, scope_changed, fast_track,
log_change)) {
return scope_changed;
for (int path_pass = 1; path_pass >= 0; path_pass--) {
for (int i = 0; i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) {
const auto& entry = flood_channel_scopes[i];
if (entry.region_id == 0 || !isExactFloodChannelScopeSelector(entry.selector)
|| packet->payload[0] != entry.channel_hash
|| !matches_path_pass(entry.selector, path_pass != 0)) {
continue;
}
if (mesh::Utils::MACThenDecrypt(entry.secret, data, &packet->payload[PATH_HASH_SIZE],
packet->payload_len - PATH_HASH_SIZE) > 0) {
bool scope_changed;
if (applyFloodChannelScopeTarget(packet, entry, scope_changed, fast_track,
log_change)) {
return scope_changed;
}
}
}
}
}
uint8_t wildcard = wildcardFloodChannelScopeSelector(type);
for (int i = 0; i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) {
const auto& entry = flood_channel_scopes[i];
if (entry.region_id != 0
&& FloodFilterPolicy::scopeSelectorValue(entry.selector) == wildcard) {
bool scope_changed;
if (applyFloodChannelScopeTarget(packet, entry, scope_changed, fast_track,
log_change)) {
return scope_changed;
for (int path_pass = 1; path_pass >= 0; path_pass--) {
for (int i = 0; i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) {
const auto& entry = flood_channel_scopes[i];
if (entry.region_id != 0
&& FloodFilterPolicy::scopeSelectorValue(entry.selector) == wildcard
&& matches_path_pass(entry.selector, path_pass != 0)) {
bool scope_changed;
if (applyFloodChannelScopeTarget(packet, entry, scope_changed, fast_track,
log_change)) {
return scope_changed;
}
}
}
}
@@ -5690,14 +5743,29 @@ void MyMesh::formatFloodChannelScopeDetail(int index, char* reply, size_t reply_
mesh::Utils::toHex(channel, prefix, sizeof(prefix));
}
RegionEntry* region = region_map.findById(entry.region_id);
char path[24];
uint8_t path_selector =
FloodFilterPolicy::scopePathSelectorValue(entry.selector);
if (path_selector == FloodFilterPolicy::SCOPE_PATH_BLACKLIST) {
strcpy(path, " path=blacklist");
} else {
uint8_t bucket =
FloodFilterPolicy::scopeBridgeBucketIndex(entry.selector);
if (bucket != FloodFilterPolicy::SCOPE_PATH_INVALID_BUCKET) {
snprintf(path, sizeof(path), " path=bucket:%u",
(unsigned int)bucket + 1U);
} else {
path[0] = 0;
}
}
const char* timing =
FloodFilterPolicy::scopeUsesSlowTiming(entry.selector) ? " tx=slow" : "";
if (region != NULL) {
snprintf(reply, reply_len, "> %d match=%s scope=%s%s",
index + 1, channel, region->name, timing);
snprintf(reply, reply_len, "> %d match=%s%s scope=%s%s",
index + 1, channel, path, region->name, timing);
} else {
snprintf(reply, reply_len, "> %d match=%s scope=id:%u?%s", index + 1,
channel, (unsigned int)entry.region_id, timing);
snprintf(reply, reply_len, "> %d match=%s%s scope=id:%u?%s", index + 1,
channel, path, (unsigned int)entry.region_id, timing);
}
}
@@ -5735,27 +5803,56 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) {
char channel_text[80];
char region_text[32];
char timing_text[12];
if (takeFloodModerationToken(cursor, channel_text, sizeof(channel_text)) != 1
|| takeFloodModerationToken(cursor, region_text, sizeof(region_text)) != 1) {
strcpy(reply, "Err - use: set flood.channel.scope[.n] <match> <region> [tx=slow]");
strcpy(reply, "Err - use: set flood.channel.scope[.n] <match> <region> [path=blacklist|path=bucket:1-6] [tx=slow]");
return;
}
int timing_result =
takeFloodModerationToken(cursor, timing_text, sizeof(timing_text));
bool slow_timing = false;
if (timing_result == 1) {
if (floodFilterAsciiEqual(timing_text, "tx=slow")) {
slow_timing = true;
} else if (!floodFilterAsciiEqual(timing_text, "tx=fast")) {
strcpy(reply, "Err - tx timing must be fast or slow");
uint8_t path_selector = FloodFilterPolicy::SCOPE_PATH_NONE;
bool timing_set = false;
bool path_set = false;
char option[20];
int option_result;
while ((option_result =
takeFloodModerationToken(cursor, option, sizeof(option))) == 1) {
if (floodFilterAsciiEqual(option, "tx=slow")
|| floodFilterAsciiEqual(option, "tx=fast")) {
if (timing_set) {
strcpy(reply, "Err - duplicate tx timing");
return;
}
timing_set = true;
slow_timing = floodFilterAsciiEqual(option, "tx=slow");
} else if (floodFilterAsciiEqual(option, "path=blacklist")) {
if (path_set) {
strcpy(reply, "Err - duplicate path matcher");
return;
}
path_set = true;
path_selector = FloodFilterPolicy::SCOPE_PATH_BLACKLIST;
} else if (strncmp(option, "path=bucket:", 12) == 0) {
if (path_set) {
strcpy(reply, "Err - duplicate path matcher");
return;
}
uint8_t bucket = 0;
if (!parseFloodFilterUnsigned(
option + 12, FLOOD_RETRY_BRIDGE_BUCKETS, bucket)
|| bucket == 0) {
strcpy(reply, "Err - path bucket must be 1-6");
return;
}
path_set = true;
path_selector = (uint8_t)(
FloodFilterPolicy::SCOPE_PATH_BRIDGE_BUCKET_BASE + bucket - 1U);
} else {
strcpy(reply, "Err - scope option must be path=blacklist, path=bucket:1-6, or tx=fast|slow");
return;
}
}
char extra[2];
if (timing_result < 0
|| takeFloodModerationToken(cursor, extra, sizeof(extra)) != 0) {
strcpy(reply, "Err - use: set flood.channel.scope[.n] <match> <region> [tx=slow]");
if (option_result < 0) {
strcpy(reply, "Err - use: set flood.channel.scope[.n] <match> <region> [path=blacklist|path=bucket:1-6] [tx=slow]");
return;
}
@@ -5796,6 +5893,8 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) {
if (entry.region_id == 0) {
if (free_slot < 0) free_slot = i;
} else if (FloodFilterPolicy::scopeSelectorValue(entry.selector) == selector
&& FloodFilterPolicy::scopePathSelectorValue(entry.selector)
== path_selector
&& (!isExactFloodChannelScopeSelector(selector)
|| memcmp(entry.secret, secret, PUB_KEY_SIZE) == 0)) {
matching_slot = i;
@@ -5813,7 +5912,8 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) {
memset(&entry, 0, sizeof(entry));
entry.region_id = region->id;
entry.selector =
FloodFilterPolicy::encodeScopeSelector(selector, slow_timing);
FloodFilterPolicy::encodeScopeSelector(
selector, slow_timing, path_selector);
if (isExactFloodChannelScopeSelector(selector)) {
entry.channel_hash = hash_prefix[0];
memcpy(entry.secret, secret, sizeof(entry.secret));
+1 -1
View File
@@ -286,7 +286,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
};
struct FloodChannelScopeEntry {
uint16_t region_id; // zero means unused
// 0/1/2 are txt:*/login:*/other:*; 16/32 are exact channel-key lengths.
// Low bits select txt:*/login:*/other:* or an exact channel-key length.
uint8_t selector;
uint8_t channel_hash;
uint8_t secret[PUB_KEY_SIZE];
+84 -3
View File
@@ -9,10 +9,19 @@
namespace FloodFilterPolicy {
static constexpr uint8_t BLACKLIST_ID_SIZE = 3;
static constexpr uint8_t SCOPE_PATH_LOW_MASK = 0x0C;
static constexpr uint8_t SCOPE_PATH_HIGH_FLAG = 0x40;
static constexpr uint8_t SCOPE_PATH_MASK =
SCOPE_PATH_LOW_MASK | SCOPE_PATH_HIGH_FLAG;
static constexpr uint8_t SLOW_SCOPE_FLAG = 0x80;
static constexpr uint8_t SLOW_SCOPE_TX_DELAY_FACTOR = 2;
static constexpr float SLOW_SCOPE_RX_DELAY_MIN = 2.0f;
static constexpr uint32_t MAX_DISPATCH_DELAY = 0xFFFFFF;
static constexpr uint8_t SCOPE_PATH_NONE = 0;
static constexpr uint8_t SCOPE_PATH_BLACKLIST = 1;
static constexpr uint8_t SCOPE_PATH_BRIDGE_BUCKET_BASE = 2;
static constexpr uint8_t SCOPE_PATH_BRIDGE_BUCKET_COUNT = 6;
static constexpr uint8_t SCOPE_PATH_INVALID_BUCKET = 0xFF;
enum ChannelScopeGate {
CHANNEL_SCOPE_USE_GLOBAL,
@@ -35,18 +44,71 @@ inline ChannelScopeGate channelScopeGate(bool table_active,
: CHANNEL_SCOPE_REQUIRED_REJECTED;
}
inline uint8_t encodeScopeSelector(uint8_t selector, bool slow) {
return slow ? (uint8_t)(selector | SLOW_SCOPE_FLAG) : selector;
inline uint8_t encodeScopeSelector(uint8_t selector, bool slow,
uint8_t path_selector = SCOPE_PATH_NONE) {
uint8_t stored = selector;
if (slow) stored |= SLOW_SCOPE_FLAG;
if (path_selector == SCOPE_PATH_BLACKLIST) {
stored |= SCOPE_PATH_HIGH_FLAG;
} else if (path_selector >= SCOPE_PATH_BRIDGE_BUCKET_BASE
&& path_selector < SCOPE_PATH_BRIDGE_BUCKET_BASE
+ SCOPE_PATH_BRIDGE_BUCKET_COUNT) {
uint8_t bucket =
(uint8_t)(path_selector - SCOPE_PATH_BRIDGE_BUCKET_BASE);
stored |= (uint8_t)(((bucket % 3U) + 1U) << 2);
if (bucket >= 3U) stored |= SCOPE_PATH_HIGH_FLAG;
}
return stored;
}
inline uint8_t scopeSelectorValue(uint8_t stored_selector) {
return stored_selector & (uint8_t)~SLOW_SCOPE_FLAG;
return stored_selector
& (uint8_t)~(SLOW_SCOPE_FLAG | SCOPE_PATH_MASK);
}
inline bool scopeUsesSlowTiming(uint8_t stored_selector) {
return (stored_selector & SLOW_SCOPE_FLAG) != 0;
}
inline uint8_t scopePathSelectorValue(uint8_t stored_selector) {
uint8_t low = (uint8_t)((stored_selector & SCOPE_PATH_LOW_MASK) >> 2);
bool high = (stored_selector & SCOPE_PATH_HIGH_FLAG) != 0;
if (low == 0) return high ? SCOPE_PATH_BLACKLIST : SCOPE_PATH_NONE;
uint8_t bucket = (uint8_t)(low - 1U + (high ? 3U : 0U));
return (uint8_t)(SCOPE_PATH_BRIDGE_BUCKET_BASE + bucket);
}
inline bool scopeRequiresPath(uint8_t stored_selector) {
return scopePathSelectorValue(stored_selector) != SCOPE_PATH_NONE;
}
inline bool scopeRequiresBlacklistPath(uint8_t stored_selector) {
return scopePathSelectorValue(stored_selector) == SCOPE_PATH_BLACKLIST;
}
inline uint8_t scopeBridgeBucketIndex(uint8_t stored_selector) {
uint8_t path_selector = scopePathSelectorValue(stored_selector);
if (path_selector < SCOPE_PATH_BRIDGE_BUCKET_BASE
|| path_selector >= SCOPE_PATH_BRIDGE_BUCKET_BASE
+ SCOPE_PATH_BRIDGE_BUCKET_COUNT) {
return SCOPE_PATH_INVALID_BUCKET;
}
return (uint8_t)(path_selector - SCOPE_PATH_BRIDGE_BUCKET_BASE);
}
inline bool scopePathQualifierMatches(uint8_t stored_selector,
bool path_matches) {
return !scopeRequiresPath(stored_selector)
|| path_matches;
}
inline bool scopePathPassMatches(uint8_t stored_selector,
bool qualified_pass,
bool path_matches) {
return scopeRequiresPath(stored_selector) == qualified_pass
&& scopePathQualifierMatches(stored_selector, path_matches);
}
inline bool fastTrackScopeChange(bool scope_changed, bool slow) {
return scope_changed && !slow;
}
@@ -70,6 +132,18 @@ inline uint8_t blacklistMatchThreshold(uint8_t path_hash_size) {
return 0;
}
inline uint8_t configuredIdCount(const uint8_t* ids,
uint8_t maximum_count) {
if (ids == NULL) return 0;
uint8_t count = 0;
while (count < maximum_count) {
const uint8_t* id = &ids[count * BLACKLIST_ID_SIZE];
if (id[0] == 0 && id[1] == 0 && id[2] == 0) break;
count++;
}
return count;
}
inline bool pathMatchesBlacklist(const mesh::Packet* packet,
const uint8_t* blacklist,
uint8_t blacklist_count) {
@@ -95,6 +169,13 @@ inline bool pathMatchesBlacklist(const mesh::Packet* packet,
return false;
}
inline bool pathMatchesConfiguredIds(const mesh::Packet* packet,
const uint8_t* ids,
uint8_t maximum_count) {
return pathMatchesBlacklist(
packet, ids, configuredIdCount(ids, maximum_count));
}
inline bool scopeRuleAllowed(bool requires_region_match,
bool incoming_region_allowed) {
return !requires_region_match || incoming_region_allowed;
+1 -1
View File
@@ -34,7 +34,7 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_mqtt_prefs_codec` | `src/helpers/MQTTPrefsStorage.h`, `src/helpers/MQTTPrefsCodec.h` | binary pre-slot/3-slot/6-slot migration fixtures; v1 header integrity; downgrade preservation |
| `test_mqtt_prefs_atomic_store` | `src/helpers/MQTTPrefsAtomicStore.h` | transactional MQTT writes and legacy `/node_prefs` handoff; exact short-write detection; begin/finish/rename failure cleanup; original-file preservation |
| `test_mqtt_payload_builder` | `src/helpers/MQTTPayloadBuilder.cpp` | status/packet/raw JSON contracts; optional fields; escaping; RX metrics and path; score handling; exact buffer bounds; maximum representative payloads |
| `test_flood_filter_policy` | `src/helpers/FloodFilterPolicy.h` | unordered 3-byte and 2-byte-prefix blacklist matching; match thresholds; repeated path-entry semantics; `require=region` and per-channel scope-gate truth tables; fast/slow scope timing; adding, replacing, and preserving packet scope |
| `test_flood_filter_policy` | `src/helpers/FloodFilterPolicy.h` | unordered 3-byte and 2-byte-prefix path matching; match thresholds; repeated path-entry semantics; blacklist and bridge-bucket channel-scope selection; `require=region` and per-channel scope-gate truth tables; fast/slow scope timing; adding, replacing, and preserving packet scope |
| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) |
## Conventions (and how to add a suite)
@@ -113,6 +113,20 @@ TEST(FloodFilterBlacklist, Esp32MaximumListIncludesTheLastEntry) {
&packet, &blacklist[0][0], 255));
}
TEST(FloodFilterBlacklist, ConfiguredBucketStopsAtEmptyTrailingIds) {
uint8_t bucket[17][3] = {
{0x10, 0x20, 0x30},
{0xAA, 0xBB, 0xCC},
};
const uint8_t path[] = {0xAA, 0xBB, 0xCC};
mesh::Packet packet = makeFloodPacket(3, path, 1);
EXPECT_EQ(2, FloodFilterPolicy::configuredIdCount(
&bucket[0][0], 17));
EXPECT_TRUE(FloodFilterPolicy::pathMatchesConfiguredIds(
&packet, &bucket[0][0], 17));
}
TEST(FloodFilterScope, RegionRequirementHasTheExpectedTruthTable) {
EXPECT_TRUE(FloodFilterPolicy::scopeRuleAllowed(false, false));
EXPECT_TRUE(FloodFilterPolicy::scopeRuleAllowed(false, true));
@@ -123,12 +137,60 @@ TEST(FloodFilterScope, RegionRequirementHasTheExpectedTruthTable) {
TEST(FloodFilterScope, SlowTimingFlagRoundTripsWithoutChangingSelector) {
const uint8_t selector = 16;
const uint8_t stored =
FloodFilterPolicy::encodeScopeSelector(selector, true);
FloodFilterPolicy::encodeScopeSelector(
selector, true, FloodFilterPolicy::SCOPE_PATH_BLACKLIST);
EXPECT_EQ(selector, FloodFilterPolicy::scopeSelectorValue(stored));
EXPECT_TRUE(FloodFilterPolicy::scopeUsesSlowTiming(stored));
EXPECT_TRUE(FloodFilterPolicy::scopeRequiresBlacklistPath(stored));
EXPECT_FALSE(FloodFilterPolicy::scopeUsesSlowTiming(
FloodFilterPolicy::encodeScopeSelector(selector, false)));
EXPECT_FALSE(FloodFilterPolicy::scopeRequiresBlacklistPath(
FloodFilterPolicy::encodeScopeSelector(
selector, true, FloodFilterPolicy::SCOPE_PATH_NONE)));
}
TEST(FloodFilterScope, PathQualifiedScopeRequiresBlacklistMatch) {
const uint8_t selector = 16;
const uint8_t general =
FloodFilterPolicy::encodeScopeSelector(
selector, false, FloodFilterPolicy::SCOPE_PATH_NONE);
const uint8_t qualified =
FloodFilterPolicy::encodeScopeSelector(
selector, false, FloodFilterPolicy::SCOPE_PATH_BLACKLIST);
EXPECT_TRUE(FloodFilterPolicy::scopePathQualifierMatches(general, false));
EXPECT_TRUE(FloodFilterPolicy::scopePathQualifierMatches(general, true));
EXPECT_FALSE(FloodFilterPolicy::scopePathQualifierMatches(qualified, false));
EXPECT_TRUE(FloodFilterPolicy::scopePathQualifierMatches(qualified, true));
EXPECT_FALSE(FloodFilterPolicy::scopePathPassMatches(general, true, true));
EXPECT_TRUE(FloodFilterPolicy::scopePathPassMatches(general, false, false));
EXPECT_TRUE(FloodFilterPolicy::scopePathPassMatches(qualified, true, true));
EXPECT_FALSE(FloodFilterPolicy::scopePathPassMatches(
qualified, true, false));
EXPECT_FALSE(FloodFilterPolicy::scopePathPassMatches(
qualified, false, true));
}
TEST(FloodFilterScope, EveryBridgeBucketRoundTripsWithoutChangingSelector) {
const uint8_t selector = 32;
for (uint8_t bucket = 0;
bucket < FloodFilterPolicy::SCOPE_PATH_BRIDGE_BUCKET_COUNT;
bucket++) {
uint8_t path_selector = (uint8_t)(
FloodFilterPolicy::SCOPE_PATH_BRIDGE_BUCKET_BASE + bucket);
uint8_t stored = FloodFilterPolicy::encodeScopeSelector(
selector, true, path_selector);
EXPECT_EQ(selector, FloodFilterPolicy::scopeSelectorValue(stored));
EXPECT_EQ(path_selector,
FloodFilterPolicy::scopePathSelectorValue(stored));
EXPECT_EQ(bucket, FloodFilterPolicy::scopeBridgeBucketIndex(stored));
EXPECT_TRUE(FloodFilterPolicy::scopeRequiresPath(stored));
EXPECT_FALSE(FloodFilterPolicy::scopeRequiresBlacklistPath(stored));
EXPECT_TRUE(FloodFilterPolicy::scopeUsesSlowTiming(stored));
}
}
TEST(FloodFilterScope, OnlyChangedFastRulesReceiveFastTrackTreatment) {