diff --git a/docs/cli_commands.md b/docs/cli_commands.md index f54362800..859694364 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1213,8 +1213,8 @@ del flood.channel.block.2 **Usage:** - `get flood.channel.scope` - `get flood.channel.scope.` -- `set flood.channel.scope [tx=slow]` -- `set flood.channel.scope. [tx=slow]` +- `set flood.channel.scope [path=blacklist|path=bucket:1-6] [tx=slow]` +- `set flood.channel.scope. [path=blacklist|path=bucket:1-6] [tx=slow]` - `del flood.channel.scope.` - `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 diff --git a/docs/flood_filtering.md b/docs/flood_filtering.md index c579ded4f..d3945d9cc 100644 --- a/docs/flood_filtering.md +++ b/docs/flood_filtering.md @@ -47,8 +47,8 @@ silently enable blocking. the scope of a transport-scoped flood before this repeater forwards it: ```text -set flood.channel.scope [tx=slow] -set flood.channel.scope. [tx=slow] +set flood.channel.scope [path=blacklist|path=bucket:1-6] [tx=slow] +set flood.channel.scope. [path=blacklist|path=bucket:1-6] [tx=slow] get flood.channel.scope get flood.channel.scope. del flood.channel.scope. @@ -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. diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index 5305a4664..b0dd88e39 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -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 ` | `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=` for a per-channel hop override. | `get flood.channel.block`, `set flood.channel.block[.n] [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 ` | `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] [tx=slow]`, `del flood.channel.scope.|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] [path=blacklist|path=bucket:1-6] [tx=slow]`, `del flood.channel.scope.|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] `, `del flood.channel.scope.require.|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=` 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] [N|N+|N-M|all] [path=blacklist] [scope=] [require=region] [tx=slow] [suspend=tempradio]`, `del flood.filter.|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] [path=...]`, `del flood.moderation.|all` | `set flood.moderation public "Noisy User" rate=5/min hops=4` | @@ -288,6 +288,11 @@ the bucket rules below instead. | `flood.retry.bucket.` | Shows one bridge bucket. Buckets are numbered `1`-`6`. | `get flood.retry.bucket.` | `get flood.retry.bucket.1` | | `flood.retry.bucket` | Sets bridge bucket prefixes. | `set flood.retry.bucket <1-6> ` | `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. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index dd6903040..8272997dd 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -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] [tx=slow]"); + strcpy(reply, "Err - use: set flood.channel.scope[.n] [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] [tx=slow]"); + if (option_result < 0) { + strcpy(reply, "Err - use: set flood.channel.scope[.n] [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)); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 4b2e60bed..4e90041bf 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -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]; diff --git a/src/helpers/FloodFilterPolicy.h b/src/helpers/FloodFilterPolicy.h index 6c7e66a05..5a954999d 100644 --- a/src/helpers/FloodFilterPolicy.h +++ b/src/helpers/FloodFilterPolicy.h @@ -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; diff --git a/test/README.md b/test/README.md index ec1ec11c7..7e31b14e6 100644 --- a/test/README.md +++ b/test/README.md @@ -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) diff --git a/test/test_flood_filter_policy/test_flood_filter_policy.cpp b/test/test_flood_filter_policy/test_flood_filter_policy.cpp index e437f0c62..53f85c516 100644 --- a/test/test_flood_filter_policy/test_flood_filter_policy.cpp +++ b/test/test_flood_filter_policy/test_flood_filter_policy.cpp @@ -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) {