From a05acd72bea9604b2422c1d86a7f321f82edeccc Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 28 Jul 2026 16:05:52 -0700 Subject: [PATCH] Extend repeater flood control and logging reliability --- docs/cli_commands.md | 265 +++- docs/flood_filtering.md | 270 +++- docs/halo_keymind_settings.md | 5 +- examples/simple_repeater/MyMesh.cpp | 1136 +++++++++++++++-- examples/simple_repeater/MyMesh.h | 64 +- examples/simple_room_server/MyMesh.cpp | 11 +- src/Dispatcher.cpp | 26 +- src/Dispatcher.h | 8 + src/helpers/FloodFilterPolicy.h | 118 ++ test/README.md | 1 + .../test_flood_filter_policy.cpp | 256 ++++ .../test_rx_reserve_packet_manager.cpp | 164 ++- 12 files changed, 2132 insertions(+), 192 deletions(-) create mode 100644 src/helpers/FloodFilterPolicy.h create mode 100644 test/test_flood_filter_policy/test_flood_filter_policy.cpp diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 4dccfb46..f5436280 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -236,6 +236,18 @@ refresh is already in flight, the scope pass is queued behind it. ## Logging +Builds compiled with `MESH_PACKET_LOGGING` emit one `RAW:` line for every +received radio frame. Serial output uses backpressure: if a connected host +temporarily stops reading, packet processing waits for USB transmit space +instead of silently omitting the record. A disconnected host cannot retain an +unbounded capture, so logging deployments should keep the reader attached and +draining the serial port. + +Ordinary `-logging-` artifacts keep packet logging separate from LoRa OTA. +Use the separately named `-ota-` artifact when LoRa OTA is required. A +`-full-logging-ota-` artifact is intentionally the exception: FULL profiles +include every supported feature. + ### Begin capture of rx log to node storage **Usage:** `log start` @@ -958,6 +970,15 @@ get clock.sync.status **Description:** When enabled, the radio performs a hardware Channel Activity Detection scan before transmitting and defers if the channel is busy. Runs independently of `int.thresh` - either, both, or none may be active. The Cascade firmware profile defaults CAD to `on`; target-default builds continue to default it to `off`. +The repeater applies the saved toggle to the radio during its periodic +noise-floor service. With one runnable packet, a busy result uses the normal +CAD retry delay and allows roughly four seconds of continuous busy results. +As the runnable transmit queue grows, both delays are divided by its depth: +retry spacing will not fall below 50 ms and the busy ceiling will not fall +below 500 ms. Reaching the busy ceiling records a CAD-timeout error and +attempts the next queued transmission rather than waiting indefinitely. +Future-scheduled packets do not accelerate CAD. `get cad` includes the +hardware busy-result count. **Parameters:** - `on|off`: Enable or disable hardware CAD @@ -1188,12 +1209,12 @@ del flood.channel.block.2 --- -#### Force a transport scope onto unscoped floods +#### Force a transport scope onto floods **Usage:** - `get flood.channel.scope` - `get flood.channel.scope.` -- `set flood.channel.scope ` -- `set flood.channel.scope. ` +- `set flood.channel.scope [tx=slow]` +- `set flood.channel.scope. [tx=slow]` - `del flood.channel.scope.` - `del flood.channel.scope all` @@ -1214,6 +1235,11 @@ 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. +- `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 + `2.0` after changing the scope. The default is fast; `tx=fast` may be + supplied explicitly when replacing a slow row. **Default:** No forced scopes. @@ -1228,42 +1254,56 @@ slot. The three wildcard classes are independent and consume one slot each. form for row detail. Keyed rows are displayed by the first four bytes of their derived channel hash because channel secrets are never returned. -This acts on received, unscoped `ROUTE_TYPE_FLOOD` packets. 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. +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. 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 scope, an existing transport code is preserved, and region/unknown-code gates do not block it. -On a match, the repeater changes the route to -`ROUTE_TYPE_TRANSPORT_FLOOD`, computes transport code 0 from the selected -region key and packet payload, and leaves transport code 1 as zero. This occurs -before region enforcement, forwarding filters, and the seen-packet lookup. -Already-scoped packets and direct routes are never rewritten. The rewritten -packet is no longer subject to `flood.max.unscoped`, but remains subject to -normal payload handling, `flood.max`, region allow/deny, `flood.filter`, +On a match, the repeater sets the route to `ROUTE_TYPE_TRANSPORT_FLOOD`, +computes transport code 0 from the selected region key and packet payload, and +sets transport code 1 to zero. This occurs before region enforcement, +forwarding filters, and the seen-packet lookup. For an already-scoped packet, +the selected code replaces both incoming transport-code fields. Direct routes +are never rewritten. A packet converted from unscoped is no longer subject to +`flood.max.unscoped`; all rewritten packets remain subject to normal payload +handling, `flood.max`, region allow/deny, `flood.filter`, `flood.channel.block`, loop detection, and moderation. Assigning a scope does -not make a packet type forwardable if the core would otherwise reject it. +not make a packet type forwardable if the core would otherwise reject it. By +default, if the selected scope differs and the rewritten packet is accepted +for forwarding, its initial retransmission uses zero `txdelay` and the highest +outbound queue priority so the newly scoped copy can win at the next hop. +Adding `tx=slow` uses an effective inbound `rxdelay` base of +`max(2, configured rxdelay * 2)`, keeps the ordinary queue priority, and uses +the maximum `txdelay` factor of `2.0` for the retransmission. As with ordinary +`txdelay`, the actual transmit delay is randomized from zero through the +resulting window; factor `2.0` gives a maximum of ten packet airtimes. +Neither mode preempts an active radio transmission or bypasses CAD and +airtime-budget limits. Selecting the scope already carried by the packet is a +no-op and does not grant special transmit treatment. If a row's target region has been removed or has no usable key, the repeater tries the next applicable row. For group packets this means later authenticated exact rows followed by `txt:*`; wildcard duplicates likewise fall through to -the next usable slot. The packet remains unscoped only when no usable mapping -exists. +the next usable slot. When no usable mapping exists, the packet retains its +original unscoped or scoped route. LoRa OTA remains functional when `other:*` is configured. OTA packets are -given that region's transport code, but the OTA handler still accepts and -re-floods them during the temporary-radio window. The target region must allow -flooding. The OTA core itself is dormant outside that window; no default flood -filter row is needed for that behavior. Forced scope does not make OTA operate -outside the window. +given that region's transport code, replacing an existing code when necessary, +but the OTA handler still accepts and re-floods them during the temporary-radio +window. The target region must allow flooding. The OTA core itself is dormant +outside that window; no default flood filter row is needed for that behavior. +Forced scope does not make OTA operate outside the window. **Capacity cost:** Each slot uses 36 bytes of runtime RAM and persistent storage, plus a 5-byte file header. The four-slot minimum uses 144 bytes RAM and @@ -1283,9 +1323,9 @@ same payload later arrives scoped, unscoped, or through a different region, it is still the same seen packet. `TRACE` is the exception only in that its encoded `path_len` byte is also hashed. -While equivalent non-TRACE flood copies are waiting in `rxdelay`, the normal receive-quality -timing still selects the packet to process, but that winner receives a scope -from the queued scoped copies with the same dedupe identity. If the copies +While equivalent non-TRACE flood copies are waiting in `rxdelay`, the normal +receive-quality timing still selects the packet to process, but that winner +receives a scope from the queued scoped copies with the same dedupe identity. If the copies carry different locally allowed scopes, the scope from the shortest received path wins. Unknown and denied transport codes are not candidates and therefore cannot overwrite an unscoped winner. With equal path lengths, the deeper child @@ -1299,12 +1339,17 @@ scope and path for arbitration. It can only use copies still present in is excluded from scope arbitration entirely, so rxdelay never adds or replaces a trace transport code. +A packet that matches a fast `flood.channel.scope` or `flood.filter scope=` +action and needs its scope changed bypasses the inbound `rxdelay` queue. A +`tx=slow` row remains in that queue with twice the configured base, floored at +`2.0`, and participates in normal queued-copy scope arbitration. + **Examples:** ```text region put west region save set flood.channel.scope #local west -set flood.channel.scope.2 txt:* west +set flood.channel.scope.2 txt:* west tx=slow set flood.channel.scope.3 login:* west set flood.channel.scope.4 other:* west get flood.channel.scope @@ -1314,7 +1359,54 @@ del flood.channel.scope.2 --- -#### Filter flood packets by payload type and hop +#### Require valid incoming scopes only on selected channels + +**Usage:** +- `get flood.channel.scope.require` +- `get flood.channel.scope.require.` +- `set flood.channel.scope.require ` +- `set flood.channel.scope.require. ` +- `del flood.channel.scope.require.` +- `del flood.channel.scope.require all` + +**Default:** Empty; normal global region enforcement remains active. + +Once this table contains a row, received flood `GRP_TXT` and `GRP_DATA` +packets use selective region enforcement. A packet authenticating against a +listed channel key must already carry a transport scope matching a locally +flood-allowed region. Listed channels arriving unscoped, with an unknown code, +or with a denied region are not retransmitted. This tests the original +incoming scope before any `flood.channel.scope` or `flood.filter scope=` +rewrite. Those rewrite actions are skipped for a rejected listed channel, so +they cannot rescue it or grant special receive/transmit timing. + +Other group channels bypass the region/unknown-code gate while the table is +active. They remain subject to every other forwarding control, including +`repeat`, `flood.max*`, packet filters, channel blocks, loop detection, payload +validation, and moderation. Non-channel flood payload types retain normal +global region enforcement. + +Channel matching validates the packet MAC/decryption with the configured key; +the visible one-byte channel hash is only a prefilter. Public hashtag channels +use their derived public key. Without `.n`, setting an existing key updates it +and a new key uses the first empty slot. Numbered `set` replaces that slot. +`get ...` reports a four-byte derived prefix and key size without exposing +the key. The table uses the same build-dependent slot count as +`flood.channel.scope`. + +Remote ACL permission `4` can manage this table. Deleting its final row +restores normal global region enforcement for group channels. + +**Example:** +```text +set flood.channel.scope.require #bot +get flood.channel.scope.require +get flood.channel.scope.require.1 +``` + +--- + +#### Filter flood packets by payload type, hop count, and path For setup guidance, interactions with the existing forwarding controls, and worked moderation examples, see [Repeater Flood Filtering and Moderation](flood_filtering.md). @@ -1322,8 +1414,14 @@ worked moderation examples, see [Repeater Flood Filtering and Moderation](flood_ **Usage:** - `get flood.filter` - `get flood.filter.` -- `set flood.filter [hops] [suspend=tempradio]` -- `set flood.filter. [hops] [suspend=tempradio]` +- `get flood.filter.blacklist` +- `get flood.filter.blacklist.` +- `set flood.filter.blacklist ` +- `set flood.filter.blacklist. ` +- `del flood.filter.blacklist` +- `del flood.filter.blacklist.` +- `set flood.filter [hops] [path=blacklist] [scope=] [require=region] [tx=slow] [suspend=tempradio]` +- `set flood.filter. [hops] [path=blacklist] [scope=] [require=region] [tx=slow] [suspend=tempradio]` - `del flood.filter.` - `del flood.filter all` @@ -1340,6 +1438,27 @@ worked moderation examples, see [Repeater Flood Filtering and Moderation](flood_ the saved range as `all`. - `suspend=tempradio`: Optional. Skip this row only while the temporary radio is actually active. +- `scope=`: Optional scope-setting action. The name is normalized with a + leading `#` and its 128-bit transport key is derived directly from that + hashtag. It does not need to exist in the region list. Public names up to 30 + characters are accepted; private `$` scopes are not. +- `require=region`: Optional and valid only with `scope=`. Apply the scope + rewrite only if the original incoming packet already passes this repeater's + region gate. An incoming transport scope must resolve to a locally allowed + region; an unscoped flood must be allowed by the wildcard region. The check + occurs before any scope rewrite during this receive pass. +- `tx=slow`: Optional and valid only with `scope=`. Use an effective inbound + `rxdelay` base of `max(2, configured rxdelay * 2)`, keep normal outbound + queue priority, and retransmit with the maximum supported `txdelay` factor + of `2.0`. Scope rows default to fast; `tx=fast` explicitly restores that + default when replacing a slow row. +- `path=blacklist`: Optional unordered path condition. The persistent blacklist + contains up to 255 unique 3-byte repeater IDs on ESP32 builds and 18 on other + builds, each written as six hexadecimal digits. A packet with 3-byte path + hashes matches after one exact ID hit. A packet with 2-byte path hashes + matches after two path entries match the first two bytes of listed IDs. + Packets with 1-byte path hashes never match this condition. Each received + path entry is counted at most once. The payload names follow the [MeshCore packet-format allocation](https://docs.meshcore.io/packet_format/): @@ -1367,12 +1486,39 @@ The payload names follow the [MeshCore packet-format allocation](https://docs.me `ROUTE_TYPE_FLOOD` (`0x01`, unscoped flood). Direct routes `0x02` and `0x03` are never affected. -**Behavior:** A match prevents retransmission by this repeater. The packet is -still received and can still be logged. Rules are persistent. While the -temporary radio is active, only rows explicitly marked `suspend=tempradio` are -skipped. `tempradio` is a radio state, not an OTA mode; normal payload types can -also use the temporary channel. Other rows remain in force. A malformed -persisted table fails open (no general rules are applied). +**Behavior:** A row with `path=blacklist` must meet the path condition as well +as its payload-type and hop-range conditions. Blacklist IDs can occur anywhere +in the received path and their configured order is irrelevant. A matching row +without `scope=` prevents retransmission by this +repeater. A matching row with `scope=` instead sets or replaces the packet's +transport scope and does not block it. The lowest-numbered matching scope row +wins; matching drop rows remain independent and can still block the rewritten +packet. Scope rewriting happens before region enforcement and is trusted even +when its name is absent from the local region list. It does not bypass +`repeat`, `flood.max`, other drop rows, channel blocking, loop detection, or +moderation. + +With `require=region`, a failed check makes that scope row ineligible. It leaves +the packet unchanged and does not set the filter-scope trust bypass, so an +unknown or denied incoming region is rejected normally unless another +independent scope rule rewrites it. Later eligible filter scope rows may still +match. + +By default, when a scope row will change the packet's transport codes, the +packet bypasses inbound `rxdelay`; its retransmission then uses zero `txdelay` +and the highest outbound queue priority. With `tx=slow`, the rewrite instead +uses an effective inbound `rxdelay` base of +`max(2, configured rxdelay * 2)`, normal queue priority, and the maximum +`txdelay` factor of `2.0`. The actual randomized transmit wait ranges from zero +to ten packet airtimes. Selecting the scope already carried is a no-op and +does not grant special treatment. An active radio transmission is not +preempted, and CAD and airtime-budget limits still apply. + +The packet is still received and can still be logged. Rules are persistent. +While the temporary radio is active, only rows explicitly marked +`suspend=tempradio` are skipped. `tempradio` is a radio state, not an OTA mode; +normal payload types can also use the temporary channel. Other rows remain in +force. A malformed persisted table fails open (no general rules are applied). **Default row:** Repeater firmware seeds a new flood-filter table with `ota all suspend=tempradio` in slot 1. This blocks repeated LoRa OTA (`0x0C`) @@ -1388,20 +1534,38 @@ set flood.filter.1 0x0C all suspend=tempradio Omitting `all` is equivalent. Omit `.1` as well to reuse an identical rule or the first empty slot instead of replacing slot 1. -**Remote-admin protection:** `flood.filter` cannot block `anon_req`, `path`, or +**Remote-admin protection:** Drop rows cannot block `anon_req`, `path`, or `response` at received hop counts `0-6`; those login-capable types become -filterable at hop `7`. Flood `txt_msg` cannot be blocked at hops `0-4` and -becomes filterable at hop `5`. `req`, `ack`, and multipart ACK have no special +blockable at hop `7`. Flood `txt_msg` cannot be blocked at hops `0-4` and +becomes blockable at hop `5`. Scope-setting rows are non-blocking and may apply +inside these protected ranges. `req`, `ack`, and multipart ACK have no special protection. Transit repeaters cannot decrypt these packets to distinguish an admin exchange from ordinary peer traffic, so each floor necessarily covers all flood packets of that outer type. These exceptions apply only to -`flood.filter`; `repeat`, `flood.max*`, region, loop-detection, and other +`flood.filter` drop actions; `repeat`, `flood.max*`, loop-detection, and other forwarding gates remain authoritative. -Without `.n`, `set` returns an identical existing rule, including its suspension -setting, or uses the first empty slot. With `.n`, it replaces that slot. +Without `.n`, `set` reuses an existing rule with the same match, scope, +requirement, and suspension settings, or uses the first empty slot. This lets +`tx=slow` or `tx=fast` change that rule's timing without creating a duplicate. +With `.n`, it replaces that slot. `get flood.filter` gives a compact list. Use `get flood.filter.` for full -details, including `suspend=tempradio`. +details, including `path=blacklist`, `scope=`, and `suspend=tempradio`. +The detail output also includes `require=region` and `tx=slow` when enabled. + +The blacklist and filter rows are persisted separately. Replacing or deleting +the blacklist does not delete rows containing `path=blacklist`; such rows +remain dormant while the list is empty. Path hashes are truncated routing +identifiers, not authenticated identities, so this is a forwarding signal +rather than proof that a particular repeater handled a packet. + +The unnumbered blacklist `set` replaces the whole list and accepts up to 18 IDs +so it fits every CLI transport. Numbered `set` writes up to 18 consecutive +entries beginning at an existing slot or exactly the next slot, allowing an +ESP32 list to grow to 255 entries in batches. Numbered deletion compacts +subsequent slots. The unnumbered `get` reports the total and as many leading +IDs as fit in one reply; use numbered `get` to inspect entries beyond that +reply. Standard traceroute is direct-routed and therefore outside `flood.filter` entirely. For a custom flood-form trace, catch-all `any` rows are deliberately @@ -1415,6 +1579,15 @@ set flood.filter.2 PAYLOAD_TYPE_ADVERT 6+ set flood.filter ota 2-4 set flood.filter.1 0x0C all suspend=tempradio set flood.filter grp_data all suspend=tempradio +set flood.filter grp_txt all scope=local +set flood.filter grp_data all scope=local require=region +set flood.filter grp_data all path=blacklist scope=local tx=slow +set flood.filter.blacklist A1B2C3,D4E5F6,112233 +set flood.filter.blacklist.4 445566 +set flood.filter.blacklist.19 778899,AABBCC,DDEEFF +set flood.filter any all path=blacklist +get flood.filter.blacklist +get flood.filter.blacklist.4 set flood.filter any 12+ get flood.filter get flood.filter.2 diff --git a/docs/flood_filtering.md b/docs/flood_filtering.md index 3f8c5422..c579ded4 100644 --- a/docs/flood_filtering.md +++ b/docs/flood_filtering.md @@ -1,8 +1,9 @@ # Repeater Flood Filtering and Moderation This guide explains the Keymind repeater forwarding filters. The filters decide -whether this repeater retransmits a packet. They do not stop local reception, -packet logging, or MQTT observation. +whether this repeater retransmits a packet and can assign a transport scope +before that decision. They do not stop local reception, packet logging, or MQTT +observation. Only flood routes are filtered: @@ -28,6 +29,7 @@ get flood.channel.data get flood.channel.data.hops get flood.channel.block get flood.channel.scope +get flood.channel.scope.require get flood.filter get flood.moderation ``` @@ -39,14 +41,14 @@ temporary radio is not synonymous with OTA and can carry normal packet types too. A corrupt or truncated table fails open, so corrupt storage does not silently enable blocking. -## Force unscoped floods into a transport scope +## Force floods into a transport scope -`flood.channel.scope` can convert a received unscoped flood into a -transport-scoped flood before this repeater forwards it: +`flood.channel.scope` can add a scope to a received unscoped flood or replace +the scope of a transport-scoped flood before this repeater forwards it: ```text -set flood.channel.scope -set flood.channel.scope. +set flood.channel.scope [tx=slow] +set flood.channel.scope. [tx=slow] get flood.channel.scope get flood.channel.scope. del flood.channel.scope. @@ -74,29 +76,41 @@ 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. -On a successful match, the route changes from `ROUTE_TYPE_FLOOD` to -`ROUTE_TYPE_TRANSPORT_FLOOD`. Transport code 0 is calculated with the target -region key over the payload type and payload; code 1 remains zero. The change -happens before region enforcement, forwarding filters, and deduplication. -Consequently `flood.max.unscoped` no longer applies to the rewritten packet, -but `flood.max`, target-region permissions, `flood.filter`, channel blocking, -loop detection, and moderation still do. +On a successful match, an unscoped route changes from `ROUTE_TYPE_FLOOD` to +`ROUTE_TYPE_TRANSPORT_FLOOD`; an already-scoped route remains transport-flood +but receives replacement codes. Transport code 0 is calculated with the target +region key over the payload type and payload, and code 1 becomes zero. The +change happens before region enforcement, forwarding filters, and +deduplication. Consequently `flood.max.unscoped` no longer applies to a packet +converted from unscoped, while `flood.max`, target-region permissions, +`flood.filter`, channel blocking, loop detection, and moderation still apply +to every rewritten packet. By default, if the selected scope differs and the +rewritten packet passes those checks, its initial retransmission uses zero +`txdelay` and the highest outbound queue priority so the newly scoped copy can +win at the next hop. Add `tx=slow` to use an effective inbound `rxdelay` base +of `max(2, configured rxdelay * 2)`, retain normal outbound queue priority, and +force the maximum `txdelay` factor of `2.0`. The actual transmit delay is still +randomized, from zero through ten packet airtimes. +It does not preempt an active radio transmission or bypass CAD and +airtime-budget limits. Selecting the scope already present is a no-op and does +not grant special treatment. -Already-scoped floods and direct routes are never rewritten. TRACE is never -rewritten even in flood form; its existing code, if any, is preserved and it -bypasses region/unknown-code enforcement. Scope assignment also does not -override normal payload validation or make an otherwise non-forwardable packet -type forwardable. +Direct routes are never rewritten. TRACE is never rewritten even in flood +form; its existing code, if any, is preserved and it bypasses +region/unknown-code enforcement. Scope assignment also does not override +normal payload validation or make an otherwise non-forwardable packet type +forwardable. LoRa OTA (`0x0C`) falls under `other:*`. A matching row adds the selected -transport code, but OTA still operates normally during the temporary-radio -window because the OTA handler accepts both unscoped and transport-scoped flood -routes. The target region must allow flooding. A new repeater also seeds -`ota all suspend=tempradio` in flood-filter slot 1. That visible rule blocks OTA -forwarding at every received hop outside temporary-radio operation and is -skipped while temporary radio is active. Independently, the OTA core refuses -OTA receive, relay, and transmit outside an actually active temporary-radio -window, even if the seeded row is deleted or replaced. +transport code or replaces the existing one, but OTA still operates normally +during the temporary-radio window because the OTA handler accepts both +unscoped and transport-scoped flood routes. The target region must allow +flooding. A new repeater also seeds `ota all suspend=tempradio` in flood-filter +slot 1. That visible rule blocks OTA forwarding at every received hop outside +temporary-radio operation and is skipped while temporary radio is active. +Independently, the OTA core refuses OTA receive, relay, and transmit outside an +actually active temporary-radio window, even if the seeded row is deleted or +replaced. Capacity is selected at build time: @@ -112,6 +126,52 @@ The region map still has 32 named-region entries. Large ESP32 tables can map many channels to the same targets, but cannot reference more than 32 distinct configured region names. +## Require valid incoming scopes only on selected channels + +`flood.channel.scope.require` changes region enforcement for received flood +`GRP_TXT` and `GRP_DATA` packets from a global policy to a channel opt-in +policy: + +```text +set flood.channel.scope.require +set flood.channel.scope.require. +get flood.channel.scope.require +get flood.channel.scope.require. +del flood.channel.scope.require. +del flood.channel.scope.require all +``` + +An empty table preserves the normal global region behavior. Once at least one +row exists, a group-channel packet that authenticates against a listed key must +arrive as `ROUTE_TYPE_TRANSPORT_FLOOD` with a transport code matching a locally +flood-allowed region. An unscoped packet, an unknown transport code, or a code +for a denied region is not retransmitted. The check uses the original incoming +scope before `flood.channel.scope` or `flood.filter scope=` can rewrite it. +Those rewrite actions are skipped for a rejected listed channel, so they +cannot rescue it or grant special receive/transmit timing. + +Group-channel packets that do not authenticate against any listed key bypass +the region/unknown-code forwarding gate. They still pass through `repeat`, +`flood.max*`, `flood.filter`, `flood.channel.block`, loop detection, payload +validation, and moderation. Non-channel flood payload types retain the normal +global region behavior. A one-byte channel-hash collision is only a prefilter; +the packet must also pass MAC validation/decryption with the configured key. + +Without `.slot`, setting an existing key updates its row and a new key uses the +first empty row. Numbered `set` replaces that slot. Detail output displays only +the first four derived hash bytes and key size, never the secret. The table has +the same build-dependent slot count as `flood.channel.scope`; each row consumes +34 bytes of RAM and storage, plus a five-byte file header. ACL permission `4` +can manage it. + +For example, this requires an allowed incoming scope on `#bot`, while every +other group channel bypasses region enforcement: + +```text +set flood.channel.scope.require #bot +get flood.channel.scope.require +``` + ### Interaction with duplicate detection The seen-packet hash contains the payload type and exact payload bytes. It does @@ -121,9 +181,10 @@ unscoped packet and the same packet after this repeater adds a transport code are the same duplicate. A later copy with a different scope is also the same duplicate; changing or adding scope cannot evade the seen table. -When equivalent non-TRACE flood copies overlap in `rxdelay`, the normal receive-quality timing -still chooses the packet to process, but that winner takes a scope from the -queued copies whose transport code matches an allowed region in this repeater. +When equivalent non-TRACE flood copies overlap in `rxdelay`, the normal +receive-quality timing still chooses the packet to process, but that winner +takes a scope from the queued copies whose transport code matches an allowed +region in this repeater. Unknown and denied scopes are ignored. If eligible copies have different scopes, the shortest received path supplies the scope. Equal path lengths prefer the deepest matching child region (the narrowest configured scope). A @@ -136,23 +197,94 @@ comparison. It applies only while copies are queued and cannot alter a copy already processed into the seen table. TRACE is excluded from scope arbitration entirely, so rxdelay never adds or replaces a trace transport code. -## Filter by payload type and received hop count +A packet that already matches a configured fast `flood.channel.scope` or +`flood.filter scope=` action and needs its scope changed bypasses this inbound +`rxdelay` queue entirely. A `tx=slow` row remains in the queue with twice the +configured base, floored at `2.0`, and participates in normal queued-copy +scope arbitration. -Use `flood.filter` when the packet type and its current path length are enough -to make the decision: +## Filter by payload type, received hop count, and path + +Use `flood.filter` when the packet type, current path length, or listed path +identifiers are enough to make the decision: ```text -set flood.filter [hops] [suspend=tempradio] -set flood.filter. [hops] [suspend=tempradio] +set flood.filter.blacklist +set flood.filter.blacklist. +get flood.filter.blacklist +get flood.filter.blacklist. +del flood.filter.blacklist +del flood.filter.blacklist. +set flood.filter [hops] [path=blacklist] [scope=] [require=region] [tx=slow] [suspend=tempradio] +set flood.filter. [hops] [path=blacklist] [scope=] [require=region] [tx=slow] [suspend=tempradio] get flood.filter get flood.filter. del flood.filter. del flood.filter all ``` -Without a slot number, `set` reuses an identical rule, including its suspension -setting, or selects the first empty slot. With a slot number, it replaces that -slot. Omitting the hop expression means `all` (`0-63`). +The blacklist holds up to 255 unique 3-byte repeater IDs on ESP32 builds and +18 on other builds. Each is written as six hexadecimal digits. For example: + +```text +set flood.filter.blacklist A1B2C3,D4E5F6,112233 +set flood.filter.blacklist.4 445566 +set flood.filter any all path=blacklist +``` + +An unnumbered `set` replaces the list with up to 18 IDs, the largest command +that fits every CLI transport. A numbered `set` writes a batch of up to 18 IDs +beginning at an existing slot or the next consecutive slot. This is how an +ESP32 list grows beyond 18. Deleting a numbered entry compacts the entries +after it. Unnumbered `get` reports the total and prints the leading IDs that +fit; numbered `get` retrieves one specific entry. + +`path=blacklist` is an unordered precondition on that row. With 3-byte path +hashes, one or more exact blacklist hits qualifies the packet. With 2-byte +path hashes, two or more received path entries must match the first two bytes +of listed IDs. Each received entry is counted at most once. A 1-byte path +never qualifies. The IDs may occur anywhere in the received path; neither +their list order nor their path order matters. + +Without a slot number, `set` reuses a rule with the same match, scope, +requirement, and suspension settings, or selects the first empty slot. This +lets `tx=slow` or `tx=fast` change that rule's timing without creating a +duplicate. With a slot number, it replaces that slot. Omitting the hop +expression means `all` (`0-63`). + +A row without `scope=` is the existing drop action. A row with `scope=` is a +scope-setting action instead: it adds transport scope to an unscoped packet or +replaces the codes on an already-scoped packet. The scope name is normalized +with a leading `#`, and the 128-bit transport key is derived directly from that +hashtag. The name does not need to exist in the region list and is not added to +it. Public names up to 30 characters are accepted; private `$` scopes are not. + +Add `require=region` to a scope row when rewriting must not rescue a packet +that the incoming-region gate would reject. The repeater evaluates the packet's +original route before any rewrite in that receive pass. An incoming transport +scope must match a locally allowed region; an unscoped flood must be allowed by +the wildcard region. If the check fails, that scope row is skipped, the filter +does not grant its region bypass, and the unchanged packet is allowed to fail +normal region enforcement. Other independently configured scope rows still +apply in their normal order. + +When multiple scope rows match, the lowest-numbered row wins. Scope rows do not +approve a packet: any matching drop row and every remaining forwarding gate can +still reject it. A filter-assigned scope is trusted without local region-list +validation, but `repeat`, `flood.max`, channel blocking, loop detection, and +moderation still apply. By default, a changed scope bypasses inbound `rxdelay`, +then is retransmitted with zero `txdelay` and the highest outbound queue +priority. Add `tx=slow` to use an effective inbound `rxdelay` base of +`max(2, configured rxdelay * 2)`, retain normal queue priority, and force the +maximum `txdelay` factor of `2.0`; the randomized transmit delay ranges from +zero through ten packet airtimes. `tx=fast` explicitly restores the default. +Selecting the scope already present does not grant special treatment. Active +radio transmission, CAD, and airtime-budget limits are unchanged. + +The blacklist and rule table are persisted separately. Deleting the blacklist +leaves `path=blacklist` rows in place but dormant until IDs are configured +again. Path hashes are truncated routing identifiers and are not authenticated +proof that a particular repeater handled a packet. On first initialization, flood-filter slot 1 is seeded with: @@ -169,10 +301,10 @@ on a temporary channel. Suspension does not approve a packet or bypass the rest of the filter table. It skips that row, then evaluation continues with the next row and the remaining -forwarding gates. An ordinary `any` row therefore still applies during the -temporary-radio window, subject to the short-path remote-admin protection -below. `repeat`, `flood.max*`, region, loop-detection, and the OTA subsystem's -own hop limit also remain in force. +forwarding gates. An ordinary drop `any` row therefore still applies during +the temporary-radio window, subject to the short-path remote-admin protection +below. `repeat`, `flood.max*`, region handling, loop detection, and the OTA +subsystem's own hop limit also remain in force. Standard traceroute uses direct routing and never enters `flood.filter`. For a custom flood-form trace, catch-all `any` rows are deliberately ignored; only an @@ -181,7 +313,7 @@ flood-forward TRACE packets. ### Remote administration cannot be type-filtered on short paths -`flood.filter` uses two minimum filterable hop counts: +`flood.filter` drop actions use two minimum filterable hop counts: - `anon_req`, `path`, and `response` cannot be blocked at received hops `0-6`; configured rules begin applying at hop `7`. @@ -189,7 +321,8 @@ flood-forward TRACE packets. begin applying at hop `5`. `req`, `ack`, and multipart ACK have no special floor and remain filterable from -hop `0`. +hop `0`. Scope-setting rows do not block traffic and may apply within the +protected ranges. A flooded login starts as `ANON_REQ`; its reply is commonly a `PATH` packet carrying an encrypted `RESPONSE`. Before a direct return path is established, @@ -199,9 +332,8 @@ from ordinary peer packets with the same outer type. Each hop floor therefore covers the complete outer packet class, not only packets that ultimately authenticate as administrators. -This protects only against the configurable `flood.filter` table. It does not -override `repeat`, `flood.max*`, region enforcement, loop detection, or other -forwarding gates. +This protects only against configurable `flood.filter` drop actions. It does +not override `repeat`, `flood.max*`, loop detection, or other forwarding gates. Hop expressions are based on the path count when this repeater receives the packet: @@ -225,6 +357,19 @@ set flood.filter.2 advert 6+ # Keep LoRa OTA floods from crossing this repeater at path counts 2 through 4. set flood.filter.3 ota 2-4 +# Assign #local scope to group text without requiring #local in the region map. +set flood.filter grp_txt all scope=local + +# Rewrite only packets whose incoming region was already acceptable. +set flood.filter grp_data all scope=local require=region + +# Rewrite matching blacklisted paths without fast-tracking their retransmission. +set flood.filter grp_data all path=blacklist scope=local tx=slow + +# Drop matching flood types after the unordered path blacklist qualifies. +set flood.filter.blacklist A1B2C3,D4E5F6,112233 +set flood.filter any all path=blacklist + # Apply a hard ceiling to flood payload types at 12 or more received hops. set flood.filter any 12+ ``` @@ -362,13 +507,21 @@ the packet contains all path entries listed by the rule. A flood packet is retransmitted only if it passes every applicable control. In other words, the controls combine as deny rules: -1. `flood.channel.scope` first rewrites a matching unscoped flood packet. -2. `repeat`, `flood.max*`, and the channel-data gate are checked. -3. `flood.filter` checks payload type and hop range, subject to the login floor - of `7` and flood-text floor of `5` described above. -4. `flood.channel.block` checks keyed channels. -5. Region and loop-detection rules are checked. -6. `flood.moderation` checks decrypted group text, username, rate, hops, and path. +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. +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. +5. `flood.filter` drop rows check payload type and hop range, subject to the + login floor of `7` and flood-text floor of `5` described above. +6. `flood.channel.block` checks keyed channels. +7. Region and loop-detection rules are checked; a filter-assigned scope is + already trusted when it has no region-list match, except that it cannot + rescue a channel rejected by `flood.channel.scope.require`. +8. `flood.moderation` checks decrypted group text, username, rate, hops, and + path. The first denial is enough to prevent retransmission. A packet that is denied can still appear in local logs or MQTT output. Moderation runs last because its @@ -388,11 +541,14 @@ A filter manager can read non-secret operational status and manage `repeat`, `flood.filter*`, and `flood.moderation*`. Delegated `get` access uses an explicit allowlist: it cannot retrieve guest, WiFi, MQTT, bridge, or other credentials, and it cannot change regions, ACL entries, radio settings, or -unrelated administrator settings. +unrelated administrator settings. Because `flood.filter scope=` derives a +public hashtag key directly, a filter manager can configure that action without +region-manager permission; it still cannot edit the region hierarchy. ACL permission `4`, the region/scope-manager role, can read, add, replace, and -delete `flood.channel.scope` rows and manage regions. This lets the same -delegate create target regions and assign them to forced-scope rows. +delete `flood.channel.scope` and `flood.channel.scope.require` rows and manage +regions. This lets the same delegate create target regions, assign forced +scopes, and select the channels that require valid incoming scopes. ## Security limitations diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index 1f834323..5305a466 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -83,8 +83,9 @@ 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` | Assigns a transport-region scope to received unscoped floods. 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] `, `del flood.channel.scope.|all` | `set flood.channel.scope login:* west` | -| `flood.filter` | Persistent repeater-only forwarding rules for flood routes `0x00`/`0x01`, selected by payload type and optional received hop count/range (omitted means `all`). 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` types become filterable at hop `7`; flood `txt_msg` becomes filterable at hop `5`. Standard direct traceroute, other direct routing, and local receive/logging are unchanged. | `get flood.filter[.n]`, `set flood.filter[.n] [N|N+|N-M|all] [suspend=tempradio]`, `del flood.filter.|all` | `set flood.filter.1 0x0C all suspend=tempradio` | +| `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.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` | | `clock.sync.mesh` | Defaults on for nRF52 repeaters and off for other builds; a saved setting overrides that default. It estimates UTC as soon as the configured number of fresh signed-advert or valid Public-channel sources is collected, with a 30-minute bootstrap/retry timer when evidence is still insufficient, then repeats lazily seven days after each successful estimate. New evidence retriggers evaluation after a no-consensus result. `clock.sync.mesh now` queues an immediate LoRa-only attempt without bypassing quorum or source suppression. Only timestamps from firmware build time through build time plus ten years are recorded. Successful CLI, GPS, or WiFi/NTP clock updates suppress LoRa time collection until reboot; after reboot LoRa is the fallback if NTP cannot sync. Status reports the reason a clock was not set; its `.table` and `.1` through `.16` forms inspect collected samples. | `get clock.sync.mesh`, `set clock.sync.mesh `, `clock.sync.mesh now`, `get clock.sync.status[.table|.1-.16]` | `set clock.sync.mesh on` | | `clock.sync.mesh.edge` | Defaults on so edge repeaters can collect clock evidence when all packets arrive through one relay path. Verified evidence is observed before the forwarding decision, so `repeat off` and forwarding filters do not prevent collection. Signed adverts are deduplicated by public key and Public-channel timestamps by case-insensitive display name; all may share one receive path. Public display names are unauthenticated and can be spoofed. Changing this setting clears current clock samples. | `get clock.sync.mesh.edge`, `set clock.sync.mesh.edge ` | `set clock.sync.mesh.edge on` | diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 67a0d69d..11024c92 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -3,6 +3,7 @@ #include // for qsort() #include #include +#include #include #ifdef WITH_WEBCONFIG #include @@ -97,8 +98,11 @@ #define FLOOD_CHANNEL_BLOCK_FILE "/flood_ch_block" #define FLOOD_PACKET_FILTER_FILE "/flood_filter" +#define FLOOD_PACKET_FILTER_BLACKLIST_FILE "/flood_filter_bl" #define FLOOD_CHANNEL_SCOPE_FILE "/flood_ch_scope" #define FLOOD_CHANNEL_SCOPE_TEMP_FILE "/flood_ch_scope.tmp" +#define FLOOD_CHANNEL_SCOPE_REQUIRE_FILE "/flood_ch_req" +#define FLOOD_CHANNEL_SCOPE_REQUIRE_TEMP_FILE "/flood_ch_req.tmp" #define FLOOD_GROUP_MODERATION_FILE "/flood_grp_mod" #define CLOCK_SYNC_PREFS_FILE "/clock_sync" #define FLOOD_PACKET_FILTER_LOGIN_PROTECTED_HOPS 7 @@ -883,8 +887,14 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { if (shouldBlockFloodChannelForward(packet)) return false; #endif } + if (packet->isRouteFlood() && recv_pkt_channel_scope_rejected) { + MESH_DEBUG_PRINTLN( + "allowPacketForward: flood.channel.scope.require rejected incoming scope"); + return false; + } if (packet->isRouteFlood() && packet->getPayloadType() != PAYLOAD_TYPE_TRACE - && recv_pkt_region == NULL) { + && recv_pkt_region == NULL && !recv_pkt_filter_scope_set + && !recv_pkt_channel_scope_bypass) { MESH_DEBUG_PRINTLN("allowPacketForward: unknown transport code, or wildcard not allowed for FLOOD packet"); return false; } @@ -927,12 +937,11 @@ const char *MyMesh::getLogDateTime() { void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { #if MESH_PACKET_LOGGING - if (Serial.availableForWrite() > 0) { - Serial.print(getLogDateTime()); - Serial.print(" RAW: "); - mesh::Utils::printHex(Serial, raw, len); - Serial.println(); - } + // Logging builds prefer backpressure over silently losing a packet record. + Serial.print(getLogDateTime()); + Serial.print(" RAW: "); + mesh::Utils::printHex(Serial, raw, len); + Serial.println(); #endif #ifdef WITH_MQTT_BRIDGE @@ -1019,10 +1028,82 @@ int MyMesh::calcRxDelay(float score, uint32_t air_time) const { return (int)((powf(_prefs.rx_delay_base, 0.85f - score) - 1.0f) * air_time); } +bool MyMesh::evaluateScopeRewriteTiming(const mesh::Packet* packet, + bool& fast_track) { + fast_track = false; +#if defined(PORTABLE_MQTT_OBSERVER) + (void)packet; + return false; +#else + if (packet == NULL || !packet->isRouteFlood()) return false; + + bool incoming_region_allowed = packet->getPayloadType() == PAYLOAD_TYPE_TRACE; + if (packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { + incoming_region_allowed = + region_map.findMatch(packet, REGION_DENY_FLOOD) != NULL; + } else if (packet->getRouteType() == ROUTE_TYPE_FLOOD) { + incoming_region_allowed = + (region_map.getWildcard().flags & REGION_DENY_FLOOD) == 0; + } + + bool requirement_table_active = false; + bool channel_requires_scope = findFloodChannelScopeRequirementMatch( + packet, requirement_table_active); + bool is_group_channel_packet = + packet->getPayloadType() == PAYLOAD_TYPE_GRP_TXT + || packet->getPayloadType() == PAYLOAD_TYPE_GRP_DATA; + FloodFilterPolicy::ChannelScopeGate channel_scope_gate = + FloodFilterPolicy::channelScopeGate( + requirement_table_active, is_group_channel_packet, + channel_requires_scope, + packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD, + incoming_region_allowed); + if (channel_scope_gate + == FloodFilterPolicy::CHANNEL_SCOPE_REQUIRED_REJECTED) { + return false; + } + + mesh::Packet candidate = *packet; + bool scope_changed = applyFloodChannelScope(&candidate, fast_track, false); + bool filter_scope_set = false; + bool filter_fast_track = false; + if (applyFloodPacketFilterScope(&candidate, incoming_region_allowed, + filter_scope_set, filter_fast_track, false)) { + scope_changed = true; + fast_track = filter_fast_track; + } + return scope_changed; +#endif +} + +bool MyMesh::shouldBypassRxDelay(const mesh::Packet* packet) { + bool fast_track = false; + return evaluateScopeRewriteTiming(packet, fast_track) && fast_track; +} + +int MyMesh::calcRxDelayForPacket(const mesh::Packet* packet, float score, + uint32_t air_time) { + bool fast_track = false; + if (!evaluateScopeRewriteTiming(packet, fast_track)) { + return calcRxDelay(score, air_time); + } + if (fast_track) return 0; + + float slow_base = + FloodFilterPolicy::slowScopeRxDelayBase(_prefs.rx_delay_base); + return (int)((pow(slow_base, 0.85f - score) - 1.0f) * air_time); +} + uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) { uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.tx_delay_factor); return getRNG()->nextInt(0, 5*t + 1); } +uint32_t MyMesh::getSlowScopeRetransmitDelay(const mesh::Packet* packet) { + uint32_t airtime = + _radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2); + uint32_t max_delay = FloodFilterPolicy::slowScopeMaxDelay(airtime); + return getRNG()->nextInt(0, max_delay + 1); +} uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); return getRNG()->nextInt(0, 5*t + 1); @@ -2162,9 +2243,52 @@ void MyMesh::expireRecentRepeatersIfDue() { } mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { + bool scope_changed = false; + bool fast_track_scope_change = false; + recv_pkt_filter_scope_set = false; + recv_pkt_channel_scope_bypass = false; + recv_pkt_channel_scope_rejected = false; #if !defined(PORTABLE_MQTT_OBSERVER) - if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { - applyFloodChannelScope(pkt); + if (pkt->isRouteFlood()) { + bool incoming_region_allowed = pkt->getPayloadType() == PAYLOAD_TYPE_TRACE; + if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { + incoming_region_allowed = + region_map.findMatch(pkt, REGION_DENY_FLOOD) != NULL; + } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { + incoming_region_allowed = + (region_map.getWildcard().flags & REGION_DENY_FLOOD) == 0; + } + + bool requirement_table_active = false; + bool channel_requires_scope = findFloodChannelScopeRequirementMatch( + pkt, requirement_table_active); + bool is_group_channel_packet = + pkt->getPayloadType() == PAYLOAD_TYPE_GRP_TXT + || pkt->getPayloadType() == PAYLOAD_TYPE_GRP_DATA; + FloodFilterPolicy::ChannelScopeGate channel_scope_gate = + FloodFilterPolicy::channelScopeGate( + requirement_table_active, is_group_channel_packet, + channel_requires_scope, + pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD, + incoming_region_allowed); + recv_pkt_channel_scope_bypass = + channel_scope_gate == FloodFilterPolicy::CHANNEL_SCOPE_BYPASS; + recv_pkt_channel_scope_rejected = + channel_scope_gate + == FloodFilterPolicy::CHANNEL_SCOPE_REQUIRED_REJECTED; + + if (!recv_pkt_channel_scope_rejected) { + scope_changed = + applyFloodChannelScope(pkt, fast_track_scope_change); + bool filter_scope_set = false; + bool filter_fast_track = false; + if (applyFloodPacketFilterScope(pkt, incoming_region_allowed, + filter_scope_set, filter_fast_track)) { + scope_changed = true; + fast_track_scope_change = filter_fast_track; + } + recv_pkt_filter_scope_set = filter_scope_set; + } } #endif if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { @@ -2178,7 +2302,19 @@ mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { } else { recv_pkt_region = NULL; } - return Mesh::onRecvPacket(pkt); + mesh::DispatcherAction action = Mesh::onRecvPacket(pkt); + if (scope_changed && action != ACTION_RELEASE && action != ACTION_MANUAL_HOLD) { + if (fast_track_scope_change) { + // This repeater changed the scope, so forward it at the highest queue + // priority with no txdelay and let the selected scope win downstream. + action = ACTION_RETRANSMIT(0); + } else { + uint8_t priority = (action >> 24) - 1; + action = ACTION_RETRANSMIT_DELAYED( + priority, getSlowScopeRetransmitDelay(pkt)); + } + } + return action; } void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const mesh::Identity &sender, @@ -2620,9 +2756,16 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc memset(flood_retry_bridge_states, 0, sizeof(flood_retry_bridge_states)); memset(flood_retry_bridge_reachability, 0, sizeof(flood_retry_bridge_reachability)); recv_pkt_region = NULL; + recv_pkt_filter_scope_set = false; + recv_pkt_channel_scope_bypass = false; + recv_pkt_channel_scope_rejected = false; memset(flood_channel_blocks, 0, sizeof(flood_channel_blocks)); memset(flood_packet_filters, 0, sizeof(flood_packet_filters)); + flood_packet_filter_blacklist_count = 0; + memset(flood_packet_filter_blacklist, 0, sizeof(flood_packet_filter_blacklist)); memset(flood_channel_scopes, 0, sizeof(flood_channel_scopes)); + memset(flood_channel_scope_requirements, 0, + sizeof(flood_channel_scope_requirements)); memset(flood_group_moderation, 0, sizeof(flood_group_moderation)); memset(clock_sync_samples, 0, sizeof(clock_sync_samples)); clock_sync_mesh_enabled = CLOCK_SYNC_MESH_DEFAULT_ENABLED != 0; @@ -2782,8 +2925,10 @@ void MyMesh::begin(FILESYSTEM *fs) { region_map.load(_fs); #if !defined(PORTABLE_MQTT_OBSERVER) loadFloodChannelBlocks(); + loadFloodPacketFilterBlacklist(); loadFloodPacketFilters(); loadFloodChannelScopes(); + loadFloodChannelScopeRequirements(); loadFloodGroupModeration(); loadClockSyncPrefs(); #endif @@ -4228,6 +4373,37 @@ static void formatFloodFilterHopSpec(char* dest, size_t dest_len, uint8_t min_ho } } +static bool normalizeFloodFilterScopeName(const char* text, char* dest, size_t dest_len) { + if (text == NULL || dest == NULL || dest_len < 3) return false; + if (*text == '#') text++; + if (*text == 0 || *text == '$') return false; + + size_t name_len = 0; + while (text[name_len]) { + if (!RegionMap::is_name_char((uint8_t)text[name_len]) + || text[name_len] == '#' || text[name_len] == '$') { + return false; + } + name_len++; + } + if (name_len + 2 > dest_len) return false; + + dest[0] = '#'; + memcpy(&dest[1], text, name_len + 1); + return true; +} + +static bool isValidStoredFloodFilterScopeName(const char* text) { + char normalized[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN]; + return normalizeFloodFilterScopeName(text, normalized, sizeof(normalized)) + && strcmp(text, normalized) == 0; +} + +static void deriveFloodFilterScopeKey(const char* scope_name, TransportKey& scope) { + mesh::Utils::sha256(scope.key, sizeof(scope.key), + (const uint8_t*)scope_name, strlen(scope_name)); +} + static uint8_t floodPacketFilterProtectedHops(const mesh::Packet* packet) { if (packet == NULL) return 0; switch (packet->getPayloadType()) { @@ -4242,6 +4418,288 @@ static uint8_t floodPacketFilterProtectedHops(const mesh::Packet* packet) { } } +static bool parseFloodPacketFilterBlacklist( + const char* text, + uint8_t ids[FLOOD_PACKET_FILTER_BLACKLIST_REPLACE_MAX] + [FLOOD_PACKET_FILTER_PATH_ID_SIZE], + uint8_t& count) { + count = 0; + memset(ids, 0, FLOOD_PACKET_FILTER_BLACKLIST_REPLACE_MAX + * FLOOD_PACKET_FILTER_PATH_ID_SIZE); + if (text == NULL || *text == 0 + || strlen(text) >= FLOOD_PACKET_FILTER_BLACKLIST_REPLACE_MAX * 7U) { + return false; + } + + char input[FLOOD_PACKET_FILTER_BLACKLIST_REPLACE_MAX * 7]; + strcpy(input, text); + char* token = input; + while (token != NULL) { + char* comma = strchr(token, ','); + if (comma != NULL) *comma = 0; + if (strlen(token) != FLOOD_PACKET_FILTER_PATH_ID_SIZE * 2U + || count >= FLOOD_PACKET_FILTER_BLACKLIST_REPLACE_MAX) { + return false; + } + for (const char* p = token; *p; p++) { + if (!mesh::Utils::isHexChar(*p)) return false; + } + if (!mesh::Utils::fromHex(ids[count], FLOOD_PACKET_FILTER_PATH_ID_SIZE, token)) { + return false; + } + for (uint8_t i = 0; i < count; i++) { + if (memcmp(ids[i], ids[count], FLOOD_PACKET_FILTER_PATH_ID_SIZE) == 0) { + return false; + } + } + count++; + token = comma != NULL ? comma + 1 : NULL; + if (token != NULL && *token == 0) return false; + } + return count > 0; +} + +void MyMesh::loadFloodPacketFilterBlacklist() { + flood_packet_filter_blacklist_count = 0; + memset(flood_packet_filter_blacklist, 0, sizeof(flood_packet_filter_blacklist)); + if (_fs == NULL || !_fs->exists(FLOOD_PACKET_FILTER_BLACKLIST_FILE)) return; + + File file = openFloodChannelBlockRead(_fs, FLOOD_PACKET_FILTER_BLACKLIST_FILE); + if (!file) return; + + uint8_t magic[4]; + uint8_t count = 0; + uint8_t loaded[FLOOD_PACKET_FILTER_BLACKLIST_MAX][FLOOD_PACKET_FILTER_PATH_ID_SIZE]; + memset(loaded, 0, sizeof(loaded)); + bool success = file.read(magic, sizeof(magic)) == sizeof(magic) + && memcmp(magic, "FBL1", sizeof(magic)) == 0 + && file.read(&count, sizeof(count)) == sizeof(count) + && count <= FLOOD_PACKET_FILTER_BLACKLIST_MAX + && file.read((uint8_t*)loaded, count * FLOOD_PACKET_FILTER_PATH_ID_SIZE) + == count * FLOOD_PACKET_FILTER_PATH_ID_SIZE; + for (uint8_t i = 0; success && i < count; i++) { + for (uint8_t j = 0; j < i; j++) { + if (memcmp(loaded[i], loaded[j], FLOOD_PACKET_FILTER_PATH_ID_SIZE) == 0) { + success = false; + break; + } + } + } + file.close(); + + if (success) { + flood_packet_filter_blacklist_count = count; + memcpy(flood_packet_filter_blacklist, loaded, sizeof(loaded)); + } +} + +bool MyMesh::saveFloodPacketFilterBlacklist() { + if (_fs == NULL) return false; + File file = openFloodChannelBlockWrite(_fs, FLOOD_PACKET_FILTER_BLACKLIST_FILE); + if (!file) return false; + + const uint8_t magic[4] = {'F', 'B', 'L', '1'}; + bool success = file.write(magic, sizeof(magic)) == sizeof(magic) + && file.write(&flood_packet_filter_blacklist_count, + sizeof(flood_packet_filter_blacklist_count)) + == sizeof(flood_packet_filter_blacklist_count) + && file.write((const uint8_t*)flood_packet_filter_blacklist, + flood_packet_filter_blacklist_count * FLOOD_PACKET_FILTER_PATH_ID_SIZE) + == flood_packet_filter_blacklist_count * FLOOD_PACKET_FILTER_PATH_ID_SIZE; + file.close(); + return success; +} + +void MyMesh::formatFloodPacketFilterBlacklist(const char* args, char* reply) const { + const char* selector = skipFloodFilterSpaces(args); + if (*selector == '.') selector = skipFloodFilterSpaces(selector + 1); + if (*selector != 0) { + uint8_t slot = 0; + if (!parseFloodFilterUnsigned(selector, FLOOD_PACKET_FILTER_BLACKLIST_MAX, slot) + || slot == 0 || slot > flood_packet_filter_blacklist_count) { + snprintf(reply, 160, "Err - blacklist slot must be 1-%u", + (uint32_t)flood_packet_filter_blacklist_count); + return; + } + char id[FLOOD_PACKET_FILTER_PATH_ID_SIZE * 2 + 1]; + mesh::Utils::toHex(id, flood_packet_filter_blacklist[slot - 1], + FLOOD_PACKET_FILTER_PATH_ID_SIZE); + snprintf(reply, 160, "> %u=%s", (uint32_t)slot, id); + return; + } + if (flood_packet_filter_blacklist_count == 0) { + strcpy(reply, "> off"); + return; + } + + size_t used = (size_t)snprintf(reply, 160, "> count=%u ", + (uint32_t)flood_packet_filter_blacklist_count); + bool truncated = false; + for (uint8_t i = 0; i < flood_packet_filter_blacklist_count; i++) { + char id[FLOOD_PACKET_FILTER_PATH_ID_SIZE * 2 + 1]; + mesh::Utils::toHex(id, flood_packet_filter_blacklist[i], + FLOOD_PACKET_FILTER_PATH_ID_SIZE); + size_t needed = strlen(id) + (i == 0 ? 0 : 1); + if (used + needed >= 155) { + truncated = true; + break; + } + int written = snprintf(&reply[used], 160 - used, "%s%s", i == 0 ? "" : ",", id); + if (written < 0) break; + used += (size_t)written; + } + if (truncated) { + StrHelper::strncpy(&reply[used], " ...", 160 - used); + } +} + +void MyMesh::setFloodPacketFilterBlacklist(const char* args, char* reply) { + const char* cursor = skipFloodFilterSpaces(args); + int requested_slot = -1; + if (*cursor == '.') { + cursor++; + const char* slot_start = cursor; + while (*cursor >= '0' && *cursor <= '9') cursor++; + size_t slot_len = (size_t)(cursor - slot_start); + char slot_text[8]; + if (slot_len == 0 || slot_len >= sizeof(slot_text)) { + snprintf(reply, 160, "Err - blacklist slot must be 1-%u", + (uint32_t)FLOOD_PACKET_FILTER_BLACKLIST_MAX); + return; + } + memcpy(slot_text, slot_start, slot_len); + slot_text[slot_len] = 0; + uint8_t slot = 0; + if (!parseFloodFilterUnsigned(slot_text, FLOOD_PACKET_FILTER_BLACKLIST_MAX, slot) + || slot == 0) { + snprintf(reply, 160, "Err - blacklist slot must be 1-%u", + (uint32_t)FLOOD_PACKET_FILTER_BLACKLIST_MAX); + return; + } + requested_slot = slot - 1; + if (*cursor != ' ') { + strcpy(reply, "Err - expected six-digit repeater ID"); + return; + } + } + cursor = skipFloodFilterSpaces(cursor); + + uint8_t parsed[FLOOD_PACKET_FILTER_BLACKLIST_REPLACE_MAX] + [FLOOD_PACKET_FILTER_PATH_ID_SIZE]; + uint8_t count = 0; + if (!parseFloodPacketFilterBlacklist(cursor, parsed, count)) { + if (requested_slot >= 0) { + snprintf(reply, 160, "Err - use 1-%d unique six-digit hex IDs separated by commas", + FLOOD_PACKET_FILTER_BLACKLIST_REPLACE_MAX); + } else { + snprintf(reply, 160, "Err - use 1-%d unique six-digit hex IDs separated by commas", + FLOOD_PACKET_FILTER_BLACKLIST_REPLACE_MAX); + } + return; + } + + uint8_t previous_count = flood_packet_filter_blacklist_count; + uint8_t previous[FLOOD_PACKET_FILTER_BLACKLIST_MAX][FLOOD_PACKET_FILTER_PATH_ID_SIZE]; + memcpy(previous, flood_packet_filter_blacklist, sizeof(previous)); + + if (requested_slot >= 0) { + if (requested_slot > flood_packet_filter_blacklist_count) { + snprintf(reply, 160, "Err - next blacklist slot is %u", + (uint32_t)flood_packet_filter_blacklist_count + 1U); + return; + } + if ((uint16_t)requested_slot + count > FLOOD_PACKET_FILTER_BLACKLIST_MAX) { + snprintf(reply, 160, "Err - blacklist range exceeds slot %u", + (uint32_t)FLOOD_PACKET_FILTER_BLACKLIST_MAX); + return; + } + for (uint8_t i = 0; i < flood_packet_filter_blacklist_count; i++) { + if (i >= requested_slot && i < requested_slot + count) continue; + for (uint8_t j = 0; j < count; j++) { + if (memcmp(flood_packet_filter_blacklist[i], parsed[j], + FLOOD_PACKET_FILTER_PATH_ID_SIZE) == 0) { + strcpy(reply, "Err - duplicate blacklist ID"); + return; + } + } + } + memcpy(flood_packet_filter_blacklist[requested_slot], parsed, + count * FLOOD_PACKET_FILTER_PATH_ID_SIZE); + uint16_t new_count = (uint16_t)requested_slot + count; + if (new_count > flood_packet_filter_blacklist_count) { + flood_packet_filter_blacklist_count = (uint8_t)new_count; + } + } else { + flood_packet_filter_blacklist_count = count; + memset(flood_packet_filter_blacklist, 0, sizeof(flood_packet_filter_blacklist)); + memcpy(flood_packet_filter_blacklist, parsed, + count * FLOOD_PACKET_FILTER_PATH_ID_SIZE); + } + + if (!saveFloodPacketFilterBlacklist()) { + flood_packet_filter_blacklist_count = previous_count; + memcpy(flood_packet_filter_blacklist, previous, sizeof(previous)); + strcpy(reply, "Err - unable to save flood filter blacklist"); + return; + } + if (requested_slot >= 0) { + if (count == 1) { + snprintf(reply, 160, "OK - blacklist slot %u", + (uint32_t)requested_slot + 1U); + } else { + snprintf(reply, 160, "OK - blacklist slots %u-%u", + (uint32_t)requested_slot + 1U, + (uint32_t)requested_slot + count); + } + } else { + snprintf(reply, 160, "OK - %u blacklist IDs", (uint32_t)count); + } +} + +void MyMesh::deleteFloodPacketFilterBlacklist(const char* args, char* reply) { + const char* selector = skipFloodFilterSpaces(args); + if (*selector == '.') selector = skipFloodFilterSpaces(selector + 1); + bool delete_all = *selector == 0 || floodFilterAsciiEqual(selector, "all"); + uint8_t slot = 0; + if (!delete_all + && (!parseFloodFilterUnsigned(selector, FLOOD_PACKET_FILTER_BLACKLIST_MAX, slot) + || slot == 0 || slot > flood_packet_filter_blacklist_count)) { + snprintf(reply, 160, "Err - use: del flood.filter.blacklist[.<1-%u>| all]", + (uint32_t)FLOOD_PACKET_FILTER_BLACKLIST_MAX); + return; + } + + uint8_t previous_count = flood_packet_filter_blacklist_count; + uint8_t previous[FLOOD_PACKET_FILTER_BLACKLIST_MAX][FLOOD_PACKET_FILTER_PATH_ID_SIZE]; + memcpy(previous, flood_packet_filter_blacklist, sizeof(previous)); + if (delete_all) { + flood_packet_filter_blacklist_count = 0; + memset(flood_packet_filter_blacklist, 0, sizeof(flood_packet_filter_blacklist)); + } else { + uint8_t index = slot - 1; + if (index + 1 < flood_packet_filter_blacklist_count) { + memmove(flood_packet_filter_blacklist[index], + flood_packet_filter_blacklist[index + 1], + (flood_packet_filter_blacklist_count - index - 1) + * FLOOD_PACKET_FILTER_PATH_ID_SIZE); + } + flood_packet_filter_blacklist_count--; + memset(flood_packet_filter_blacklist[flood_packet_filter_blacklist_count], 0, + FLOOD_PACKET_FILTER_PATH_ID_SIZE); + } + if (!saveFloodPacketFilterBlacklist()) { + flood_packet_filter_blacklist_count = previous_count; + memcpy(flood_packet_filter_blacklist, previous, sizeof(previous)); + strcpy(reply, "Err - unable to save flood filter blacklist"); + return; + } + if (delete_all) { + strcpy(reply, "OK - flood filter blacklist removed"); + } else { + snprintf(reply, 160, "OK - blacklist slot %u removed", (uint32_t)slot); + } +} + void MyMesh::seedDefaultFloodPacketFilters() { auto& entry = flood_packet_filters[0]; memset(&entry, 0, sizeof(entry)); @@ -4274,33 +4732,82 @@ void MyMesh::loadFloodPacketFilters() { bool success = file.read(magic, sizeof(magic)) == sizeof(magic); bool version_1 = success && memcmp(magic, "FPF1", sizeof(magic)) == 0; bool version_2 = success && memcmp(magic, "FPF2", sizeof(magic)) == 0; - success = (version_1 || version_2) + bool version_3 = success && memcmp(magic, "FPF3", sizeof(magic)) == 0; + bool version_4 = success && memcmp(magic, "FPF4", sizeof(magic)) == 0; + bool version_5 = success && memcmp(magic, "FPF5", sizeof(magic)) == 0; + bool version_6 = success && memcmp(magic, "FPF6", sizeof(magic)) == 0; + success = (version_1 || version_2 || version_3 || version_4 || version_5 + || version_6) && file.read(&count, sizeof(count)) == sizeof(count) && count <= FLOOD_PACKET_FILTER_SLOTS; for (int i = 0; success && i < count; i++) { uint8_t active = 0; uint8_t suspend_on_temp_radio = 0; + uint8_t match_blacklisted_path = 0; + uint8_t scope_requires_region_match = 0; + uint8_t scope_uses_slow_timing = 0; success = file.read(&active, sizeof(active)) == sizeof(active); success = success && file.read(&loaded[i].payload_type, sizeof(loaded[i].payload_type)) == sizeof(loaded[i].payload_type); success = success && file.read(&loaded[i].min_hops, sizeof(loaded[i].min_hops)) == sizeof(loaded[i].min_hops); success = success && file.read(&loaded[i].max_hops, sizeof(loaded[i].max_hops)) == sizeof(loaded[i].max_hops); - if (success && version_2) { + if (success && (version_2 || version_3 || version_4 || version_5 + || version_6)) { success = file.read(&suspend_on_temp_radio, sizeof(suspend_on_temp_radio)) == sizeof(suspend_on_temp_radio); } + if (success && (version_3 || version_4 || version_5 || version_6)) { + success = file.read((uint8_t*)loaded[i].scope_name, + sizeof(loaded[i].scope_name)) == sizeof(loaded[i].scope_name); + success = success + && memchr(loaded[i].scope_name, 0, sizeof(loaded[i].scope_name)) != NULL; + } + if (success && (version_4 || version_5 || version_6)) { + success = file.read(&match_blacklisted_path, + sizeof(match_blacklisted_path)) + == sizeof(match_blacklisted_path); + } + if (success && (version_5 || version_6)) { + success = file.read(&scope_requires_region_match, + sizeof(scope_requires_region_match)) + == sizeof(scope_requires_region_match); + } + if (success && version_6) { + success = file.read(&scope_uses_slow_timing, + sizeof(scope_uses_slow_timing)) + == sizeof(scope_uses_slow_timing); + } loaded[i].active = active != 0; loaded[i].suspend_on_temp_radio = suspend_on_temp_radio != 0; + loaded[i].match_blacklisted_path = match_blacklisted_path != 0; + loaded[i].scope_requires_region_match = scope_requires_region_match != 0; + loaded[i].scope_uses_slow_timing = scope_uses_slow_timing != 0; if (success && (active > 1 || suspend_on_temp_radio > 1 - || (loaded[i].suspend_on_temp_radio && !loaded[i].active))) { + || match_blacklisted_path > 1 || scope_requires_region_match > 1 + || scope_uses_slow_timing > 1 + || (!loaded[i].active + && (loaded[i].suspend_on_temp_radio || loaded[i].scope_name[0] != 0 + || loaded[i].match_blacklisted_path + || loaded[i].scope_requires_region_match + || loaded[i].scope_uses_slow_timing)))) { success = false; } if (success && loaded[i].active && !((loaded[i].payload_type <= PH_TYPE_MASK || loaded[i].payload_type == FLOOD_PACKET_FILTER_ANY_TYPE) && loaded[i].min_hops <= loaded[i].max_hops - && loaded[i].max_hops <= FLOOD_PACKET_FILTER_MAX_HOPS)) { + && loaded[i].max_hops <= FLOOD_PACKET_FILTER_MAX_HOPS + && (loaded[i].scope_name[0] == 0 + || isValidStoredFloodFilterScopeName(loaded[i].scope_name)) + && (!loaded[i].scope_requires_region_match + || loaded[i].scope_name[0] != 0) + && (!loaded[i].scope_uses_slow_timing + || loaded[i].scope_name[0] != 0))) { success = false; } + if (success && loaded[i].scope_name[0] != 0) { + StrHelper::strzcpy(loaded[i].scope_name, loaded[i].scope_name, + sizeof(loaded[i].scope_name)); + } } file.close(); @@ -4313,7 +4820,7 @@ bool MyMesh::saveFloodPacketFilters() { File file = openFloodChannelBlockWrite(_fs, FLOOD_PACKET_FILTER_FILE); if (!file) return false; - const uint8_t magic[4] = {'F', 'P', 'F', '2'}; + const uint8_t magic[4] = {'F', 'P', 'F', '6'}; uint8_t count = FLOOD_PACKET_FILTER_SLOTS; bool success = file.write(magic, sizeof(magic)) == sizeof(magic) && file.write(&count, sizeof(count)) == sizeof(count); @@ -4321,16 +4828,99 @@ bool MyMesh::saveFloodPacketFilters() { const auto& entry = flood_packet_filters[i]; uint8_t active = entry.active ? 1 : 0; uint8_t suspend_on_temp_radio = entry.suspend_on_temp_radio ? 1 : 0; + uint8_t match_blacklisted_path = entry.match_blacklisted_path ? 1 : 0; + uint8_t scope_requires_region_match = + entry.scope_requires_region_match ? 1 : 0; + uint8_t scope_uses_slow_timing = + entry.scope_uses_slow_timing ? 1 : 0; success = file.write(&active, sizeof(active)) == sizeof(active); success = success && file.write(&entry.payload_type, sizeof(entry.payload_type)) == sizeof(entry.payload_type); success = success && file.write(&entry.min_hops, sizeof(entry.min_hops)) == sizeof(entry.min_hops); success = success && file.write(&entry.max_hops, sizeof(entry.max_hops)) == sizeof(entry.max_hops); success = success && file.write(&suspend_on_temp_radio, sizeof(suspend_on_temp_radio)) == sizeof(suspend_on_temp_radio); + success = success && file.write((const uint8_t*)entry.scope_name, + sizeof(entry.scope_name)) == sizeof(entry.scope_name); + success = success && file.write(&match_blacklisted_path, + sizeof(match_blacklisted_path)) + == sizeof(match_blacklisted_path); + success = success && file.write(&scope_requires_region_match, + sizeof(scope_requires_region_match)) + == sizeof(scope_requires_region_match); + success = success && file.write(&scope_uses_slow_timing, + sizeof(scope_uses_slow_timing)) + == sizeof(scope_uses_slow_timing); } file.close(); return success; } +bool MyMesh::floodPacketFilterBlacklistMatches(const mesh::Packet* packet) const { + static_assert(FLOOD_PACKET_FILTER_PATH_ID_SIZE + == FloodFilterPolicy::BLACKLIST_ID_SIZE, + "flood filter blacklist ID sizes must agree"); + return FloodFilterPolicy::pathMatchesBlacklist( + packet, (const uint8_t*)flood_packet_filter_blacklist, + flood_packet_filter_blacklist_count); +} + +bool MyMesh::floodPacketFilterMatches(const FloodPacketFilterEntry& entry, + const mesh::Packet* packet) const { + if (!entry.active || packet == NULL || !packet->isRouteFlood()) return false; + if (entry.suspend_on_temp_radio && isTempRadioActive()) return false; + if (entry.match_blacklisted_path && !floodPacketFilterBlacklistMatches(packet)) { + return false; + } + + uint8_t type = packet->getPayloadType(); + if (type == PAYLOAD_TYPE_TRACE && entry.payload_type == FLOOD_PACKET_FILTER_ANY_TYPE) { + return false; + } + uint8_t hops = packet->getPathHashCount(); + if ((entry.payload_type != FLOOD_PACKET_FILTER_ANY_TYPE && entry.payload_type != type) + || hops < entry.min_hops || hops > entry.max_hops) { + return false; + } + + return true; +} + +bool MyMesh::applyFloodPacketFilterScope(mesh::Packet* packet, + bool incoming_region_allowed, + bool& scope_set, + bool& fast_track, + bool log_change) { + scope_set = false; + fast_track = false; + if (packet == NULL || !packet->isRouteFlood()) return false; + + for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + const auto& entry = flood_packet_filters[i]; + if (entry.scope_name[0] == 0 || !floodPacketFilterMatches(entry, packet)) continue; + if (!FloodFilterPolicy::scopeRuleAllowed(entry.scope_requires_region_match, + incoming_region_allowed)) { + continue; + } + + TransportKey scope; + deriveFloodFilterScopeKey(entry.scope_name, scope); + uint16_t transport_code = scope.calcTransportCode(packet); + bool scope_changed = + FloodFilterPolicy::setTransportScope(packet, transport_code); + + scope_set = true; + fast_track = FloodFilterPolicy::fastTrackScopeChange( + scope_changed, entry.scope_uses_slow_timing); + if (scope_changed && log_change) { + MESH_DEBUG_PRINTLN("flood.filter set scope slot=%d type=%d hops=%d scope=%s tx=%s", + i + 1, packet->getPayloadType(), packet->getPathHashCount(), + entry.scope_name, + entry.scope_uses_slow_timing ? "slow" : "fast"); + } + return scope_changed; + } + return false; +} + bool MyMesh::shouldBlockFloodPacketForward(const mesh::Packet* packet) const { if (packet == NULL || !packet->isRouteFlood()) return false; uint8_t type = packet->getPayloadType(); @@ -4339,7 +4929,7 @@ bool MyMesh::shouldBlockFloodPacketForward(const mesh::Packet* packet) const { // A transit repeater cannot decrypt enough to distinguish remote-login/CLI // packets from ordinary peer traffic of the same outer types. // Keep the short-path management transport reachable despite `flood.filter`; - // the existing repeat, flood.max, region, and loop gates still apply. + // the remaining repeat, flood.max, scope, and loop gates still apply. uint8_t protected_hops = floodPacketFilterProtectedHops(packet); if (hops < protected_hops) { MESH_DEBUG_PRINTLN("allowPacketForward: flood.filter remote-admin protection type=%d hops=%d", @@ -4349,18 +4939,9 @@ bool MyMesh::shouldBlockFloodPacketForward(const mesh::Packet* packet) const { for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { const auto& entry = flood_packet_filters[i]; - if (!entry.active) continue; - - // When the temporary radio is active, skip a row explicitly marked with - // `suspend=tempradio`. Other rows remain in force. - if (entry.suspend_on_temp_radio && isTempRadioActive()) continue; - - // TRACE deliberately ignores catch-all rows so traceroute cannot be - // disabled accidentally. An explicit `trace` row remains available. - if (type == PAYLOAD_TYPE_TRACE && entry.payload_type == FLOOD_PACKET_FILTER_ANY_TYPE) continue; - - if ((entry.payload_type == FLOOD_PACKET_FILTER_ANY_TYPE || entry.payload_type == type) - && hops >= entry.min_hops && hops <= entry.max_hops) { + // A row with scope= is a rewrite action, not a drop action. + if (entry.scope_name[0] != 0) continue; + if (floodPacketFilterMatches(entry, packet)) { MESH_DEBUG_PRINTLN("allowPacketForward: flood.filter matched slot=%d type=%d hops=%d range=%d-%d", i + 1, type, hops, entry.min_hops, entry.max_hops); return true; @@ -4378,11 +4959,23 @@ void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_ char hops[12]; formatFloodFilterHopSpec(hops, sizeof(hops), entry.min_hops, entry.max_hops); const char* suspension = entry.suspend_on_temp_radio ? " suspend=tempradio" : ""; + const char* scope_prefix = entry.scope_name[0] ? " scope=" : ""; + const char* scope_name = entry.scope_name[0] ? entry.scope_name : ""; + const char* path_match = entry.match_blacklisted_path ? " path=blacklist" : ""; + const char* region_requirement = + entry.scope_requires_region_match ? " require=region" : ""; + const char* scope_timing = + entry.scope_uses_slow_timing ? " tx=slow" : ""; if (entry.payload_type == FLOOD_PACKET_FILTER_ANY_TYPE) { - snprintf(reply, reply_len, "> %d type=any hops=%s route=flood%s", index + 1, hops, suspension); + snprintf(reply, reply_len, "> %d type=any hops=%s route=flood%s%s%s%s%s%s", + index + 1, hops, path_match, scope_prefix, scope_name, + region_requirement, scope_timing, suspension); } else { - snprintf(reply, reply_len, "> %d type=%s(%u) hops=%s route=flood%s", index + 1, - floodFilterPayloadTypeName(entry.payload_type), (uint32_t)entry.payload_type, hops, suspension); + snprintf(reply, reply_len, "> %d type=%s(%u) hops=%s route=flood%s%s%s%s%s%s", + index + 1, floodFilterPayloadTypeName(entry.payload_type), + (uint32_t)entry.payload_type, hops, path_match, + scope_prefix, scope_name, region_requirement, scope_timing, + suspension); } } @@ -4407,10 +5000,16 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply) const { if (!entry.active) continue; active_count++; char hops[12]; - char item[40]; + char item[112]; formatFloodFilterHopSpec(hops, sizeof(hops), entry.min_hops, entry.max_hops); - snprintf(item, sizeof(item), entry.suspend_on_temp_radio ? " %d=%s@%s~tempradio" : " %d=%s@%s", - i + 1, floodFilterPayloadTypeName(entry.payload_type), hops); + snprintf(item, sizeof(item), " %d=%s@%s%s%s%s%s%s%s", + i + 1, floodFilterPayloadTypeName(entry.payload_type), hops, + entry.match_blacklisted_path ? "?blacklist" : "", + entry.scope_name[0] ? ">" : "", + entry.scope_name, + entry.scope_requires_region_match ? "!region" : "", + entry.scope_uses_slow_timing ? "~slow" : "", + entry.suspend_on_temp_radio ? "~tempradio" : ""); size_t item_len = strlen(item); if (used + item_len >= 156) { truncated = true; @@ -4453,18 +5052,18 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply) { } } cursor = skipFloodFilterSpaces(cursor); - if (strlen(cursor) >= 64) { + if (strlen(cursor) >= 140) { strcpy(reply, "Err - filter parameters too long"); return; } - char params[64]; + char params[140]; strcpy(params, cursor); - char* tokens[4]; + char* tokens[7]; int token_count = 0; char* token = params; while (*token != 0) { - if (token_count >= 4) { + if (token_count >= 7) { strcpy(reply, "Err - too many flood filter parameters"); return; } @@ -4476,7 +5075,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply) { token = separator; } if (token_count == 0) { - strcpy(reply, "Err - use: set flood.filter[.n] [hops] [suspend=tempradio]"); + strcpy(reply, "Err - use: set flood.filter[.n] [hops] [path=blacklist] [scope=] [require=region] [tx=slow] [suspend=tempradio]"); return; } @@ -4490,6 +5089,12 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply) { uint8_t max_hops = FLOOD_PACKET_FILTER_MAX_HOPS; bool hops_set = false; bool suspend_on_temp_radio = false; + bool match_blacklisted_path = false; + bool scope_requires_region_match = false; + bool scope_timing_set = false; + bool scope_uses_slow_timing = false; + char scope_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN]; + memset(scope_name, 0, sizeof(scope_name)); for (int i = 1; i < token_count; i++) { if (floodFilterAsciiEqual(tokens[i], "suspend=tempradio")) { if (suspend_on_temp_radio) { @@ -4497,13 +5102,55 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply) { return; } suspend_on_temp_radio = true; + } else if (floodFilterAsciiEqual(tokens[i], "path=blacklist")) { + if (match_blacklisted_path) { + strcpy(reply, "Err - duplicate path=blacklist"); + return; + } + match_blacklisted_path = true; + } else if (floodFilterAsciiEqual(tokens[i], "require=region")) { + if (scope_requires_region_match) { + strcpy(reply, "Err - duplicate require=region"); + return; + } + scope_requires_region_match = true; + } else if (floodFilterAsciiStartsWith(tokens[i], "tx=")) { + if (scope_timing_set) { + strcpy(reply, "Err - duplicate tx timing"); + return; + } + if (floodFilterAsciiEqual(tokens[i], "tx=slow")) { + scope_uses_slow_timing = true; + } else if (!floodFilterAsciiEqual(tokens[i], "tx=fast")) { + strcpy(reply, "Err - tx timing must be fast or slow"); + return; + } + scope_timing_set = true; + } else if (floodFilterAsciiStartsWith(tokens[i], "scope=")) { + if (scope_name[0] != 0) { + strcpy(reply, "Err - duplicate scope"); + return; + } + if (!normalizeFloodFilterScopeName(tokens[i] + strlen("scope="), + scope_name, sizeof(scope_name))) { + strcpy(reply, "Err - scope must be a public name of at most 30 characters"); + return; + } } else if (!hops_set && parseFloodFilterHopSpec(tokens[i], min_hops, max_hops)) { hops_set = true; } else { - strcpy(reply, "Err - use one hop expression and optional suspend=tempradio"); + strcpy(reply, "Err - use optional hops, path=blacklist, scope=, require=region, tx=slow, and suspend=tempradio"); return; } } + if (scope_requires_region_match && scope_name[0] == 0) { + strcpy(reply, "Err - require=region requires scope="); + return; + } + if (scope_timing_set && scope_name[0] == 0) { + strcpy(reply, "Err - tx timing requires scope="); + return; + } int slot = requested_slot; if (slot < 0) { @@ -4511,7 +5158,10 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply) { const auto& entry = flood_packet_filters[i]; if (entry.active && entry.payload_type == payload_type && entry.min_hops == min_hops && entry.max_hops == max_hops - && entry.suspend_on_temp_radio == suspend_on_temp_radio) { + && entry.suspend_on_temp_radio == suspend_on_temp_radio + && entry.match_blacklisted_path == match_blacklisted_path + && entry.scope_requires_region_match == scope_requires_region_match + && strcmp(entry.scope_name, scope_name) == 0) { slot = i; break; } @@ -4537,6 +5187,10 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply) { entry.min_hops = min_hops; entry.max_hops = max_hops; entry.suspend_on_temp_radio = suspend_on_temp_radio; + entry.match_blacklisted_path = match_blacklisted_path; + entry.scope_requires_region_match = scope_requires_region_match; + entry.scope_uses_slow_timing = scope_uses_slow_timing; + StrHelper::strzcpy(entry.scope_name, scope_name, sizeof(entry.scope_name)); if (!saveFloodPacketFilters()) { entry = previous; strcpy(reply, "Err - unable to save flood filter"); @@ -4585,6 +5239,7 @@ void MyMesh::deleteFloodPacketFilter(const char* args, char* reply) { } static bool isExactFloodChannelScopeSelector(uint8_t selector) { + selector = FloodFilterPolicy::scopeSelectorValue(selector); return selector == CIPHER_KEY_SIZE || selector == PUB_KEY_SIZE; } @@ -4610,6 +5265,7 @@ static uint8_t wildcardFloodChannelScopeSelector(uint8_t type) { } static const char* wildcardFloodChannelScopeName(uint8_t selector) { + selector = FloodFilterPolicy::scopeSelectorValue(selector); if (selector == FLOOD_CHANNEL_SCOPE_TXT_ANY) return "txt:*"; if (selector == FLOOD_CHANNEL_SCOPE_LOGIN_ANY) return "login:*"; if (selector == FLOOD_CHANNEL_SCOPE_OTHER_ANY) return "other:*"; @@ -4624,8 +5280,10 @@ void MyMesh::loadFloodChannelScopes() { uint8_t magic[4]; uint8_t count = 0; - bool success = file.read(magic, sizeof(magic)) == sizeof(magic) - && memcmp(magic, "FCS1", sizeof(magic)) == 0 + 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) && 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); @@ -4636,14 +5294,21 @@ void MyMesh::loadFloodChannelScopes() { } for (int i = 0; success && i < retained; i++) { auto& entry = flood_channel_scopes[i]; + if (version_1 + && FloodFilterPolicy::scopeUsesSlowTiming(entry.selector)) { + success = false; + break; + } + uint8_t selector = FloodFilterPolicy::scopeSelectorValue(entry.selector); if (entry.region_id == 0) { memset(&entry, 0, sizeof(entry)); - } else if (entry.selector <= FLOOD_CHANNEL_SCOPE_OTHER_ANY) { + } else if (selector <= FLOOD_CHANNEL_SCOPE_OTHER_ANY) { entry.channel_hash = 0; memset(entry.secret, 0, sizeof(entry.secret)); - } else if (isExactFloodChannelScopeSelector(entry.selector)) { - mesh::Utils::sha256(&entry.channel_hash, sizeof(entry.channel_hash), entry.secret, entry.selector); - if (entry.selector == CIPHER_KEY_SIZE) { + } else if (isExactFloodChannelScopeSelector(selector)) { + mesh::Utils::sha256(&entry.channel_hash, sizeof(entry.channel_hash), + entry.secret, selector); + if (selector == CIPHER_KEY_SIZE) { memset(&entry.secret[CIPHER_KEY_SIZE], 0, PUB_KEY_SIZE - CIPHER_KEY_SIZE); } } else { @@ -4661,7 +5326,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', '1'}; + const uint8_t magic[4] = {'F', 'C', 'S', '2'}; uint8_t count = FLOOD_CHANNEL_SCOPE_SLOTS; bool success = file.write(magic, sizeof(magic)) == sizeof(magic) && file.write(&count, sizeof(count)) == sizeof(count); @@ -4683,8 +5348,125 @@ bool MyMesh::saveFloodChannelScopes(bool empty_table) { return true; } +void MyMesh::loadFloodChannelScopeRequirements() { + memset(flood_channel_scope_requirements, 0, + sizeof(flood_channel_scope_requirements)); + if (_fs == NULL || !_fs->exists(FLOOD_CHANNEL_SCOPE_REQUIRE_FILE)) return; + + File file = + openFloodChannelBlockRead(_fs, FLOOD_CHANNEL_SCOPE_REQUIRE_FILE); + if (!file) return; + + uint8_t magic[4]; + uint8_t count = 0; + bool success = file.read(magic, sizeof(magic)) == sizeof(magic) + && memcmp(magic, "FCR1", sizeof(magic)) == 0 + && file.read(&count, sizeof(count)) == sizeof(count); + uint8_t retained = count < FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS + ? count : FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS; + size_t retained_bytes = + (size_t)retained * sizeof(FloodChannelScopeRequireEntry); + success = success + && file.read((uint8_t*)flood_channel_scope_requirements, retained_bytes) + == retained_bytes; + FloodChannelScopeRequireEntry discarded; + for (int i = retained; success && i < count; i++) { + success = file.read((uint8_t*)&discarded, sizeof(discarded)) + == sizeof(discarded); + } + for (int i = 0; success && i < retained; i++) { + auto& entry = flood_channel_scope_requirements[i]; + if (entry.key_len == 0) { + memset(&entry, 0, sizeof(entry)); + } else if (entry.key_len == CIPHER_KEY_SIZE + || entry.key_len == PUB_KEY_SIZE) { + mesh::Utils::sha256(&entry.channel_hash, sizeof(entry.channel_hash), + entry.secret, entry.key_len); + if (entry.key_len == CIPHER_KEY_SIZE) { + memset(&entry.secret[CIPHER_KEY_SIZE], 0, + PUB_KEY_SIZE - CIPHER_KEY_SIZE); + } + } else { + success = false; + } + } + file.close(); + + if (!success) { + memset(flood_channel_scope_requirements, 0, + sizeof(flood_channel_scope_requirements)); + } +} + +bool MyMesh::saveFloodChannelScopeRequirements(bool empty_table) { + if (_fs == NULL) return false; + File file = openFloodChannelBlockWrite( + _fs, FLOOD_CHANNEL_SCOPE_REQUIRE_TEMP_FILE); + if (!file) return false; + + const uint8_t magic[4] = {'F', 'C', 'R', '1'}; + uint8_t count = FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS; + bool success = file.write(magic, sizeof(magic)) == sizeof(magic) + && file.write(&count, sizeof(count)) == sizeof(count); + if (success && empty_table) { + FloodChannelScopeRequireEntry empty; + memset(&empty, 0, sizeof(empty)); + for (int i = 0; success && i < FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS; i++) { + success = file.write((const uint8_t*)&empty, sizeof(empty)) + == sizeof(empty); + } + } else if (success) { + success = file.write( + (const uint8_t*)flood_channel_scope_requirements, + sizeof(flood_channel_scope_requirements)) + == sizeof(flood_channel_scope_requirements); + } + file.close(); + if (!success || !_fs->rename(FLOOD_CHANNEL_SCOPE_REQUIRE_TEMP_FILE, + FLOOD_CHANNEL_SCOPE_REQUIRE_FILE)) { + _fs->remove(FLOOD_CHANNEL_SCOPE_REQUIRE_TEMP_FILE); + return false; + } + return true; +} + +bool MyMesh::findFloodChannelScopeRequirementMatch( + const mesh::Packet* packet, bool& table_active) const { + table_active = false; + if (packet == NULL || !packet->isRouteFlood()) return false; + + uint8_t type = packet->getPayloadType(); + if (type != PAYLOAD_TYPE_GRP_TXT && type != PAYLOAD_TYPE_GRP_DATA) { + return false; + } + bool valid_layout = + 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; + + for (int i = 0; i < FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS; i++) { + const auto& entry = flood_channel_scope_requirements[i]; + if (entry.key_len == 0) continue; + table_active = true; + if (!valid_layout || packet->payload[0] != entry.channel_hash) continue; + + uint8_t data[MAX_PACKET_PAYLOAD]; + if (mesh::Utils::MACThenDecrypt( + entry.secret, data, &packet->payload[PATH_HASH_SIZE], + packet->payload_len - PATH_HASH_SIZE) > 0) { + return true; + } + } + return false; +} + bool MyMesh::applyFloodChannelScopeTarget(mesh::Packet* packet, - const FloodChannelScopeEntry& entry) { + const FloodChannelScopeEntry& entry, + bool& scope_changed, + bool& fast_track, + bool log_change) { + scope_changed = false; + fast_track = false; RegionEntry* region = region_map.findById(entry.region_id); TransportKey scope; if (region == NULL || region->isWildcard() || (region->flags & REGION_DENY_FLOOD) != 0 @@ -4692,16 +5474,26 @@ bool MyMesh::applyFloodChannelScopeTarget(mesh::Packet* packet, return false; } - packet->header = (packet->header & (uint8_t)~PH_ROUTE_MASK) | ROUTE_TYPE_TRANSPORT_FLOOD; - packet->transport_codes[0] = scope.calcTransportCode(packet); - packet->transport_codes[1] = 0; - MESH_DEBUG_PRINTLN("force-scoped unscoped flood type=%u to region=%s", - (unsigned int)packet->getPayloadType(), region->name); + uint16_t transport_code = scope.calcTransportCode(packet); + scope_changed = + FloodFilterPolicy::setTransportScope(packet, transport_code); + if (!scope_changed) return true; + fast_track = FloodFilterPolicy::fastTrackScopeChange( + scope_changed, FloodFilterPolicy::scopeUsesSlowTiming(entry.selector)); + + if (log_change) { + MESH_DEBUG_PRINTLN("force-scoped flood type=%u to region=%s tx=%s", + (unsigned int)packet->getPayloadType(), region->name, + FloodFilterPolicy::scopeUsesSlowTiming(entry.selector) + ? "slow" : "fast"); + } return true; } -bool MyMesh::applyFloodChannelScope(mesh::Packet* packet) { - if (packet == NULL || packet->getRouteType() != ROUTE_TYPE_FLOOD) return false; +bool MyMesh::applyFloodChannelScope(mesh::Packet* packet, bool& fast_track, + bool log_change) { + fast_track = false; + if (packet == NULL || !packet->isRouteFlood()) return false; uint8_t type = packet->getPayloadType(); // TRACE must cross region boundaries unchanged. Normal traceroute is direct, // but preserve the same rule for any flood-form trace received over the air. @@ -4717,15 +5509,26 @@ bool MyMesh::applyFloodChannelScope(mesh::Packet* packet) { 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 - && applyFloodChannelScopeTarget(packet, entry)) return true; + 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 && entry.selector == wildcard - && applyFloodChannelScopeTarget(packet, entry)) return true; + 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; + } + } } return false; } @@ -4855,20 +5658,24 @@ void MyMesh::formatFloodChannelScopeDetail(int index, char* reply, size_t reply_ } char channel[12]; - const char* wildcard = wildcardFloodChannelScopeName(entry.selector); + uint8_t selector = FloodFilterPolicy::scopeSelectorValue(entry.selector); + const char* wildcard = wildcardFloodChannelScopeName(selector); if (wildcard != NULL) { strcpy(channel, wildcard); } else { uint8_t prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]; - mesh::Utils::sha256(prefix, sizeof(prefix), entry.secret, entry.selector); + mesh::Utils::sha256(prefix, sizeof(prefix), entry.secret, selector); mesh::Utils::toHex(channel, prefix, sizeof(prefix)); } RegionEntry* region = region_map.findById(entry.region_id); + const char* timing = + FloodFilterPolicy::scopeUsesSlowTiming(entry.selector) ? " tx=slow" : ""; if (region != NULL) { - snprintf(reply, reply_len, "> %d match=%s scope=%s", index + 1, channel, region->name); + snprintf(reply, reply_len, "> %d match=%s scope=%s%s", + index + 1, channel, region->name, timing); } else { - snprintf(reply, reply_len, "> %d match=%s scope=id:%u?", index + 1, channel, - (unsigned int)entry.region_id); + snprintf(reply, reply_len, "> %d match=%s scope=id:%u?%s", index + 1, + channel, (unsigned int)entry.region_id, timing); } } @@ -4906,11 +5713,27 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) { char channel_text[80]; char region_text[32]; - char extra[2]; + char timing_text[12]; if (takeFloodModerationToken(cursor, channel_text, sizeof(channel_text)) != 1 - || takeFloodModerationToken(cursor, region_text, sizeof(region_text)) != 1 + || takeFloodModerationToken(cursor, region_text, sizeof(region_text)) != 1) { + strcpy(reply, "Err - use: set flood.channel.scope[.n] [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"); + return; + } + } + char extra[2]; + if (timing_result < 0 || takeFloodModerationToken(cursor, extra, sizeof(extra)) != 0) { - strcpy(reply, "Err - use: set flood.channel.scope[.n] "); + strcpy(reply, "Err - use: set flood.channel.scope[.n] [tx=slow]"); return; } @@ -4950,7 +5773,7 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) { const auto& entry = flood_channel_scopes[i]; if (entry.region_id == 0) { if (free_slot < 0) free_slot = i; - } else if (entry.selector == selector + } else if (FloodFilterPolicy::scopeSelectorValue(entry.selector) == selector && (!isExactFloodChannelScopeSelector(selector) || memcmp(entry.secret, secret, PUB_KEY_SIZE) == 0)) { matching_slot = i; @@ -4967,7 +5790,8 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) { auto& entry = flood_channel_scopes[slot]; memset(&entry, 0, sizeof(entry)); entry.region_id = region->id; - entry.selector = selector; + entry.selector = + FloodFilterPolicy::encodeScopeSelector(selector, slow_timing); if (isExactFloodChannelScopeSelector(selector)) { entry.channel_hash = hash_prefix[0]; memcpy(entry.secret, secret, sizeof(entry.secret)); @@ -5012,6 +5836,158 @@ void MyMesh::deleteFloodChannelScope(const char* args, char* reply) { } } +void MyMesh::formatFloodChannelScopeRequirementDetail( + int index, char* reply, size_t reply_len) const { + if (index < 0 || index >= FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS) { + snprintf(reply, reply_len, "Err - require slot must be 1-%d", + FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS); + return; + } + const auto& entry = flood_channel_scope_requirements[index]; + if (entry.key_len == 0) { + snprintf(reply, reply_len, "> %d empty", index + 1); + return; + } + + uint8_t prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]; + char prefix_text[FLOOD_CHANNEL_BLOCK_PREFIX_LEN * 2 + 1]; + mesh::Utils::sha256(prefix, sizeof(prefix), entry.secret, entry.key_len); + mesh::Utils::toHex(prefix_text, prefix, sizeof(prefix)); + snprintf(reply, reply_len, "> %d match=%s key=%u require=scope", + index + 1, prefix_text, (unsigned int)entry.key_len * 8U); +} + +void MyMesh::formatFloodChannelScopeRequirements(const char* args, + char* reply) { + const char* cursor = args; + int index = -1; + if (!parseFloodChannelScopeIndex(cursor, index)) { + snprintf(reply, 160, "Err - require slot must be 1-%d", + FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS); + return; + } + if (*cursor != 0) { + strcpy(reply, "Err - use get flood.channel.scope.require[.n]"); + return; + } + if (index >= 0) { + formatFloodChannelScopeRequirementDetail(index, reply, 160); + return; + } + + int active = 0; + for (int i = 0; i < FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS; i++) { + if (flood_channel_scope_requirements[i].key_len != 0) active++; + } + snprintf(reply, 160, + "> %d/%d active; listed=require allowed scope, others=bypass", + active, FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS); +} + +void MyMesh::setFloodChannelScopeRequirement(const char* args, char* reply) { + const char* cursor = args; + int requested_slot = -1; + if (!parseFloodChannelScopeIndex(cursor, requested_slot)) { + strcpy(reply, "Err - bad require slot"); + return; + } + + char channel_text[80]; + char extra[2]; + if (takeFloodModerationToken(cursor, channel_text, sizeof(channel_text)) != 1 + || takeFloodModerationToken(cursor, extra, sizeof(extra)) != 0) { + strcpy(reply, + "Err - use: set flood.channel.scope.require[.n] "); + return; + } + + uint8_t secret[PUB_KEY_SIZE]; + uint8_t key_len = 0; + uint8_t hash_prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]; + char ignored_name[FLOOD_GROUP_MODERATION_NAME_LEN]; + if (!parseFloodModerationChannel(channel_text, secret, key_len, hash_prefix, + ignored_name, sizeof(ignored_name))) { + strcpy(reply, "Err - channel must be public, #name, or 128/256-bit key"); + return; + } + + int matching_slot = -1; + int free_slot = -1; + for (int i = 0; i < FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS; i++) { + const auto& entry = flood_channel_scope_requirements[i]; + if (entry.key_len == 0) { + if (free_slot < 0) free_slot = i; + } else if (entry.key_len == key_len + && memcmp(entry.secret, secret, PUB_KEY_SIZE) == 0) { + matching_slot = i; + break; + } + } + int slot = requested_slot >= 0 + ? requested_slot + : (matching_slot >= 0 ? matching_slot : free_slot); + if (slot < 0) { + strcpy(reply, "Err - scope require table full"); + return; + } + + FloodChannelScopeRequireEntry previous = + flood_channel_scope_requirements[slot]; + auto& entry = flood_channel_scope_requirements[slot]; + memset(&entry, 0, sizeof(entry)); + entry.key_len = key_len; + entry.channel_hash = hash_prefix[0]; + memcpy(entry.secret, secret, sizeof(entry.secret)); + if (entry.key_len == CIPHER_KEY_SIZE) { + memset(&entry.secret[CIPHER_KEY_SIZE], 0, + PUB_KEY_SIZE - CIPHER_KEY_SIZE); + } + + if (!saveFloodChannelScopeRequirements()) { + entry = previous; + strcpy(reply, "Err - save failed"); + return; + } + formatFloodChannelScopeRequirementDetail(slot, reply, 160); +} + +void MyMesh::deleteFloodChannelScopeRequirement(const char* args, + char* reply) { + const char* cursor = skipFloodFilterSpaces(args); + if (floodFilterAsciiEqual(cursor, "all")) { + if (!saveFloodChannelScopeRequirements(true)) { + strcpy(reply, "Err - save failed"); + } else { + memset(flood_channel_scope_requirements, 0, + sizeof(flood_channel_scope_requirements)); + strcpy(reply, "OK"); + } + return; + } + + int index = -1; + if (!parseFloodChannelScopeIndex(cursor, index)) index = -1; + if (index < 0 || *cursor != 0) { + strcpy(reply, "Err - use: del flood.channel.scope.require.|all"); + return; + } + if (flood_channel_scope_requirements[index].key_len == 0) { + strcpy(reply, "Err - empty require slot"); + return; + } + + FloodChannelScopeRequireEntry previous = + flood_channel_scope_requirements[index]; + memset(&flood_channel_scope_requirements[index], 0, + sizeof(flood_channel_scope_requirements[index])); + if (!saveFloodChannelScopeRequirements()) { + flood_channel_scope_requirements[index] = previous; + strcpy(reply, "Err - save failed"); + } else { + strcpy(reply, "OK"); + } +} + static bool parseFloodModerationPath(const char* text, uint8_t& hash_size, uint8_t& path_hops, uint8_t path[FLOOD_GROUP_MODERATION_PATH_BYTES_MAX]) { memset(path, 0, FLOOD_GROUP_MODERATION_PATH_BYTES_MAX); @@ -6914,12 +7890,30 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * } // handle ACL related commands - if (commandFamilyMatches(command, "get flood.channel.scope")) { + if (commandFamilyMatches(command, "get flood.channel.scope.require")) { + formatFloodChannelScopeRequirements( + command + strlen("get flood.channel.scope.require"), reply); + } else if (commandFamilyMatches(command, "set flood.channel.scope.require")) { + setFloodChannelScopeRequirement( + command + strlen("set flood.channel.scope.require"), reply); + } else if (commandFamilyMatches(command, "del flood.channel.scope.require")) { + deleteFloodChannelScopeRequirement( + command + strlen("del flood.channel.scope.require"), reply); + } else if (commandFamilyMatches(command, "get flood.channel.scope")) { formatFloodChannelScopes(command + strlen("get flood.channel.scope"), reply); } else if (commandFamilyMatches(command, "set flood.channel.scope")) { setFloodChannelScope(command + strlen("set flood.channel.scope"), reply); } else if (commandFamilyMatches(command, "del flood.channel.scope")) { deleteFloodChannelScope(command + strlen("del flood.channel.scope"), reply); + } else if (commandFamilyMatches(command, "get flood.filter.blacklist")) { + formatFloodPacketFilterBlacklist( + command + strlen("get flood.filter.blacklist"), reply); + } else if (commandFamilyMatches(command, "set flood.filter.blacklist")) { + setFloodPacketFilterBlacklist( + command + strlen("set flood.filter.blacklist"), reply); + } else if (commandFamilyMatches(command, "del flood.filter.blacklist")) { + deleteFloodPacketFilterBlacklist( + command + strlen("del flood.filter.blacklist"), reply); } else if (commandFamilyMatches(command, "get flood.filter")) { formatFloodPacketFilters(command + strlen("get flood.filter"), reply); } else if (commandFamilyMatches(command, "set flood.filter")) { diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index a9f6de84..4a0fbcb8 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -127,6 +127,14 @@ struct NeighbourInfo { #endif #define FLOOD_PACKET_FILTER_ANY_TYPE 0xFF #define FLOOD_PACKET_FILTER_MAX_HOPS 63 +#define FLOOD_PACKET_FILTER_SCOPE_NAME_LEN 32 +#if defined(ESP32) + #define FLOOD_PACKET_FILTER_BLACKLIST_MAX 255 +#else + #define FLOOD_PACKET_FILTER_BLACKLIST_MAX 18 +#endif +#define FLOOD_PACKET_FILTER_BLACKLIST_REPLACE_MAX 18 +#define FLOOD_PACKET_FILTER_PATH_ID_SIZE 3 #ifndef FLOOD_CHANNEL_SCOPE_SLOTS #if defined(ESP32) @@ -140,6 +148,9 @@ struct NeighbourInfo { #define FLOOD_CHANNEL_SCOPE_TXT_ANY 0 #define FLOOD_CHANNEL_SCOPE_LOGIN_ANY 1 #define FLOOD_CHANNEL_SCOPE_OTHER_ANY 2 +#ifndef FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS + #define FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS FLOOD_CHANNEL_SCOPE_SLOTS +#endif #ifndef FLOOD_GROUP_MODERATION_SLOTS #define FLOOD_GROUP_MODERATION_SLOTS 16 @@ -230,6 +241,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks RegionMap region_map, temp_map; RegionEntry* load_stack[8]; RegionEntry* recv_pkt_region; + bool recv_pkt_filter_scope_set; + bool recv_pkt_channel_scope_bypass; + bool recv_pkt_channel_scope_rejected; TransportKey default_scope; RateLimiter discover_limiter, anon_limiter; struct FloodRetryBridgeState { @@ -259,6 +273,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks uint8_t min_hops; uint8_t max_hops; bool suspend_on_temp_radio; + char scope_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN]; + bool match_blacklisted_path; + bool scope_requires_region_match; + bool scope_uses_slow_timing; }; struct FloodChannelScopeEntry { uint16_t region_id; // zero means unused @@ -267,6 +285,11 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks uint8_t channel_hash; uint8_t secret[PUB_KEY_SIZE]; }; + struct FloodChannelScopeRequireEntry { + uint8_t key_len; // zero means unused + uint8_t channel_hash; + uint8_t secret[PUB_KEY_SIZE]; + }; struct FloodGroupModerationEntry { bool active; uint8_t key_len; @@ -295,7 +318,12 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks FloodRetryBridgeReachability flood_retry_bridge_reachability[FLOOD_RETRY_BRIDGE_BUCKETS + 1]; FloodChannelBlockEntry flood_channel_blocks[FLOOD_CHANNEL_BLOCK_SLOTS]; FloodPacketFilterEntry flood_packet_filters[FLOOD_PACKET_FILTER_SLOTS]; + uint8_t flood_packet_filter_blacklist_count; + uint8_t flood_packet_filter_blacklist[FLOOD_PACKET_FILTER_BLACKLIST_MAX] + [FLOOD_PACKET_FILTER_PATH_ID_SIZE]; FloodChannelScopeEntry flood_channel_scopes[FLOOD_CHANNEL_SCOPE_SLOTS]; + FloodChannelScopeRequireEntry + flood_channel_scope_requirements[FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS]; FloodGroupModerationEntry flood_group_moderation[FLOOD_GROUP_MODERATION_SLOTS]; ClockSyncSample clock_sync_samples[CLOCK_SYNC_SAMPLE_SLOTS]; bool clock_sync_mesh_enabled; @@ -489,22 +517,45 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks bool shouldBlockFloodChannelForward(const mesh::Packet* packet) const; void loadFloodPacketFilters(); bool saveFloodPacketFilters(); + void loadFloodPacketFilterBlacklist(); + bool saveFloodPacketFilterBlacklist(); void seedDefaultFloodPacketFilters(); + bool floodPacketFilterBlacklistMatches(const mesh::Packet* packet) const; + bool floodPacketFilterMatches(const FloodPacketFilterEntry& entry, + const mesh::Packet* packet) const; + bool applyFloodPacketFilterScope(mesh::Packet* packet, bool incoming_region_allowed, + bool& scope_set, bool& fast_track, + bool log_change = true); bool shouldBlockFloodPacketForward(const mesh::Packet* packet) const; void formatFloodPacketFilters(const char* args, char* reply) const; void formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len) const; void setFloodPacketFilter(const char* args, char* reply); void deleteFloodPacketFilter(const char* args, char* reply); + void formatFloodPacketFilterBlacklist(const char* args, char* reply) const; + void setFloodPacketFilterBlacklist(const char* args, char* reply); + void deleteFloodPacketFilterBlacklist(const char* args, char* reply); void loadFloodChannelScopes(); bool saveFloodChannelScopes(bool empty_table = false); - bool applyFloodChannelScopeTarget(mesh::Packet* packet, const FloodChannelScopeEntry& entry); - bool applyFloodChannelScope(mesh::Packet* packet); + bool applyFloodChannelScopeTarget(mesh::Packet* packet, const FloodChannelScopeEntry& entry, + bool& scope_changed, bool& fast_track, + bool log_change = true); + bool applyFloodChannelScope(mesh::Packet* packet, bool& fast_track, + bool log_change = true); static uint8_t scoreFloodTransportScope(const mesh::Packet* packet, void* context); uint8_t getFloodTransportScopeDepth(const mesh::Packet* packet); void formatFloodChannelScopes(const char* args, char* reply); void formatFloodChannelScopeDetail(int index, char* reply, size_t reply_len); void setFloodChannelScope(const char* args, char* reply); void deleteFloodChannelScope(const char* args, char* reply); + void loadFloodChannelScopeRequirements(); + bool saveFloodChannelScopeRequirements(bool empty_table = false); + bool findFloodChannelScopeRequirementMatch(const mesh::Packet* packet, + bool& table_active) const; + void formatFloodChannelScopeRequirements(const char* args, char* reply); + void formatFloodChannelScopeRequirementDetail(int index, char* reply, + size_t reply_len) const; + void setFloodChannelScopeRequirement(const char* args, char* reply); + void deleteFloodChannelScopeRequirement(const char* args, char* reply); void loadFloodGroupModeration(); bool saveFloodGroupModeration(); bool shouldBlockFloodGroupTextForward(const mesh::Packet* packet); @@ -554,6 +605,9 @@ protected: float getAirtimeBudgetFactor() const override { return _prefs.airtime_factor; } + bool getCADEnabled() const override { + return _prefs.cad_enabled; + } bool allowPacketForward(const mesh::Packet* packet) override; const char* getLogDateTime() override; @@ -563,8 +617,14 @@ protected: void logTx(mesh::Packet* pkt, int len) override; void logTxFail(mesh::Packet* pkt, int len) override; int calcRxDelay(float score, uint32_t air_time) const override; + int calcRxDelayForPacket(const mesh::Packet* packet, float score, + uint32_t air_time) override; + bool shouldBypassRxDelay(const mesh::Packet* packet) override; + bool evaluateScopeRewriteTiming(const mesh::Packet* packet, + bool& fast_track); uint32_t getRetransmitDelay(const mesh::Packet* packet) override; + uint32_t getSlowScopeRetransmitDelay(const mesh::Packet* packet); uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; bool supportsBasicRetryConfig() const override { return true; } bool supportsAdvancedRetryConfig() const override { return true; } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index d5ee0b88..eca26b06 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -220,12 +220,11 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { #if MESH_PACKET_LOGGING - if (Serial.availableForWrite() > 0) { - Serial.print(getLogDateTime()); - Serial.print(" RAW: "); - mesh::Utils::printHex(Serial, raw, len); - Serial.println(); - } + // Logging builds prefer backpressure over silently losing a packet record. + Serial.print(getLogDateTime()); + Serial.print(" RAW: "); + mesh::Utils::printHex(Serial, raw, len); + Serial.println(); #endif #ifdef WITH_MQTT_BRIDGE diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 5c741fb7..4adf0234 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -23,6 +23,18 @@ namespace mesh { #define RADIO_LIVENESS_HARD_MS (12UL * 60UL * 60UL * 1000UL) #endif +#define MIN_CAD_FAIL_RETRY_DELAY_MS 50UL +#define MIN_CAD_FAIL_MAX_DURATION_MS 500UL + +static uint32_t scaleCADDelayForQueue(uint32_t normal_delay, int ready_count, + uint32_t minimum_delay) { + if (ready_count <= 1) return normal_delay; + + uint32_t scaled = normal_delay / (uint32_t)ready_count; + uint32_t floor = normal_delay < minimum_delay ? normal_delay : minimum_delay; + return scaled < floor ? floor : scaled; +} + void Dispatcher::begin() { n_sent_flood = n_sent_direct = 0; n_recv_flood = n_recv_direct = 0; @@ -427,7 +439,7 @@ void Dispatcher::checkRecv() { if (pkt->isRouteFlood()) { n_recv_flood++; - int _delay = calcRxDelay(score, air_time); + int _delay = calcRxDelayForPacket(pkt, score, air_time); if (_delay < 50) { MESH_DEBUG_PRINTLN("%s Dispatcher::checkRecv(), score delay below threshold (%d)", getLogDateTime(), _delay); processRecvPacket(pkt); // is below the score delay threshold, so process immediately @@ -492,18 +504,24 @@ void Dispatcher::checkSend() { ? _radio->isReceivingPassive(getRetryInterferenceMargin()) : _radio->isReceiving(); if (channel_busy) { + const uint32_t cad_now = _ms->getMillis(); + const int ready_count = _mgr->getOutboundCount(cad_now); if (cad_busy_start == 0) { - cad_busy_start = _ms->getMillis(); // record when CAD busy state started + cad_busy_start = cad_now; // record when CAD busy state started } - if (_ms->getMillis() - cad_busy_start > getCADFailMaxDuration()) { + const uint32_t max_busy_duration = scaleCADDelayForQueue( + getCADFailMaxDuration(), ready_count, MIN_CAD_FAIL_MAX_DURATION_MS); + if (cad_now - cad_busy_start > max_busy_duration) { _err_flags |= ERR_EVENT_CAD_TIMEOUT; MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): CAD busy max duration reached!", getLogDateTime()); // channel activity has gone on too long... (Radio might be in a bad state) // force the pending transmit below... } else { - next_tx_time = futureMillis(getCADFailRetryDelay()); + const uint32_t retry_delay = scaleCADDelayForQueue( + getCADFailRetryDelay(), ready_count, MIN_CAD_FAIL_RETRY_DELAY_MS); + next_tx_time = futureMillis(retry_delay); return; } } diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 82bc339d..b46c93f5 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -293,6 +293,14 @@ protected: virtual float getAirtimeBudgetFactor() const; virtual int calcRxDelay(float score, uint32_t air_time) const; + virtual bool shouldBypassRxDelay(const Packet* packet) { + (void)packet; + return false; + } + virtual int calcRxDelayForPacket(const Packet* packet, float score, + uint32_t air_time) { + return shouldBypassRxDelay(packet) ? 0 : calcRxDelay(score, air_time); + } virtual uint32_t getCADFailRetryDelay() const; virtual uint32_t getCADFailMaxDuration() const; virtual uint8_t getDefaultTxCodingRate() const { return 0; } diff --git a/src/helpers/FloodFilterPolicy.h b/src/helpers/FloodFilterPolicy.h new file mode 100644 index 00000000..6c7e66a0 --- /dev/null +++ b/src/helpers/FloodFilterPolicy.h @@ -0,0 +1,118 @@ +#pragma once + +#include +#include +#include + +#include + +namespace FloodFilterPolicy { + +static constexpr uint8_t BLACKLIST_ID_SIZE = 3; +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; + +enum ChannelScopeGate { + CHANNEL_SCOPE_USE_GLOBAL, + CHANNEL_SCOPE_BYPASS, + CHANNEL_SCOPE_REQUIRED_ALLOWED, + CHANNEL_SCOPE_REQUIRED_REJECTED, +}; + +inline ChannelScopeGate channelScopeGate(bool table_active, + bool is_group_channel_packet, + bool channel_requires_scope, + bool incoming_is_scoped, + bool incoming_region_allowed) { + if (!table_active || !is_group_channel_packet) { + return CHANNEL_SCOPE_USE_GLOBAL; + } + if (!channel_requires_scope) return CHANNEL_SCOPE_BYPASS; + return incoming_is_scoped && incoming_region_allowed + ? CHANNEL_SCOPE_REQUIRED_ALLOWED + : 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 scopeSelectorValue(uint8_t stored_selector) { + return stored_selector & (uint8_t)~SLOW_SCOPE_FLAG; +} + +inline bool scopeUsesSlowTiming(uint8_t stored_selector) { + return (stored_selector & SLOW_SCOPE_FLAG) != 0; +} + +inline bool fastTrackScopeChange(bool scope_changed, bool slow) { + return scope_changed && !slow; +} + +inline float slowScopeRxDelayBase(float configured_base) { + float doubled = configured_base * 2.0f; + return doubled < SLOW_SCOPE_RX_DELAY_MIN + ? SLOW_SCOPE_RX_DELAY_MIN : doubled; +} + +inline uint32_t slowScopeMaxDelay(uint32_t airtime_millis) { + const uint64_t delay = (uint64_t)airtime_millis + * SLOW_SCOPE_TX_DELAY_FACTOR * 5U; + return delay > MAX_DISPATCH_DELAY + ? MAX_DISPATCH_DELAY : (uint32_t)delay; +} + +inline uint8_t blacklistMatchThreshold(uint8_t path_hash_size) { + if (path_hash_size == BLACKLIST_ID_SIZE) return 1; + if (path_hash_size == 2) return 2; + return 0; +} + +inline bool pathMatchesBlacklist(const mesh::Packet* packet, + const uint8_t* blacklist, + uint8_t blacklist_count) { + if (packet == NULL || blacklist == NULL || blacklist_count == 0) return false; + + const uint8_t hash_size = packet->getPathHashSize(); + const uint8_t required = blacklistMatchThreshold(hash_size); + if (required == 0) return false; + + uint8_t matches = 0; + const uint8_t path_hops = packet->getPathHashCount(); + for (uint8_t hop = 0; hop < path_hops; hop++) { + const uint8_t* path_id = &packet->path[hop * hash_size]; + for (uint8_t i = 0; i < blacklist_count; i++) { + const uint8_t* listed_id = &blacklist[i * BLACKLIST_ID_SIZE]; + if (memcmp(path_id, listed_id, hash_size) == 0) { + matches++; + if (matches >= required) return true; + break; + } + } + } + return false; +} + +inline bool scopeRuleAllowed(bool requires_region_match, + bool incoming_region_allowed) { + return !requires_region_match || incoming_region_allowed; +} + +inline bool setTransportScope(mesh::Packet* packet, uint16_t transport_code) { + if (packet == NULL || !packet->isRouteFlood()) return false; + + const bool changed = packet->getRouteType() != ROUTE_TYPE_TRANSPORT_FLOOD + || packet->transport_codes[0] != transport_code + || packet->transport_codes[1] != 0; + if (!changed) return false; + + packet->header = + (packet->header & (uint8_t)~PH_ROUTE_MASK) | ROUTE_TYPE_TRANSPORT_FLOOD; + packet->transport_codes[0] = transport_code; + packet->transport_codes[1] = 0; + return true; +} + +} // namespace FloodFilterPolicy diff --git a/test/README.md b/test/README.md index 1a33fadf..ec1ec11c 100644 --- a/test/README.md +++ b/test/README.md @@ -34,6 +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_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 new file mode 100644 index 00000000..e437f0c6 --- /dev/null +++ b/test/test_flood_filter_policy/test_flood_filter_policy.cpp @@ -0,0 +1,256 @@ +#include + +#include + +static mesh::Packet makeFloodPacket(uint8_t hash_size, + const uint8_t* path, + uint8_t path_hops) { + mesh::Packet packet; + packet.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT); + packet.setPathHashSizeAndCount(hash_size, path_hops); + memcpy(packet.path, path, hash_size * path_hops); + packet.payload_len = 0; + return packet; +} + +TEST(FloodFilterBlacklist, ThreeBytePathMatchesOneExactIdInAnyPosition) { + const uint8_t blacklist[][3] = { + {0x10, 0x20, 0x30}, + {0xAA, 0xBB, 0xCC}, + {0x70, 0x80, 0x90}, + }; + const uint8_t path[] = { + 0x01, 0x02, 0x03, + 0x70, 0x80, 0x90, + 0x04, 0x05, 0x06, + }; + mesh::Packet packet = makeFloodPacket(3, path, 3); + + EXPECT_TRUE(FloodFilterPolicy::pathMatchesBlacklist( + &packet, &blacklist[0][0], 3)); +} + +TEST(FloodFilterBlacklist, ThreeBytePathRequiresAnExactThirdByte) { + const uint8_t blacklist[][3] = {{0xAA, 0xBB, 0xCC}}; + const uint8_t path[] = {0xAA, 0xBB, 0xCD}; + mesh::Packet packet = makeFloodPacket(3, path, 1); + + EXPECT_FALSE(FloodFilterPolicy::pathMatchesBlacklist( + &packet, &blacklist[0][0], 1)); +} + +TEST(FloodFilterBlacklist, TwoBytePathRequiresTwoMatchingEntries) { + const uint8_t blacklist[][3] = { + {0xAA, 0xBB, 0x01}, + {0x11, 0x22, 0x02}, + }; + const uint8_t one_match_path[] = { + 0xAA, 0xBB, + 0x33, 0x44, + }; + const uint8_t two_match_path[] = { + 0x11, 0x22, + 0x33, 0x44, + 0xAA, 0xBB, + }; + mesh::Packet one_match = makeFloodPacket(2, one_match_path, 2); + mesh::Packet two_matches = makeFloodPacket(2, two_match_path, 3); + + EXPECT_FALSE(FloodFilterPolicy::pathMatchesBlacklist( + &one_match, &blacklist[0][0], 2)); + EXPECT_TRUE(FloodFilterPolicy::pathMatchesBlacklist( + &two_matches, &blacklist[0][0], 2)); +} + +TEST(FloodFilterBlacklist, OnePathEntryCountsOnceWhenIdsSharePrefix) { + const uint8_t blacklist[][3] = { + {0xAA, 0xBB, 0x01}, + {0xAA, 0xBB, 0x02}, + }; + const uint8_t path[] = {0xAA, 0xBB}; + mesh::Packet packet = makeFloodPacket(2, path, 1); + + EXPECT_FALSE(FloodFilterPolicy::pathMatchesBlacklist( + &packet, &blacklist[0][0], 2)); +} + +TEST(FloodFilterBlacklist, RepeatedTwoBytePathEntriesCountSeparately) { + const uint8_t blacklist[][3] = {{0xAA, 0xBB, 0x01}}; + const uint8_t path[] = { + 0xAA, 0xBB, + 0xAA, 0xBB, + }; + mesh::Packet packet = makeFloodPacket(2, path, 2); + + EXPECT_TRUE(FloodFilterPolicy::pathMatchesBlacklist( + &packet, &blacklist[0][0], 1)); +} + +TEST(FloodFilterBlacklist, OneBytePathsNeverMatch) { + const uint8_t blacklist[][3] = {{0xAA, 0xBB, 0xCC}}; + const uint8_t path[] = {0xAA, 0xAA, 0xAA}; + mesh::Packet packet = makeFloodPacket(1, path, 3); + + EXPECT_FALSE(FloodFilterPolicy::pathMatchesBlacklist( + &packet, &blacklist[0][0], 1)); +} + +TEST(FloodFilterBlacklist, Esp32MaximumListIncludesTheLastEntry) { + uint8_t blacklist[255][3]; + for (uint16_t i = 0; i < 255; i++) { + blacklist[i][0] = (uint8_t)i; + blacklist[i][1] = (uint8_t)(i ^ 0xA5); + blacklist[i][2] = (uint8_t)(i ^ 0x5A); + } + const uint8_t path[] = { + blacklist[254][0], + blacklist[254][1], + blacklist[254][2], + }; + mesh::Packet packet = makeFloodPacket(3, path, 1); + + EXPECT_TRUE(FloodFilterPolicy::pathMatchesBlacklist( + &packet, &blacklist[0][0], 255)); +} + +TEST(FloodFilterScope, RegionRequirementHasTheExpectedTruthTable) { + EXPECT_TRUE(FloodFilterPolicy::scopeRuleAllowed(false, false)); + EXPECT_TRUE(FloodFilterPolicy::scopeRuleAllowed(false, true)); + EXPECT_FALSE(FloodFilterPolicy::scopeRuleAllowed(true, false)); + EXPECT_TRUE(FloodFilterPolicy::scopeRuleAllowed(true, true)); +} + +TEST(FloodFilterScope, SlowTimingFlagRoundTripsWithoutChangingSelector) { + const uint8_t selector = 16; + const uint8_t stored = + FloodFilterPolicy::encodeScopeSelector(selector, true); + + EXPECT_EQ(selector, FloodFilterPolicy::scopeSelectorValue(stored)); + EXPECT_TRUE(FloodFilterPolicy::scopeUsesSlowTiming(stored)); + EXPECT_FALSE(FloodFilterPolicy::scopeUsesSlowTiming( + FloodFilterPolicy::encodeScopeSelector(selector, false))); +} + +TEST(FloodFilterScope, OnlyChangedFastRulesReceiveFastTrackTreatment) { + EXPECT_TRUE(FloodFilterPolicy::fastTrackScopeChange(true, false)); + EXPECT_FALSE(FloodFilterPolicy::fastTrackScopeChange(true, true)); + EXPECT_FALSE(FloodFilterPolicy::fastTrackScopeChange(false, false)); + EXPECT_FALSE(FloodFilterPolicy::fastTrackScopeChange(false, true)); +} + +TEST(FloodFilterScope, SlowTimingUsesMaximumTxDelayFactor) { + EXPECT_EQ(2, FloodFilterPolicy::SLOW_SCOPE_TX_DELAY_FACTOR); + EXPECT_EQ(1000U, FloodFilterPolicy::slowScopeMaxDelay(100)); + EXPECT_EQ(FloodFilterPolicy::MAX_DISPATCH_DELAY, + FloodFilterPolicy::slowScopeMaxDelay(0xFFFFFFFFU)); +} + +TEST(FloodFilterScope, SlowTimingDoublesRxDelayBaseWithMinimumOfTwo) { + EXPECT_FLOAT_EQ(2.0f, FloodFilterPolicy::slowScopeRxDelayBase(0.0f)); + EXPECT_FLOAT_EQ(2.0f, FloodFilterPolicy::slowScopeRxDelayBase(0.5f)); + EXPECT_FLOAT_EQ(2.0f, FloodFilterPolicy::slowScopeRxDelayBase(1.0f)); + EXPECT_FLOAT_EQ(4.0f, FloodFilterPolicy::slowScopeRxDelayBase(2.0f)); + EXPECT_FLOAT_EQ(20.0f, FloodFilterPolicy::slowScopeRxDelayBase(10.0f)); + EXPECT_FLOAT_EQ(24.0f, FloodFilterPolicy::slowScopeRxDelayBase(12.0f)); + EXPECT_FLOAT_EQ(40.0f, FloodFilterPolicy::slowScopeRxDelayBase(20.0f)); +} + +TEST(FloodFilterScope, ChannelRequirementTableKeepsLegacyGlobalBehaviorWhenEmpty) { + EXPECT_EQ( + FloodFilterPolicy::CHANNEL_SCOPE_USE_GLOBAL, + FloodFilterPolicy::channelScopeGate(false, true, true, false, false)); +} + +TEST(FloodFilterScope, ChannelRequirementTableDoesNotChangeOtherPayloadTypes) { + EXPECT_EQ( + FloodFilterPolicy::CHANNEL_SCOPE_USE_GLOBAL, + FloodFilterPolicy::channelScopeGate(true, false, false, false, false)); +} + +TEST(FloodFilterScope, UnlistedGroupChannelsBypassOnlyTheScopeGate) { + EXPECT_EQ( + FloodFilterPolicy::CHANNEL_SCOPE_BYPASS, + FloodFilterPolicy::channelScopeGate(true, true, false, false, false)); + EXPECT_EQ( + FloodFilterPolicy::CHANNEL_SCOPE_BYPASS, + FloodFilterPolicy::channelScopeGate(true, true, false, true, false)); +} + +TEST(FloodFilterScope, ListedChannelsRequireAnAllowedIncomingScope) { + EXPECT_EQ( + FloodFilterPolicy::CHANNEL_SCOPE_REQUIRED_REJECTED, + FloodFilterPolicy::channelScopeGate(true, true, true, false, true)); + EXPECT_EQ( + FloodFilterPolicy::CHANNEL_SCOPE_REQUIRED_REJECTED, + FloodFilterPolicy::channelScopeGate(true, true, true, true, false)); + EXPECT_EQ( + FloodFilterPolicy::CHANNEL_SCOPE_REQUIRED_ALLOWED, + FloodFilterPolicy::channelScopeGate(true, true, true, true, true)); +} + +TEST(FloodFilterScope, AddsScopeToUnscopedFloodWithoutChangingPayloadType) { + mesh::Packet packet; + packet.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT); + packet.transport_codes[0] = 0; + packet.transport_codes[1] = 0; + + EXPECT_TRUE(FloodFilterPolicy::setTransportScope(&packet, 0x1234)); + EXPECT_EQ(ROUTE_TYPE_TRANSPORT_FLOOD, packet.getRouteType()); + EXPECT_EQ(PAYLOAD_TYPE_GRP_TXT, packet.getPayloadType()); + EXPECT_EQ(0x1234, packet.transport_codes[0]); + EXPECT_EQ(0, packet.transport_codes[1]); +} + +TEST(FloodFilterScope, ReplacesExistingScopeAndClearsSecondaryCode) { + mesh::Packet packet; + packet.header = + ROUTE_TYPE_TRANSPORT_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT); + packet.transport_codes[0] = 0x1111; + packet.transport_codes[1] = 0x2222; + + EXPECT_TRUE(FloodFilterPolicy::setTransportScope(&packet, 0x3333)); + EXPECT_EQ(0x3333, packet.transport_codes[0]); + EXPECT_EQ(0, packet.transport_codes[1]); +} + +TEST(FloodFilterScope, ExistingExactScopeIsANoOp) { + mesh::Packet packet; + packet.header = + ROUTE_TYPE_TRANSPORT_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT); + packet.transport_codes[0] = 0x3333; + packet.transport_codes[1] = 0; + + EXPECT_FALSE(FloodFilterPolicy::setTransportScope(&packet, 0x3333)); + EXPECT_EQ(0x3333, packet.transport_codes[0]); + EXPECT_EQ(0, packet.transport_codes[1]); +} + +TEST(FloodFilterScope, MatchingPrimaryStillChangesWhenSecondaryIsPresent) { + mesh::Packet packet; + packet.header = + ROUTE_TYPE_TRANSPORT_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT); + packet.transport_codes[0] = 0x3333; + packet.transport_codes[1] = 0x4444; + + EXPECT_TRUE(FloodFilterPolicy::setTransportScope(&packet, 0x3333)); + EXPECT_EQ(0x3333, packet.transport_codes[0]); + EXPECT_EQ(0, packet.transport_codes[1]); +} + +TEST(FloodFilterScope, DirectPacketsAreNeverConvertedToFlood) { + mesh::Packet packet; + packet.header = + ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT); + packet.transport_codes[0] = 0x1111; + packet.transport_codes[1] = 0x2222; + + EXPECT_FALSE(FloodFilterPolicy::setTransportScope(&packet, 0x3333)); + EXPECT_EQ(ROUTE_TYPE_DIRECT, packet.getRouteType()); + EXPECT_EQ(0x1111, packet.transport_codes[0]); + EXPECT_EQ(0x2222, packet.transport_codes[1]); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_packet_manager/test_rx_reserve_packet_manager.cpp b/test/test_packet_manager/test_rx_reserve_packet_manager.cpp index cbb09d2f..491690fa 100644 --- a/test/test_packet_manager/test_rx_reserve_packet_manager.cpp +++ b/test/test_packet_manager/test_rx_reserve_packet_manager.cpp @@ -9,15 +9,25 @@ public: class TestRadio : public mesh::Radio { public: + uint8_t pending_rx[MAX_TRANS_UNIT]; + int pending_rx_len = 0; int send_starts = 0; bool receiving = false; bool in_recv_mode = true; int soft_recoveries = 0; int hard_recoveries = 0; + int cad_set_calls = 0; + bool cad_enabled = false; unsigned long last_irq = 0; bool recovery_result = true; - int recvRaw(uint8_t*, int) override { return 0; } + int recvRaw(uint8_t* dest, int max_len) override { + if (pending_rx_len == 0) return 0; + int len = pending_rx_len < max_len ? pending_rx_len : max_len; + memcpy(dest, pending_rx, len); + pending_rx_len = 0; + return len; + } uint32_t getEstAirtimeFor(int) override { return 1; } float packetScore(float, int) override { return 0; } bool startSendRaw(const uint8_t*, int) override { send_starts++; return true; } @@ -25,6 +35,10 @@ public: void onSendFinished() override { } bool isInRecvMode() const override { return in_recv_mode; } bool isReceiving() override { return receiving; } + void setCADEnabled(bool enable) override { + cad_set_calls++; + cad_enabled = enable; + } unsigned long getLastRadioInterruptMillis() const override { return last_irq; } bool recoverRadio(bool hard) override { if (hard) { @@ -34,13 +48,35 @@ public: } return recovery_result; } + + void queueRx(const uint8_t* raw, int len) { + ASSERT_NE(raw, nullptr); + ASSERT_GT(len, 0); + ASSERT_LE(len, MAX_TRANS_UNIT); + memcpy(pending_rx, raw, len); + pending_rx_len = len; + } }; class TestDispatcher : public mesh::Dispatcher { RxReservePacketManager& manager; protected: - mesh::DispatcherAction onRecvPacket(mesh::Packet*) override { return ACTION_RELEASE; } + mesh::DispatcherAction onRecvPacket(mesh::Packet*) override { + received_packets++; + return ACTION_RELEASE; + } + int calcRxDelay(float score, uint32_t air_time) const override { + return forced_rx_delay >= 0 + ? forced_rx_delay + : mesh::Dispatcher::calcRxDelay(score, air_time); + } + bool shouldBypassRxDelay(const mesh::Packet*) override { + return bypass_rx_delay; + } + bool getCADEnabled() const override { + return configured_cad_enabled; + } void onSendFail(mesh::Packet* packet) override { failed_packet = packet; free_count_during_failure = manager.getFreeCount(); @@ -49,6 +85,10 @@ protected: public: mesh::Packet* failed_packet = nullptr; int free_count_during_failure = -1; + int received_packets = 0; + int forced_rx_delay = -1; + bool bypass_rx_delay = false; + bool configured_cad_enabled = false; TestDispatcher(TestRadio& radio, TestClock& clock, RxReservePacketManager& mgr) : mesh::Dispatcher(radio, clock, mgr), manager(mgr) { } @@ -124,6 +164,71 @@ TEST(Dispatcher, ParserAcceptsACompletePacketWithEmptyPayload) { EXPECT_EQ(0U, packet.payload_len); } +TEST(Dispatcher, FloodPacketWaitsForConfiguredRxDelayWithoutBypass) { + RxReservePacketManager manager(4, 1); + TestClock clock; + TestRadio radio; + TestDispatcher dispatcher(radio, clock, manager); + dispatcher.forced_rx_delay = 1000; + dispatcher.begin(); + const uint8_t raw[] = { + ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT), 0, 0x42 + }; + + clock.now = 100; + radio.queueRx(raw, sizeof(raw)); + dispatcher.loop(); + EXPECT_EQ(0, dispatcher.received_packets); + + clock.now = 1099; + dispatcher.loop(); + EXPECT_EQ(0, dispatcher.received_packets); + + clock.now = 1100; + dispatcher.loop(); + EXPECT_EQ(1, dispatcher.received_packets); +} + +TEST(Dispatcher, ScopeRewriteHookBypassesConfiguredRxDelay) { + RxReservePacketManager manager(4, 1); + TestClock clock; + TestRadio radio; + TestDispatcher dispatcher(radio, clock, manager); + dispatcher.forced_rx_delay = 1000; + dispatcher.bypass_rx_delay = true; + dispatcher.begin(); + const uint8_t raw[] = { + ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT), 0, 0x42 + }; + + clock.now = 100; + radio.queueRx(raw, sizeof(raw)); + dispatcher.loop(); + + EXPECT_EQ(1, dispatcher.received_packets); + EXPECT_EQ(4, manager.getFreeCount()); +} + +TEST(Dispatcher, ConfiguredCadStateIsPropagatedToTheRadio) { + RxReservePacketManager manager(4, 1); + TestClock clock; + TestRadio radio; + TestDispatcher dispatcher(radio, clock, manager); + dispatcher.configured_cad_enabled = true; + dispatcher.begin(); + + clock.now = 1; + dispatcher.loop(); + EXPECT_EQ(1, radio.cad_set_calls); + EXPECT_TRUE(radio.cad_enabled); + + dispatcher.configured_cad_enabled = false; + clock.now = 2002; + dispatcher.loop(); + EXPECT_EQ(2, radio.cad_set_calls); + EXPECT_FALSE(radio.cad_enabled); +} + TEST(StaticPoolPacketManager, ReportsEarliestQueueTimesWithoutDequeuing) { StaticPoolPacketManager manager(8); mesh::Packet* later = manager.allocNew(); @@ -366,7 +471,7 @@ TEST(StaticPoolPacketManager, RxDelayEqualPathsPreferNarrowerLocalScope) { manager.free(manager.getNextInbound(200)); } -TEST(Dispatcher, QueueWakeDelayIncludesSchedulesAndChannelBackoff) { +TEST(Dispatcher, QueueWakeDelayIncludesSchedulesAndAdaptiveChannelBackoff) { RxReservePacketManager manager(8, 4); TestClock clock; clock.now = 100; @@ -393,11 +498,62 @@ TEST(Dispatcher, QueueWakeDelayIncludesSchedulesAndChannelBackoff) { EXPECT_EQ(200U, delay_millis); EXPECT_FALSE(dispatcher.queuedWorkDue()); - clock.now = 800; + mesh::Packet* second = dispatcher.obtainNewPacket(); + ASSERT_NE(second, nullptr); + second->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT); + second->payload[0] = 0x43; + second->payload_len = 1; + ASSERT_TRUE(dispatcher.sendPacket(second, 0)); + + clock.now = 801; + dispatcher.loop(); + ASSERT_TRUE(dispatcher.nextQueueWakeDelay(delay_millis)); + EXPECT_EQ(100U, delay_millis); + EXPECT_FALSE(dispatcher.queuedWorkDue()); + + clock.now = 901; radio.receiving = false; EXPECT_TRUE(dispatcher.queuedWorkDue()); } +TEST(Dispatcher, GrowingQueueShortensCADBusyCeiling) { + RxReservePacketManager manager(8, 1); + TestClock clock; + clock.now = 100; + TestRadio radio; + radio.receiving = true; + TestDispatcher dispatcher(radio, clock, manager); + dispatcher.begin(); + + mesh::Packet* first = dispatcher.obtainNewPacket(); + ASSERT_NE(first, nullptr); + first->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT); + first->payload[0] = 0x40; + first->payload_len = 1; + ASSERT_TRUE(dispatcher.sendPacket(first, 0)); + clock.now = 101; + dispatcher.loop(); + EXPECT_EQ(0, radio.send_starts); + + for (uint8_t i = 1; i < 4; i++) { + mesh::Packet* packet = dispatcher.obtainNewPacket(); + ASSERT_NE(packet, nullptr); + packet->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT); + packet->payload[0] = (uint8_t)(0x40 + i); + packet->payload_len = 1; + ASSERT_TRUE(dispatcher.sendPacket(packet, 0)); + } + EXPECT_EQ(4, manager.getOutboundCount(clock.now)); + + // Four ready packets reduce the normal four-second CAD ceiling to one + // second, measured from the original busy observation. + clock.now = 1102; + dispatcher.loop(); + EXPECT_EQ(1, radio.send_starts); + EXPECT_EQ(3, manager.getOutboundCount(clock.now)); + EXPECT_TRUE(dispatcher.getErrFlags() & ERR_EVENT_CAD_TIMEOUT); +} + TEST(Dispatcher, SilentRadioEscalatesFromSoftToHardRecovery) { RxReservePacketManager manager(8, 4); TestClock clock;