diff --git a/build.sh b/build.sh index 2d1e0b24..ba8a82b9 100755 --- a/build.sh +++ b/build.sh @@ -1907,7 +1907,7 @@ apply_esp32_full_size_profile() { # The FULL artifact uses expanded dual-OTA slots, so restore features that # target or portable profiles disabled only to save application space. append_platformio_build_unflags "-DWEBCONFIG_DISABLED=1" - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UWEBCONFIG_DISABLED -DWIFI_OTA_SEEDER=1" + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UWEBCONFIG_DISABLED -DWIFI_OTA_SEEDER=1 -DMESHCORE_ESP32_FULL_PROFILE=1" # Keep ordinary builds at their board-defined neighbor capacity. FULL builds # normally use the largest table supported by the one-byte neighbor discovery diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index d0e4c95a..4a84f9de 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -117,10 +117,9 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without | Routing | [`get/set flood.max.unscoped`](cli_commands.md#limit-the-number-of-hops-for-an-unscoped-flood-message) | Repeater | Yes | Yes | Yes | | Routing | [`get/set flood.max.advert`](cli_commands.md#limit-the-number-of-hops-for-an-advert-flood-message) | Repeater | Yes | Yes | Yes | | Routing | [`get/set flood.channel.data`; `get/set flood.channel.data.hops`](cli_commands.md#forward-flood-group-data-packets-on-repeaters) | Repeater | Yes | Yes | Yes | -| Routing | [`get/set/del flood.channel.block*`](cli_commands.md#block-selected-flood-channel-packets-on-repeaters) | Repeater | Yes | Yes | Yes | | Routing | [`get/set/del flood.channel.scope*`](cli_commands.md#force-a-transport-scope-onto-floods) | Repeater | Yes | Yes | Yes | | Routing | [`get/set/del flood.channel.scope.require*`](cli_commands.md#require-valid-incoming-scopes-only-on-selected-channels) | Repeater | Yes | Yes | Yes | -| Routing | [`get/set/del flood.filter*`; `get/set/del flood.filter.blacklist*`](cli_commands.md#filter-flood-packets-by-payload-type-hop-count-and-path) | Repeater | Yes | Yes | Yes | +| Routing | [`get/set/del flood.rule*`; `get/set/del flood.filter*`; `get/set/del flood.filter.blacklist*`](cli_commands.md#change-persistent-flood-rules-in-the-field) | Repeater; `flood.rule`/`flood.filter` also on FULL ESP32 room server (no blacklist) | Yes | Yes | Yes | | Routing | [`get/set/del flood.moderation*`](cli_commands.md#moderate-flood-group-text-by-channel-sender-and-source-path) | Repeater | Yes | Yes | Yes | | Routing | [`get/set outpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | | Routing | [`get/set altpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | @@ -273,10 +272,9 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without | Routing | [`get/set flood.max.unscoped`](cli_commands.md#limit-the-number-of-hops-for-an-unscoped-flood-message) | Repeater, full common parser | Yes | Yes | Yes | No | No | Yes | Yes | | Routing | [`get/set flood.max.advert`](cli_commands.md#limit-the-number-of-hops-for-an-advert-flood-message) | Repeater, full common parser | Yes | Yes | Yes | No | No | Yes | Yes | | Routing | [`get/set flood.channel.data`; `get/set flood.channel.data.hops`](cli_commands.md#forward-flood-group-data-packets-on-repeaters) | Repeater, full common parser | Yes | Yes | Yes | No | No | Yes | Yes | -| Routing | [`get/set/del flood.channel.block*`](cli_commands.md#block-selected-flood-channel-packets-on-repeaters) | Repeater, full common parser | Yes | Yes | Yes | No | No | Yes | Yes | | Routing | [`get/set/del flood.channel.scope*`](cli_commands.md#force-a-transport-scope-onto-floods) | Repeater role handler | Yes | Yes | Yes | No | Yes | Yes | Yes | | Routing | [`get/set/del flood.channel.scope.require*`](cli_commands.md#require-valid-incoming-scopes-only-on-selected-channels) | Repeater role handler | Yes | Yes | Yes | No | Yes | Yes | Yes | -| Routing | [`get/set/del flood.filter*`; `get/set/del flood.filter.blacklist*`](cli_commands.md#filter-flood-packets-by-payload-type-hop-count-and-path) | Repeater role handler | Yes | Yes | Yes | No | Yes | Yes | Yes | +| Routing | [`get/set/del flood.rule*`; `get/set/del flood.filter*`; `get/set/del flood.filter.blacklist*`](cli_commands.md#change-persistent-flood-rules-in-the-field) | Repeater role handler; `flood.rule`/`flood.filter` also on FULL ESP32 room server (no blacklist) | Yes | Yes | Yes | No | Yes | Yes | Yes | | Routing | [`get/set/del flood.moderation*`](cli_commands.md#moderate-flood-group-text-by-channel-sender-and-source-path) | Repeater role handler | Yes | Yes | Yes | No | Yes | Yes | Yes | | Routing | [`get/set outpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | Yes | Yes | Yes | Yes | | Routing | [`get/set altpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | Yes | Yes | Yes | Yes | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 3014631d..a9ff9206 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -25,7 +25,7 @@ arguments such as node names, passwords, and keys is left unchanged. - [System](#system) - [GPIO](#control-an-exposed-gpio) - [Routing](#routing) - - [Flood Filtering](#filter-flood-packets-by-payload-type-and-hop) + - [Flood Rules](#change-persistent-flood-rules-in-the-field) - [Group Text Moderation](#moderate-flood-group-text-by-channel-sender-and-source-path) - [ACL](#acl) - [Region Management](#region-management-v110) @@ -1016,10 +1016,10 @@ get clock.sync.status **Usage:** -- `get gpio` — list the Arduino pin numbers this firmware build permits -- `get gpio state`, `get gpio states`, or `get gpio status` — list every available pin currently controlled by the user (anything not in `reset`) -- `get gpio state ` — show one pin's state; `states` and `status` are accepted here too -- `get gpio ` — show `on`, `off`, or `reset`, plus any pending timed transition +- `get gpio` - list the Arduino pin numbers this firmware build permits +- `get gpio state`, `get gpio states`, or `get gpio status` - list every available pin currently controlled by the user (anything not in `reset`) +- `get gpio state ` - show one pin's state; `states` and `status` are accepted here too +- `get gpio ` - show `on`, `off`, or `reset`, plus any pending timed transition - `set gpio on` - `set gpio off` - `set gpio reset` @@ -1027,9 +1027,9 @@ get clock.sync.status **Examples:** -- `set gpio 16 on 30 off` — drive GPIO16 high for 30 seconds, then drive it low -- `set gpio 16 on 5ms off` — drive GPIO16 high for 5 milliseconds, then drive it low -- `set gpio 16 off 5 reset` — drive GPIO16 low for 5 seconds, then return it to high impedance +- `set gpio 16 on 30 off` - drive GPIO16 high for 30 seconds, then drive it low +- `set gpio 16 on 5ms off` - drive GPIO16 high for 5 milliseconds, then drive it low +- `set gpio 16 off 5 reset` - drive GPIO16 low for 5 seconds, then return it to high impedance An integer duration has seconds as its default unit, so `5` means 5 seconds. Add `ms` for milliseconds (`5ms`); an explicit `s` suffix is also accepted (`5s`). The maximum duration is 24 hours (86,400 seconds or 86,400,000 milliseconds). `on` or `off` without a duration remains in that state until another command, reset, or reboot. @@ -1334,108 +1334,36 @@ reduce false recoveries when no traffic is expected. **Forwarding behavior:** Repeater firmware only. The repeater still receives and logs the packet when logging is enabled; this only blocks retransmission. -This is checked before `flood.channel.block` and applies to flood `GRP_DATA` -packets regardless of channel key. Flood group text (`GRP_TXT`) is unaffected by -this setting. - -`flood.channel.data.hops` is separate from `flood.channel.block.hops`. -`flood.channel.block.hops` does not restrict unkeyed `GRP_DATA` packets. With -the default `flood.channel.data on`, `GRP_DATA` repeats normally even when -`flood.channel.block.hops` is set for keyed channel blocks. +This is checked before FPF7 and applies to flood `GRP_DATA` packets regardless +of channel key. Flood group text (`GRP_TXT`) is unaffected by this setting. `get flood.channel.data` includes the active hop gate as `h=all` or `h>N`. --- -#### Block selected flood channel packets on repeaters -**Usage:** -- `get flood.channel.block` -- `get flood.channel.block.` -- `get flood.channel.block ` -- `get flood.channel.block.hops` -- `set flood.channel.block [h=]` -- `set flood.channel.block. [h=]` -- `set flood.channel.block #channel [h=]` -- `set flood.channel.block. #channel [h=]` -- `set flood.channel.block.hops ` -- `del flood.channel.block.` -- `del flood.channel.block ` +#### Block selected flood channels with FPF7 -**Parameters:** -- `n`: Slot number from `1` to `15`. -- `key`: 128-bit or 256-bit channel key as hex. -- `#channel`: Public hashtag channel name; derives the 128-bit channel key from the hashtag and is stored as the row name. -- `name`: Local label for hex-key rows. Not needed for `#channel`; extra text after `#channel` is ignored unless it is a hop setting. -- `8_hex_prefix`: First 4 bytes of the derived channel hash, shown by single-entry `get`. -- `all`: Block matching flood channel packets at any received flood hop count. -- `1-7`: Maximum received flood path hash count to repeat. Matching packets over this hop count are blocked. -- `default`: Row inherits the global `flood.channel.block.hops` setting. - -**Slot behavior:** Without `.n`, `set flood.channel.block` updates an existing -row with the same derived channel prefix or name, otherwise it uses the next -empty slot. If all 15 slots are full, the command fails. With `.n`, the command -writes that slot. - -**Default row:** Repeater firmware seeds a new block list with -`#wardriving h=4` in slot 1. This is a normal row, so it can be changed with -`set flood.channel.block #wardriving h=` or removed with -`del flood.channel.block #wardriving`. Once the block list has been saved, the -firmware uses the saved list and does not recreate the default after deletion. -Restore the exact seeded row with: +The separate `flood.channel.block` command and 15-row table have been retired. +Generalized repeaters use the 31-row FPF7 table for authenticated channel +blocks: ```text -set flood.channel.block.1 #wardriving h=4 +set flood.rule type=any channel=#test hops=all drop +set flood.rule.2 type=any channel=#wardriving hops=5+ drop +set flood.rule type=any channel=9cd8fcf22a47333b591d96a2b848b73f hops=4+ drop +get flood.rule +del flood.rule.2 ``` -This explicitly writes slot 1. Omit `.1` to update an existing wardriving row -or use the first empty slot instead. +`type=any` with a channel condition can authenticate only `GRP_TXT` and +`GRP_DATA`, so it does not match other payload types. Use `hops=all` to block +at every received hop count. To preserve the old `h=N` meaning of repeating +through `N` hops and blocking longer paths, use `hops=N+1+`; old `h=4` is +therefore `hops=5+`. -**Forwarding behavior:** Repeater firmware only. This only affects received -flood `GRP_TXT` and `GRP_DATA` channel packets. The repeater still receives and -logs the packet, but it does not retransmit it when a configured block entry can -validate/decode it. If `flood.channel.data` is `off`, `GRP_DATA` packets are -checked against the separate `flood.channel.data.hops` gate before this -per-channel check runs. - -**Hop gate:** `flood.channel.block.hops` defaults to `all`, which preserves the -original behavior. When set to `N` from `1` to `7`, block rows that inherit the -global setting only block packets whose received flood path hash count is -greater than `N`; packets at `N` hops or lower can still repeat. For example, -`set flood.channel.block.hops 1` repeats zero-hop and one-hop matches but blocks -two-hop and longer matches. - -Each block row can override the global hop gate with `h=`. -For example, the seeded `#wardriving h=4` row blocks `#wardriving` matches -above four hops, while `set flood.channel.block #bot h=7` blocks -`#bot` matches above seven hops. Use `h=default` to make the row inherit the -global setting again. - -`get flood.channel.block` includes the global default first, then adds per-row -overrides as `/h>N` or `/h=all`; inherited rows do not show a suffix. Single-row -`get` replies include that row's stored hop mode as `h=def`, `h=all`, or `h>N`. -List replies truncate displayed row names only when the full list would exceed -the remote-management response limit. - -**Matching behavior:** Each block entry stores the first 4 bytes of the derived -channel hash for display and lookup. Current group packets carry only the first -channel-hash byte, so that byte is used as a cheap prefilter. Only entries whose -first hash byte matches the packet try MAC/decrypt with their stored key. If -multiple blocked channels share the same first byte, the repeater tries each -matching key until one validates; the packet is blocked only after a successful -MAC/decrypt. - -**Examples:** -``` -set flood.channel.block #test -set flood.channel.block.2 9cd8fcf22a47333b591d96a2b848b73f #test -set flood.channel.block.hops 3 -set flood.channel.block #wardriving h=4 -set flood.channel.block #bot h=7 -get flood.channel.block -get flood.channel.block.hops -get flood.channel.block #test -del flood.channel.block.2 -``` +New generalized repeater tables seed slot 2 with the second example. Existing +FCB2 rows are imported once into free FPF7 slots and the retired file is then +removed. The fixed-size STM32WL FPF6 build cannot match authenticated channels. --- @@ -1443,8 +1371,8 @@ del flood.channel.block.2 **Usage:** - `get flood.channel.scope` - `get flood.channel.scope.` -- `set flood.channel.scope [path=blacklist|path=bucket:1-6] [tx=slow]` -- `set flood.channel.scope. [path=blacklist|path=bucket:1-6] [tx=slow]` +- `set flood.channel.scope [path=blacklist|path=bucket:1-6] [tx=slow]` +- `set flood.channel.scope. [path=blacklist|path=bucket:1-6] [tx=slow]` - `del flood.channel.scope.` - `del flood.channel.scope all` @@ -1460,11 +1388,16 @@ del flood.channel.block.2 `RESPONSE`, `TXT_MSG`, `ANON_REQ`, and `PATH` (`0x00`, `0x01`, `0x02`, `0x07`, and `0x08`). It classifies the outer type; a transit repeater cannot authenticate whether a packet is actually part of a login session. -- `other:*`: Type-based wildcard for every remaining flood payload type except - TRACE, including ACK, advert, multipart, control, OTA, reserved types, and - raw custom. TRACE is deliberately exempt from forced-scope wildcards. +- `other:*`: Type-based wildcard for every remaining flood payload type, + including flood-form TRACE, ACK, advert, multipart, control, OTA, reserved + types, and raw custom. - `region`: Existing named region with a usable transport key. A unique region name prefix is accepted; wildcard region `*` is not a scope target. +- `scope=`: Regionless alternative to `region`. The public name is + normalized with a leading `#`, and its 128-bit transport key is derived + directly from that hashtag exactly as for `flood.filter scope=`. It + does not need to exist in the region list. Public names up to 30 characters + are accepted; private `$` scopes are not. - `path=blacklist`: Optional. Require the received path to match the passive `flood.filter.blacklist` ID table. No `flood.filter` drop row needs to be enabled. One exact listed ID qualifies a 3-byte path. A 2-byte path requires @@ -1494,15 +1427,16 @@ rows for the same channel. With `.n`, it replaces that slot. The three wildcard classes are independent and consume one slot each. `get flood.channel.scope` reports active/total slot counts; use the numbered form for row detail. Keyed rows are displayed by the first four bytes of their -derived channel hash because channel secrets are never returned. +derived channel hash because channel secrets are never returned. Regionless +targets are displayed with their normalized leading `#`. 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. Matching path-qualified exact rows are -tried before ordinary exact fallback rows. A row whose target region is -missing or unusable is skipped; later exact rows and then `txt:*` are tried. + tried before ordinary exact fallback rows. A region-backed row whose target + is missing or unusable is skipped; later exact rows and then `txt:*` are tried. Exact keyed rows with a usable target therefore beat `txt:*` regardless of slot number. Within each wildcard class, path-qualified rows similarly precede ordinary fallback rows. `login:*` and `other:*` select their non-overlapping @@ -1510,19 +1444,18 @@ outer-type families without decrypting the payload. The lowest usable slot wins within each priority tier. Standard traceroute is direct-routed and is therefore outside this flood-only -table. A custom flood-form `TRACE` is also left unchanged: no wildcard adds a -scope, an existing transport code is preserved, and region/unknown-code gates -do not block it. +table. A custom flood-form `TRACE` is treated like every other flood: an +applicable wildcard may rewrite it and region/unknown-code gates still apply. 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 +computes transport code 0 from the selected region or direct hashtag 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`, `flood.filter`, +loop detection, and moderation. Assigning a scope does 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 @@ -1536,29 +1469,39 @@ 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 +A region-backed target must be locally flood-allowed and remains subject to +the normal region gate. A `scope=` target is trusted for this matched +receive pass even though it has no region-list entry, matching the behavior of +`flood.filter scope=`. It does not create a region, consume a region +slot, or change the allow/deny state for unrelated packets carrying the same +transport code. + +If a region-backed row's target 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. 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, replacing an existing code when necessary, +given that target'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. +window. A region target must allow flooding; a direct target follows the +regionless trust behavior above. 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 -a 149-byte file; it has room for the three wildcard classes plus one exact -channel mapping. Very-tight 15-slot builds use 540 bytes RAM and a 545-byte -file. The 31-slot table uses 1,116 bytes RAM and a 1,121-byte file. Roomy ESP32 -builds use a 255-slot table: 9,180 bytes RAM and a 9,185-byte file. Classic -ESP32 LoRa-OTA builds that cannot afford that RAM use 31 slots instead. The -existing region table still permits 32 named regions, so a 255-slot channel -table may reuse targets but cannot name more than 32 distinct configured region -scopes. +**Capacity cost:** Each rule slot retains its 36-byte runtime and persistent +record. A separate 32-byte-name table holds up to the smaller of the rule count +or 32 distinct regionless targets; very-tight STM32WL builds hold one reusable +direct target. Region-backed targets do not consume this table. +The four-slot minimum uses 272 bytes RAM and a 278-byte file; it has room for +the three wildcard classes plus one exact channel mapping. Very-tight 15-slot +builds use 572 bytes RAM and a 578-byte file. The 31-slot table uses 2,108 +bytes RAM and a 2,114-byte file. Roomy ESP32 builds use 255 rule slots and 32 +direct-target slots: 10,204 bytes RAM and a 10,210-byte file. Classic ESP32 +LoRa-OTA builds that cannot afford the 255-rule table use 31 slots instead. +Both configured regions and regionless targets can be reused by any number of +rules. **Duplicate behavior:** Mesh dedup hashes payload type and payload bytes; it does not hash route type, transport codes, or the ordinary flood path. Adding @@ -1579,9 +1522,9 @@ already-scoped winner may have its code replaced by the better queued scope. The comparison is deferred until dequeue so each copy retains its original scope and path for arbitration. It can only use copies still present in -`rxdelay`; it cannot replace a packet that already won the dedupe race. TRACE -is excluded from scope arbitration entirely, so rxdelay never adds or replaces -a trace transport code. +`rxdelay`; it cannot replace a packet that already won the dedupe race. +Flood-form TRACE participates in this arbitration; direct traceroute does not +enter the flood queue. 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 @@ -1601,6 +1544,19 @@ get flood.channel.scope.1 del flood.channel.scope.2 ``` +A regionless exact mapping needs no `region` command: + +```text +set flood.channel.scope #rgdata scope=BlackHole86 +get flood.channel.scope +get flood.channel.scope.1 +``` + +For example, if an authenticated `#rgdata` packet arrives carrying scope +`#usa`, that rule replaces `#usa` with `#BlackHole86` before forwarding. The +rule also assigns `#BlackHole86` when the packet is unscoped or carries any +other scope; it is a channel-to-target mapping, not an incoming-scope filter. + To use bridge bucket 1 to assign `east` to `public` packets whose received 3-byte path contains `7576FB`, while assigning `west` to every other authenticated `public` packet: @@ -1641,7 +1597,7 @@ 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 +`repeat`, `flood.max*`, packet filters, loop detection, payload validation, and moderation. Non-channel flood payload types retain normal global region enforcement. @@ -1665,12 +1621,17 @@ get flood.channel.scope.require.1 --- -#### Filter flood packets by payload type, hop count, and path +#### Change persistent flood rules in the field For setup guidance, interactions with the existing forwarding controls, and -worked moderation examples, see [Repeater Flood Filtering and Moderation](flood_filtering.md). +worked moderation examples, see [Flood Filtering and Moderation](flood_filtering.md). **Usage:** +- `get flood.rule` +- `get flood.rule.` +- `set flood.rule[.] type= [hops=] [channel=] [prefix=] [in=] |region=|rate=/min|stop> [priority=<0-255>] [tx=slow] [suspend=tempradio]` +- `del flood.rule.` +- `del flood.rule all` - `get flood.filter` - `get flood.filter.` - `get flood.filter.blacklist` @@ -1684,40 +1645,71 @@ worked moderation examples, see [Repeater Flood Filtering and Moderation](flood_ - `del flood.filter.` - `del flood.filter all` +The extended table is available on repeaters with the rule engine enabled and +on FULL-profile ESP32 room servers. A FULL room server exposes both +`flood.rule` and `flood.filter`, has 31 slots, and requires an administrator +for remote changes. It does not have the repeater's passive path blacklist, so +`flood.filter.blacklist*` and `path=blacklist` are repeater-only; use the +ordered `prefix=` match on a room server. Standard room-server profiles do not +compile this table. + **Parameters:** -- `n`: Rule slot from `1` to `16`. +- `n`: Rule slot in the build's compiled table (normally `1-31`; some + constrained profiles use fewer slots). - `type`: Payload type name, full `PAYLOAD_TYPE_*` name, decimal value `0-15`, hexadecimal value `0x00-0x0F`, or `any`. - `hops`: Optional; omitted means `all`. - - `N`: Block only at received hop count `N`. - - `N+`: Block at received hop count `N` and higher. - - `N-M`: Block the inclusive received-hop range. - - `all`: Block at every received hop count (`0-63`). + - `N`: Match only at received hop count `N`. + - `N+`: Match at received hop count `N` and higher. + - `N-M`: Match the inclusive received-hop range. + - `all`: Match every received hop count (`0-63`). - `0+`, `all`, and an omitted hop expression are equivalent. The CLI displays the saved range as `all`. +- `channel=*|public|#name|128-bit-key|256-bit-key`: Optional authenticated + group-channel match. It applies only to `GRP_TXT`/`GRP_DATA`; `type=any` + plus a channel condition therefore matches only those group types. +- `prefix=`: Optional ordered source-path prefix of one to three + pbyte IDs. IDs must all be 2, 4, or 6 hex characters, matching a packet's + 1-, 2-, or 3-byte pbyte width. `path=` is an alias. +- `in=any|none|scoped|allowed|unknown|scope:|region:`: Optional + condition on the original incoming scope, before any rule rewrites it. + `none` is an unscoped flood. `scope:name` is the exact public + hashtag-derived scope. `region:name` is an exact allowed region match. +- `drop`: Explicit drop action. The `flood.rule` form requires an explicit + action. For compatibility, a legacy `flood.filter` row with no rewrite, + rate, or stop action is treated as drop. +- `region=`: Rewrite action using an existing locally allowed region. +- `rate=N/min`: Per-node, per-row fixed one-minute forwarding limit. It can be + the only action or accompany `scope=`/`region=`. Counters are charged only + for packets that pass all forwarding gates. +- `priority=0-255`: Optional processing order. Higher values run first and + lower slot number breaks a tie. The default is `0`; `pri=` is an alias. +- `stop` or `action=stop`: Apply this matching row, then stop lower-order FPF7 + rows from processing. It can stand alone or accompany drop, rewrite, or + rate. A stop-only row acts as an exception to lower-priority FPF7 rules. - `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 +- `require=region`: Legacy alias for `in=allowed`. Apply the row 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 +- `tx=slow`: Optional and valid with `scope=` or `region=`. 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. + is repeater-only. It 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/): @@ -1745,16 +1737,21 @@ 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 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 +**Behavior:** Match fields within one row are ANDed. Every FPF7 row is matched +against the same immutable receive-time packet, before any rule changes its +scope. Matching rows are processed in descending `priority`, with lower slot +number winning a tie. The first matching `stop` row is included and all +lower-order FPF7 matches are discarded. A stop cannot undo an earlier drop or +bypass hard forwarding gates and separate forwarding tables. A row with +`path=blacklist` must meet the path condition as well as its other conditions; +blacklist IDs can occur anywhere in the received path and their configured +order is irrelevant. In contrast, `prefix=` begins at the first received path +entry and preserves order. A matching drop row prevents retransmission. The +highest-order remaining matching scope/region row wins; matching drop and rate +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 +`repeat`, `flood.max`, other drop rows, loop detection, or moderation. With `require=region`, a failed check makes that scope row ineligible. It leaves @@ -1773,13 +1770,26 @@ 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. +The packet is still received and can still be logged. Rules are persistent +data and can be changed over serial or authenticated remote CLI without an OTA +or reboot. `flood.rule` and `flood.filter` address the same table on extended +builds; FPF6 files are migrated in memory and the next save writes FPF7. +FPF1-FPF5 files are rejected and filtering fails open. FPF7 stores canonical +region names rather than transient numeric region +IDs. Removing, reordering, or reusing a region ID therefore cannot silently +retarget a rule. If a saved input or target region name is absent, that input +match or rewrite is inert; restoring the same region name reactivates it. 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 +Within one receive evaluation, rows that use the same channel key share one +authentication result. The cache is discarded after that packet and stores +neither plaintext nor passwords; different keys are authenticated separately. + +**Default row:** Repeater firmware and FULL ESP32 room-server firmware seed a +new flood-filter table with `ota all suspend=tempradio` in slot 1. This blocks repeated LoRa OTA (`0x0C`) floods at every received hop unless temporary radio is actually active. The OTA core independently refuses OTA receive, relay, and transmit outside temporary @@ -1793,24 +1803,28 @@ 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:** Drop rows cannot block `anon_req`, `path`, or -`response` at received hop counts `0-6`; those login-capable types become -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` drop actions; `repeat`, `flood.max*`, loop-detection, and other -forwarding gates remain authoritative. +**Remote-admin lockout warning:** There are no hidden payload-type or short-hop +exceptions. FPF7 drop and rate rows may block `req`, `response`, `txt_msg`, +`anon_req`, `path`, ACK, and multipart traffic beginning at hop `0` when their +match fields say so. Transit repeaters cannot decrypt these outer types to +distinguish an admin exchange from ordinary peer traffic. Keep a serial or +other recovery path and stage broad deny/rate rules carefully. -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 `path=blacklist`, `scope=`, and `suspend=tempradio`. -The detail output also includes `require=region` and `tx=slow` when enabled. +Without `.n`, `set` reuses an identical rule or uses the first empty slot. With +`.n`, it replaces that slot, which is the intended way to change a row's match +or action. +`get flood.filter` or `get flood.rule` gives a compact list. Use the numbered +form for full details, including channel, prefix, original-scope condition, +action, timing, rate, and temporary-radio suspension. + +If all of those fields plus long names would exceed one CLI reply, the +numbered form automatically switches to a non-truncating compact spelling. +The compact aliases are also accepted by `set`: `c=` means `channel=`, `p=` +means `prefix=`, `i=*|n|s|a|u|s:|r:` means the corresponding +`in=` condition, `q=N` means `rate=N/min`, `pri=N` means priority, and `f=st` +combines slow timing +(`s`) with temporary-radio suspension (`t`). Packet type is shown numerically +in that fallback. Normal-sized rows keep the descriptive spelling above. The blacklist and filter rows are persisted separately. Replacing or deleting the blacklist does not delete rows containing `path=blacklist`; such rows @@ -1827,12 +1841,16 @@ 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 -ignored; only a deliberately explicit `set flood.filter trace ...` row can -match it. +entirely. A custom flood-form trace participates normally: `type=any`, explicit +`trace`, rewrite, rate, drop, and stop rows can all apply. **Examples:** ```text +set flood.rule.2 type=grp_data hops=4+ channel=#rgdata in=none scope=BlackHole86 +set flood.rule.3 type=grp_data channel=#rgdata in=scope:usa scope=BlackHole86 +set flood.rule.4 type=any prefix=860C rate=10/min +set flood.rule.5 type=grp_data hops=0-2 channel=#rgdata priority=200 stop +get flood.rule.2 set flood.filter grp_data 4+ set flood.filter.2 PAYLOAD_TYPE_ADVERT 6+ set flood.filter ota 2-4 @@ -1853,6 +1871,19 @@ get flood.filter.2 del flood.filter.2 ``` +The first rule authenticates `#rgdata`, requires more than three received hops, +and adds `#BlackHole86` only when no scope was present. The second rewrites the +exact incoming `#usa` scope. The third demonstrates a two-byte pbyte source +prefix and a global per-row rate cap. The fourth authenticates `#rgdata` at +zero through two hops, applies no FPF7 action of its own, and stops lower-order +FPF7 rows; hard gates and separate tables still apply. + +The fixed 240 KB STM32WL profiles leave +`MESH_ENABLE_FLOOD_RULE_ENGINE=0` and retain the compact, persistent FPF6 +`flood.filter` and blacklist commands. They still filter floods, but do not +expose the `flood.rule` alias or its extended channel, prefix, input-scope, +region-action, or rate fields. No partition size changes are required. + --- #### Moderate flood group text by channel, sender, and source path @@ -1933,7 +1964,8 @@ del flood.moderation.3 **Filter manager scope:** Permission `5` can use an explicit allowlist of non-secret operational/filter status commands and can change the forwarding controls `repeat`, `loop.detect`, `flood.max*`, `flood.channel.data*`, -`flood.channel.block*`, `flood.filter*`, and `flood.moderation*`. It cannot read +`flood.filter*`, `flood.rule*`, and +`flood.moderation*`. It cannot read guest, WiFi, MQTT, bridge, or other credentials, and it cannot change regions, ACL entries, radio settings, or other admin configuration. Permission `4` is limited to region commands, `flood.channel.scope*`, and the same non-secret diff --git a/docs/flood_filtering.md b/docs/flood_filtering.md index d3945d9c..825beacd 100644 --- a/docs/flood_filtering.md +++ b/docs/flood_filtering.md @@ -1,9 +1,13 @@ -# Repeater Flood Filtering and Moderation +# Flood Filtering and Moderation -This guide explains the Keymind repeater forwarding filters. The filters decide -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. +This guide explains the Keymind forwarding filters. Repeaters expose the full +set of channel, rule, blacklist, and moderation controls described here. +FULL-profile ESP32 room servers expose the generalized `flood.rule` table (and +its `flood.filter` alias) with 31 slots, but not the repeater's channel tables, +passive blacklist, or text-moderation table. Standard room-server profiles do +not compile the rule table. Filters decide whether the node 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: @@ -18,7 +22,7 @@ OTA assignment noted below. ## Before making changes -Show the current forwarding controls: +On a repeater, show the current forwarding controls: ```text get repeat @@ -27,39 +31,63 @@ get flood.max.unscoped get flood.max.advert 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.rule get flood.moderation ``` -The `flood.filter` and `flood.moderation` tables each have 16 persistent slots. -A new `flood.filter` table starts with `ota all suspend=tempradio` in slot 1; -`flood.moderation` starts empty. A row can opt into `suspend=tempradio`; +`flood.rule` is an alias for `flood.filter`, not another table. The +The generalized FPF7 table has 31 persistent slots; compact target profiles +may compile fewer FPF6 filter slots. `flood.moderation` has 16 slots. A new +repeater FPF7 table starts with `ota all suspend=tempradio` in slot 1 and an +authenticated `#wardriving hops=5+` drop in slot 2; FULL room servers seed only +the OTA row. `flood.moderation` starts empty. A row can opt into +`suspend=tempradio`; 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. +On a FULL ESP32 room server, use `get flood.rule` (or `get flood.filter`) for +the available table. Remote rule changes require room-server administrator +access. `flood.filter.blacklist*` and `path=blacklist` are repeater-only; use +the ordered `prefix=` condition on a room server. + ## Force floods into a transport scope `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 [path=blacklist|path=bucket:1-6] [tx=slow] -set flood.channel.scope. [path=blacklist|path=bucket:1-6] [tx=slow] +set flood.channel.scope [path=blacklist|path=bucket:1-6] [tx=slow] +set flood.channel.scope. [path=blacklist|path=bucket:1-6] [tx=slow] get flood.channel.scope get flood.channel.scope. del flood.channel.scope. del flood.channel.scope all ``` -The channel may be `public`, `#channel`, or a 128/256-bit hex key. The region -must already exist and provide a usable transport key. Keyed rules first check -the one-byte channel hash carried in the packet, then validate the MAC by -decrypting with the configured channel key. A hash collision alone cannot -force a scope. +The channel may be `public`, `#channel`, or a 128/256-bit hex key. A bare +target names an existing region with a usable transport key. Use +`scope=` instead to derive a regionless public hashtag scope exactly as +`flood.filter scope=` does. The direct name is normalized with a leading +`#`, may contain up to 30 characters, and does not need a region-list entry. +Keyed rules first check the one-byte channel hash carried in the packet, then +validate the MAC by decrypting with the configured channel key. A hash +collision alone cannot force a scope. + +For example, this authenticates only `#rgdata` and rewrites it to +`#BlackHole86` without creating a region: + +```text +set flood.channel.scope #rgdata scope=BlackHole86 +get flood.channel.scope.1 +``` + +If that channel arrives scoped to `#usa`, the rule replaces `#usa` with +`#BlackHole86`. It also handles unscoped packets and replaces any other +incoming scope; the source scope is not a condition on the rule. Add `path=blacklist` to make a channel-scope row eligible only when the received path matches the passive `flood.filter.blacklist` ID table. It does @@ -81,11 +109,11 @@ There are three independent wildcard classes: - `txt:*` handles otherwise-unmatched `GRP_TXT` and `GRP_DATA`; plain `*` is its alias. - `login:*` handles `REQ`, `RESPONSE`, `TXT_MSG`, `ANON_REQ`, and `PATH`. -- `other:*` handles every remaining flood payload type except TRACE, including - OTA. TRACE is deliberately exempt from forced-scope wildcards. +- `other:*` handles every remaining flood payload type, including flood-form + TRACE and OTA. `login:*` and `other:*` classify only the visible outer payload type; they do -not authenticate its contents. Exact channel rows with usable target regions +not authenticate its contents. Exact channel rows with usable targets always take precedence over `txt:*`, even if that wildcard has a lower slot number. Within the exact class, matching path-qualified rows are tried before ordinary fallback rows. The same qualified-then-fallback order applies within @@ -110,12 +138,14 @@ enabled. On a successful match, an unscoped route changes from `ROUTE_TYPE_FLOOD` to `ROUTE_TYPE_TRANSPORT_FLOOD`; an already-scoped route remains transport-flood 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 +region or direct hashtag 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 +converted from unscoped, while `flood.max`, `flood.filter`, loop detection, and +moderation still apply to every rewritten packet. A region +target must be flood-allowed. A regionless target is trusted for this matched +receive pass, but it neither creates a region nor changes how unrelated packets +with the same transport code pass the region gate. 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 @@ -126,17 +156,19 @@ 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. -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. Standard traceroute is direct-routed and +therefore remains outside this flood-only table. A custom flood-form TRACE is +treated like every other flood: an applicable wildcard may rewrite it and the +normal region/unknown-code gates still apply. Scope assignment 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 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 +unscoped and transport-scoped flood routes. A region target must allow +flooding; a direct target uses the regionless trust behavior above. 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 @@ -145,17 +177,22 @@ replaced. Capacity is selected at build time: -- Roomy ESP32 builds: 255 slots, 9,180 bytes RAM, 9,185-byte file. +- Roomy ESP32 builds: 255 rule slots and 32 regionless-target slots, 10,204 + bytes RAM, 10,210-byte file. - DRAM-tight classic ESP32 LoRa-OTA repeaters, nRF52, and other normal - constrained builds: 31 slots, 1,116 bytes RAM, 1,121-byte file. -- Very-tight STM32WL builds: 15 slots, 540 bytes RAM, 545-byte file. -- The no-PSRAM LilyGo T-LoRa V2.1 repeater/observer: 4 slots, 144 bytes RAM, - 149-byte file. This minimum holds the three wildcard classes and one exact - channel mapping. + constrained builds: 31 rule and regionless-target slots, 2,108 bytes RAM, + 2,114-byte file. +- Very-tight STM32WL builds: 15 rule slots and one reusable regionless-target + slot, 572 bytes RAM, 578-byte file. +- The no-PSRAM LilyGo T-LoRa V2.1 repeater/observer: 4 rule and + regionless-target slots, 272 bytes RAM, 278-byte file. This minimum holds the + three wildcard classes and one exact channel mapping. -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. +Each rule retains its 36-byte record. A separate table holds 32-byte normalized +names for up to the smaller of the rule count or 32 distinct regionless +targets, except that very-tight STM32WL builds retain one reusable direct +target. Both configured regions and regionless targets can be reused by any +number of rules. ## Require valid incoming scopes only on selected channels @@ -183,7 +220,7 @@ 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 +`flood.max*`, `flood.filter`, 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. @@ -225,8 +262,8 @@ of a less-preferred scope it already carried. Scope selection happens at dequeue so the original scopes remain available for 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. +already processed into the seen table. Flood-form TRACE participates in the +same arbitration. Direct traceroute never enters this flood queue. A packet that already matches a configured fast `flood.channel.scope` or `flood.filter scope=` action and needs its scope changed bypasses this inbound @@ -234,10 +271,130 @@ A packet that already matches a configured fast `flood.channel.scope` or configured base, floored at `2.0`, and participates in normal queued-copy scope arbitration. -## Filter by payload type, received hop count, and path +## Runtime flood rules -Use `flood.filter` when the packet type, current path length, or listed path -identifiers are enough to make the decision: +On repeaters with the rule engine enabled and on FULL-profile ESP32 room +servers, `flood.rule` and `flood.filter` are two names for the same persistent +table. The evaluator is fixed firmware, but every row is data, so an +authenticated operator can add, replace, inspect, or delete a row without an +OTA or reboot. Existing `flood.filter` commands remain compatible. Only FPF6 +and FPF7 files are accepted; FPF1-FPF5 files are rejected and filtering fails +open. A row saved by the extended engine uses FPF7. + +The former `flood.channel.block` table is now represented by ordinary FPF7 +rows. On a generalized repeater, an existing FCB2 file is imported once into +free FPF7 slots and then removed. For example, an old `#wardriving h=4` row +becomes `type=any channel=#wardriving hops=5+ drop`. The 31 FPF7 slots replace +the former 16 general-filter plus 15 channel-block slots. Compact STM32WL FPF6 +builds cannot match authenticated channels. + +FPF7 binds `in=region:` and `region=` to canonical region names, +not numeric region IDs. Removing, reordering, or reusing a region ID cannot +silently redirect a rule. If the saved name is missing, an input-region match +does not match and a target-region rewrite is skipped. Re-adding the same name +reactivates the rule. + +The extended form is: + +```text +set flood.rule[.] type= [hops=] [channel=] + [prefix=] [in=] [rate=/min] + [priority=<0-255>] [stop] [tx=fast|slow] [suspend=tempradio] +get flood.rule +get flood.rule. +del flood.rule. +del flood.rule all +``` + +The command must be entered on one line. Match fields in one row are ANDed. +Every row is matched against the same immutable packet state captured on +receive, before any rule rewrites its scope. Matching rows are then processed +by descending `priority`; lower slot number wins a priority tie. Priority +defaults to `0`. + +The first matching `stop` row ends FPF7 processing after that row. Higher-order +matches and the stop row still apply; lower-order matches do not. A stop-only +row is therefore an exception to lower-priority FPF7 rows. It cannot undo a +higher-priority drop and it does not bypass hard forwarding gates or separate +forwarding tables. Without a stop row, matching drop and rate rows remain +independent and the highest-order matching scope or region rewrite wins. + +Match fields: + +- `type=` accepts the same packet names and numeric values as legacy + `flood.filter`. The positional form remains accepted. +- `hops=` accepts `all`, `N`, `N+`, or `N-M`. The positional form remains + accepted. Received hops over 3 are written as `hops=4+`. +- `channel=*|public|#name|128-bit-key|256-bit-key` authenticates a `GRP_TXT` or + `GRP_DATA` packet with that channel key before the row can match. With + `type=any`, this condition naturally limits the row to those two group + packet types. +- `prefix=` is a source-path prefix containing one to three comma-separated + pbyte IDs. Every ID must use the packet's pbyte width: 2, 4, or 6 hex + characters for 1-, 2-, or 3-byte paths. Order matters and matching begins at + the first received path entry. `path=` is an alias; + `path=blacklist` retains its separate unordered-list behavior. +- `in=any|none|scoped|allowed|unknown|scope:|region:` tests the + original incoming route before any rewrite. `none` means an unscoped flood; + `scoped` means any transport flood; `scope:name` compares the exact public + hashtag-derived scope; and `region:name` compares an allowed configured + region. `allowed` is the legacy `require=region` test and includes an + unscoped packet when the wildcard region allows it. `unknown` means a scoped + packet that does not resolve to an allowed local region. + +Actions: + +- `drop` prevents retransmission when the row matches. The strict + `flood.rule` form requires an explicit action. For backward compatibility, + only a legacy `flood.filter` row with no rewrite, rate, or stop action means + drop implicitly. +- `scope=` assigns a direct public hashtag scope without requiring a + region-list entry. +- `region=` assigns an existing locally allowed region and its transport + key. +- `rate=N/min` is a per-node, per-row fixed one-minute forwarding limit. It can + stand alone or accompany a scope/region rewrite. Quota is charged only after + every other forwarding gate, including moderation, accepts the packet. It is + not keyed per sender; use `flood.moderation` when a group-text rate must be + tied to an exact display name. +- `priority=0-255` controls processing order. Higher values run first; lower + slot number breaks ties. `pri=` is the compact alias. +- `stop` (or `action=stop`) applies this row and prevents lower-order FPF7 rows + from acting. It can stand alone or accompany drop, rewrite, or rate. + +When several rows use the same channel key, authentication is performed once +for that packet and reused by those rows. This cache lives only for the current +receive evaluation; it is not persisted and never stores plaintext or a +password. + +The exact requested examples are: + +```text +# If #rgdata arrives unscoped with more than 3 received hops, add +# the regionless #BlackHole86 scope. +set flood.rule.2 type=grp_data hops=4+ channel=#rgdata in=none scope=BlackHole86 + +# Rewrite the exact incoming #usa scope to #BlackHole86 for #rgdata. +set flood.rule.3 type=grp_data channel=#rgdata in=scope:usa scope=BlackHole86 + +# Match a two-byte source-path prefix and cap forwarding at 10 per minute. +set flood.rule.4 type=any prefix=860C rate=10/min + +# Keep authenticated #rgdata at two hops or less out of lower-priority FPF7 +# rules. Hard gates and separate tables still apply. +set flood.rule.5 type=grp_data hops=0-2 channel=#rgdata priority=200 stop + +get flood.rule.2 +get flood.rule.3 +get flood.rule.4 +``` + +The 240 KB STM32WL profiles keep `MESH_ENABLE_FLOOD_RULE_ENGINE=0` and retain +the persistent compact FPF6 `flood.filter` and blacklist syntax below. They +still perform filtering, but omit the generalized `flood.rule` parser and +extended fields. No partition size is changed by this feature. + +The compatible filter and blacklist commands are: ```text set flood.filter.blacklist @@ -277,32 +434,38 @@ 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`). +Without a slot number, `set` reuses an identical row or selects the first empty +slot. Use a slot number to replace a row whose match or action is changing. +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 +Numbered `get` normally uses the long field names. If a rule containing +several maximum-length names would exceed one CLI reply, it switches to a +non-truncating compact spelling that `set` also accepts: `c=` is `channel=`, +`p=` is `prefix=`, `i=*|n|s|a|u|s:|r:` represents `in=`, `q=N` +is `rate=N/min`, and `f=st` combines slow timing (`s`) and temporary-radio +suspension (`t`). The fallback prints packet type numerically. + +A legacy row without `scope=` is the existing drop action. On extended builds, +an explicit `drop` has the same result, while `rate=` by itself creates a +rate-only row. A row with `scope=` 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 +`require=region` is the legacy spelling of `in=allowed`. 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 +the wildcard region. If the check fails, that row is skipped, the filter does +not grant a 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 +When multiple scope or region rows match, the highest-priority row wins; lower +slot number breaks a priority tie. +Rewrite 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 +validation, but `repeat`, `flood.max`, 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 @@ -312,10 +475,13 @@ 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 repeaters, the blacklist and rule table are persisted separately. FPF7 +stores compiled match/action fields and canonical region names, not executable +code, a script, or numeric region references. 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. FULL room servers have only the +rule table and reject blacklist commands. On first initialization, flood-filter slot 1 is seeded with: @@ -333,38 +499,29 @@ 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 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 +the temporary-radio window unless an earlier matching stop row ends FPF7 +processing. `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 -explicit `trace` row can match it. The stock core does not normally -flood-forward TRACE packets. +custom flood-form trace, `type=any`, explicit `trace`, scope, region, rate, and +drop rows all behave normally. The stock core does not normally flood-forward +TRACE packets. -### Remote administration cannot be type-filtered on short paths +### Remote-administration lockout warning -`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`. -- Flood `txt_msg` cannot be blocked at received hops `0-4`; configured rules - begin applying at hop `5`. - -`req`, `ack`, and multipart ACK have no special floor and remain filterable from -hop `0`. Scope-setting rows do not block traffic and may apply within the -protected ranges. +There are no hidden payload-type or short-hop exemptions in FPF7. Drop and +rate rules can block `req`, `response`, `txt_msg`, `anon_req`, `path`, ACK, and +multipart traffic beginning at hop `0` when their match fields say so. 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, administrative replies and CLI text can also be flooded. Transit repeaters do not have the session key and cannot distinguish those encrypted admin exchanges -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 configurable `flood.filter` drop actions. It does -not override `repeat`, `flood.max*`, loop detection, or other forwarding gates. +from ordinary peer packets with the same outer type. A rule therefore affects +the complete outer packet class, not only packets that ultimately authenticate +as administrators. Keep a serial or other recovery path and stage broad +deny/rate rules carefully. Hop expressions are based on the path count when this repeater receives the packet: @@ -431,9 +588,9 @@ get flood.filter On a new table, the factory OTA rule occupies slot 1, so these unnumbered commands normally fill slots 2 through 7. Existing tables may choose different -free slots. The `response`, `anon_req`, and `path` thresholds are above their -protected `0-6` range, so all six rules take effect at the thresholds shown. -The Control rule allows a flood received with path count `0` to be forwarded +free slots. All six rules take effect at the thresholds shown; there are no +hidden short-hop exceptions. The Control rule allows a flood received with +path count `0` to be forwarded once, then stops it at the next repeater. Normal node-discovery Control packets are direct zero-hop packets and never enter `flood.filter`. These rules affect only retransmission by the repeater; local reception and logging remain @@ -542,18 +699,23 @@ other words, the controls combine as deny rules: original incoming scope; unlisted group channels bypass the later region gate while the table is active. 2. `flood.channel.scope` tries a path-qualified channel row before that - channel's ordinary fallback, then adds or replaces the scope. -3. A matching `flood.filter scope=` row may replace that result; its scope does - not require a region-list entry. + channel's ordinary fallback, then adds or replaces the scope from either a + configured region or a direct `scope=` target. +3. All extended `flood.rule` match fields are evaluated against the same + original incoming packet. Matches are ordered by descending priority and + then ascending slot. The first matching `stop` row removes every later FPF7 + match. The highest-order remaining `scope=` or `region=` row may replace the + channel-scope result; a direct 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. +5. Remaining `flood.filter`/`flood.rule` drop and rate rows use that saved + match result. No packet type or short-hop range is silently exempted. +6. Region and loop-detection rules are checked; a regionless scope assigned by + either table is already trusted when it has no region-list match, except + that it cannot rescue a channel rejected by + `flood.channel.scope.require`. +7. `flood.moderation` checks decrypted group text, username, rate, hops, and + path. If it accepts the packet, matching general-rule rate counters are + charged immediately before retransmission is approved. 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 @@ -562,25 +724,30 @@ control and will actually be retransmitted. ## Delegate filter management -ACL permission `5` is the filter-manager role: +On repeaters, ACL permission `5` is the filter-manager role: ```text setperm 5 ``` A filter manager can read non-secret operational status and manage `repeat`, -`loop.detect`, `flood.max*`, `flood.channel.data*`, `flood.channel.block*`, -`flood.filter*`, and `flood.moderation*`. Delegated `get` access uses an +`loop.detect`, `flood.max*`, `flood.channel.data*`, +`flood.filter*`, `flood.rule*`, 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. 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. +FULL ESP32 room servers use their existing administrator check for remote +`flood.rule` and `flood.filter` commands; they do not grant this table through +permission `5`. + ACL permission `4`, the region/scope-manager role, can read, add, replace, and 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. +scopes from regions or direct hashtag names, and select the channels that +require valid incoming scopes. ## Security limitations @@ -599,13 +766,13 @@ private transport/channel keys, and controlled device access. The repeater's factory-seeded forwarding rows can be restored through the CLI: ```text -set flood.channel.block.1 #wardriving h=4 -set flood.filter.1 0x0C all suspend=tempradio +set flood.rule.1 type=ota hops=all drop suspend=tempradio +set flood.rule.2 type=any channel=#wardriving hops=5+ drop ``` -Each command explicitly replaces slot 1 in its own table. Inspect the slot first -if it may now contain another rule. To preserve existing slot assignments, omit -`.1`; the command then reuses an identical row or uses the first empty slot. +These commands explicitly replace the two seeded generalized-repeater slots. +Inspect them first if they may now contain other rules. Compact FPF6 builds use +only the first command's `flood.filter.1 0x0C all suspend=tempradio` form. ## Remove the custom rules @@ -618,5 +785,5 @@ get flood.filter get flood.moderation ``` -This does not change the older `flood.max*`, channel-block, loop-detection, or -region settings; inspect or reset those separately when troubleshooting. +This does not change the older `flood.max*`, loop-detection, or region settings; +inspect or reset those separately when troubleshooting. diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index 6e70f2a6..9c8056a1 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -80,13 +80,12 @@ set flood.retry.ignore none | `battery.alert.low` | Warning threshold percentage. Must be greater than `battery.alert.critical`. | `get battery.alert.low`, `set battery.alert.low <1-100>` | `set battery.alert.low 20` | | `battery.alert.critical` | Critical threshold percentage. Critical and warning alerts use the same 12-hour resend cooldown. | `get battery.alert.critical`, `set battery.alert.critical <0-99>` | `set battery.alert.critical 10` | | `recent.repeater` | Shows, searches, seeds, or clears the recent repeater prefix/SNR table used by direct retry and bridge freshness checks. Search results include the last-recorded age. Entries older than 24 hours are removed by a three-hour sweep. | `get recent.repeater [page]`, `get recent.repeaters search <2|4|6 hex> [page]`, `set recent.repeater `, `clear recent.repeater` | `get recent.repeaters search A1B2` | -| `flood.channel.data` | Turns forwarding of flood `GRP_DATA` channel packets on or off. With the default `on`, `GRP_DATA` repeats normally even when `flood.channel.block.hops` is set. | `get flood.channel.data`, `set flood.channel.data on/off` | `set flood.channel.data off` | +| `flood.channel.data` | Turns forwarding of all flood `GRP_DATA` channel packets on or off. The default is `on`; use an authenticated FPF7 `channel=` drop when only selected channels should be blocked. | `get flood.channel.data`, `set flood.channel.data on/off` | `set flood.channel.data off` | | `flood.channel.data.hops` | Separate hop gate used only when `flood.channel.data` is `off`; `all` blocks `GRP_DATA` at any hop count, `1`-`7` repeats at that hop count or lower and blocks longer paths. | `get flood.channel.data.hops`, `set flood.channel.data.hops ` | `set flood.channel.data.hops 7` | -| `flood.channel.block` | Blocks selected flood `GRP_TXT`/`GRP_DATA` channels when the key validates the packet. New repeater block lists start with editable/deletable `#wardriving h=4`. Add `h=` for a per-channel hop override. | `get flood.channel.block`, `set flood.channel.block[.n] [name] [h=...]`, `del flood.channel.block[.n]` | `set flood.channel.block #wardriving h=4` | -| `flood.channel.block.hops` | Limits keyed channel-block matches to short flood paths. `all` blocks matching packets at any hop count; `1`-`7` repeats packets at that hop count or lower and blocks longer matches. This does not restrict unkeyed `GRP_DATA`; use `flood.channel.data.hops` for that. | `get flood.channel.block.hops`, `set flood.channel.block.hops ` | `set flood.channel.block.hops 3` | -| `flood.channel.scope` | Adds a transport-region scope to received unscoped floods or replaces the scope of already-scoped floods. By default, a changed packet bypasses inbound `rxdelay` and is forwarded at the highest outbound queue priority with zero initial `txdelay`, so the selected scope can win at the next hop. `tx=slow` uses an effective inbound `rxdelay` base of `max(2, configured rxdelay * 2)`, retains normal queue priority, and forces the maximum `txdelay` factor of `2.0`; its actual randomized transmit delay ranges from zero through ten packet airtimes. `path=blacklist` and `path=bucket:<1-6>` make a row path-qualified; bridge buckets remain usable while bridge retry is off. An already-matching scope is a no-op. Exact channel keys beat `txt:*`; path-qualified rows beat the ordinary channel fallback. `login:*` covers the remote-login family, and `other:*` covers every remaining flood type except TRACE, including OTA. TRACE remains unchanged across scope boundaries. ACL permission `4` can manage the table. | `get flood.channel.scope[.n]`, `set flood.channel.scope[.n] [path=blacklist|path=bucket:1-6] [tx=slow]`, `del flood.channel.scope.|all` | `set flood.channel.scope public east path=bucket:1` | +| `flood.channel.scope` | Adds a transport scope to received unscoped floods or replaces the scope of already-scoped floods. A bare target uses an existing flood-allowed region; `scope=` derives a public hashtag target directly without creating a region, exactly like `flood.filter scope=`. By default, a changed packet bypasses inbound `rxdelay` and is forwarded at the highest outbound queue priority with zero initial `txdelay`, so the selected scope can win at the next hop. `tx=slow` uses an effective inbound `rxdelay` base of `max(2, configured rxdelay * 2)`, retains normal queue priority, and forces the maximum `txdelay` factor of `2.0`; its actual randomized transmit delay ranges from zero through ten packet airtimes. `path=blacklist` and `path=bucket:<1-6>` make a row path-qualified; bridge buckets remain usable while bridge retry is off. An already-matching scope is a no-op. Exact channel keys beat `txt:*`; path-qualified rows beat the ordinary channel fallback. `login:*` covers the remote-login family, and `other:*` covers every remaining flood type, including flood-form TRACE and OTA. Direct traceroute remains outside the flood table. ACL permission `4` can manage the table. | `get flood.channel.scope[.n]`, `set flood.channel.scope[.n] [path=blacklist|path=bucket:1-6] [tx=slow]`, `del flood.channel.scope.|all` | `set flood.channel.scope #rgdata scope=BlackHole86` | | `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.filter` | Persistent flood-route rules selected by payload type and optional hop range. Extended builds have 31 FPF7 slots and add authenticated channel, ordered pbyte prefix, original scope/region, rewrite, rate, priority, and terminal stop fields; `flood.rule` is an alias for the same table. Repeaters also support a separate unordered `path=blacklist`. FULL ESP32 room servers have the same 31 extended slots but no blacklist and require remote administrator access. Fixed 240 KB STM32WL repeaters retain compact FPF6 filtering and blacklist commands. New generalized repeater tables seed slot 1 with `ota all suspend=tempradio` and slot 2 with an authenticated `#wardriving hops=5+` drop. Direct routes and local receive/logging are unchanged. | `get flood.filter[.n]`, `set flood.filter[.n] [N|N+|N-M|all] [scope=] [require=region] [tx=slow] [suspend=tempradio]`, `del flood.filter.|all`; repeater only: `get/set/del flood.filter.blacklist[.n]` | `set flood.filter grp_txt all scope=local tx=slow` | +| `flood.rule` | Live alias for extended `flood.filter` on rule-engine repeaters and FULL ESP32 room servers. A row can AND packet type, hop range, authenticated group channel, ordered 1/2/3-byte pbyte source prefix, and original scope/region conditions, then drop, rewrite to a direct scope or configured region, enforce a per-row rate, and/or stop lower-priority FPF7 rules. All rows match the original receive-time packet; higher `priority` runs first and lower slot breaks a tie. Repeated rows with one channel key share a per-packet authentication result. Persistent FPF7 stores canonical region names, so region ID reorder or reuse cannot retarget a rule. A missing saved region name makes that match or rewrite inert until the name returns. Fixed 240 KB STM32WL profiles keep FPF6 and do not expose this alias; partition sizes are unchanged. | `get flood.rule[.n]`, `set flood.rule[.n] type= [hops=...] [channel=...] [prefix=...] [in=...] [priority=0-255]`, `del flood.rule.|all` | `set flood.rule.2 type=grp_data hops=4+ channel=#rgdata in=none scope=BlackHole86` | | `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/docs/payloads.md b/docs/payloads.md index b4a98b38..85c0e7fd 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -59,7 +59,15 @@ Appdata Flags ## Acknowledgement -An acknowledgement that a message was received. Note that for returned path messages, an acknowledgement can be sent in the "extra" payload (see [Returned Path](#returned-path)) instead of as a separate acknowledgement packet. CLI commands do not cause acknowledgement responses, neither discrete nor extra. +An acknowledgement that a message was received. Note that for returned path messages, an acknowledgement can be sent in the "extra" payload (see [Returned Path](#returned-path)) instead of as a separate acknowledgement packet. Current `CLI_DATA` commands do not cause acknowledgement responses, neither discrete nor extra; their text reply is the application-level result. Repeaters still ACK the legacy plain-text form before processing it. + +Repeater remote CLI keeps one volatile copy of the most recently completed +reply, keyed by the authenticated sender, request timestamp, and command text. +Repeating that same logical request re-sends the text reply without executing +the command again. A retry must therefore preserve the original timestamp and +command text. The cache is cleared by reboot and replaced by the next completed +remote command; commands that intentionally produce no text reply remain +silent. | Field | Size (bytes) | Description | |----------|--------------|------------------------------------------------------------| @@ -174,6 +182,13 @@ txt_type | `0x01` | CLI command | the command text of the message | | `0x02` | signed plain text message | first four bytes is sender pubkey prefix, followed by plain text message | +For a room post, companion firmware uses its own monotonic clock for the +on-air timestamp and preserves that timestamp across application retries. Room +servers track post timestamps separately from login, request, and CLI traffic. +They also remember recent accepted posts by sender, timestamp, and text: an +exact retry is ACKed again without storing a duplicate, while stale or +same-timestamp mismatches are rejected. + ## Anonymous request | Field | Size (bytes) | Description | diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 3dbbe416..89fd1297 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2021,9 +2021,23 @@ void MyMesh::handleCmdFrame(size_t len) { result = sendCommandData(*recipient, msg_timestamp, attempt, text, est_timeout); expected_ack = 0; // no Ack expected } else { + const uint32_t app_timestamp = msg_timestamp; + const bool is_room_post = recipient->type == ADV_TYPE_ROOM; + if (is_room_post + && !room_message_timestamps.find(text_fingerprint, app_timestamp, + &msg_timestamp)) { + // Room login/control packets already use the node's monotonic clock. + // Give a new post that same clock source, then preserve the mapping + // so application retries keep one logical server-side timestamp. + msg_timestamp = getRTCClock()->getCurrentTimeUnique(); + } result = sendMessage(*recipient, msg_timestamp, attempt, text, expected_ack, est_timeout, packet_retry_key, replacement_entry != NULL ? replacement_entry->retry_key : NULL); + if (result != MSG_SEND_FAILED && is_room_post) { + room_message_timestamps.remember(text_fingerprint, app_timestamp, + msg_timestamp); + } } if (result == MSG_SEND_FAILED) { writeErrFrame(ERR_CODE_TABLE_FULL); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index c762a4e8..914a47ef 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -40,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -73,6 +74,10 @@ #define OFFLINE_QUEUE_SIZE 16 #endif +#ifndef ROOM_MESSAGE_TIMESTAMP_CACHE_SIZE +#define ROOM_MESSAGE_TIMESTAMP_CACHE_SIZE 16 +#endif + #ifndef BLE_NAME_PREFIX #define BLE_NAME_PREFIX "MeshCore-" #endif @@ -324,6 +329,7 @@ private: }; #define EXPECTED_ACK_TABLE_SIZE 8 AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table + mesh::LogicalMessageCache room_message_timestamps; int next_ack_idx; unsigned long next_ack_expiry; bool has_next_ack_expiry; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 4c8b1b29..12364310 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #if defined(USE_LR2021) #include #endif @@ -104,7 +105,10 @@ extern "C" caddr_t _sbrk(int increment); #define LAZY_CONTACTS_WRITE_DELAY 5000 -#define FLOOD_CHANNEL_BLOCK_FILE "/flood_ch_block" +#define LEGACY_FLOOD_CHANNEL_BLOCK_FILE "/flood_ch_block" +#define LEGACY_FLOOD_CHANNEL_BLOCK_SLOTS 15 +#define LEGACY_FLOOD_CHANNEL_BLOCK_NAME_LEN 32 +#define LEGACY_FLOOD_CHANNEL_BLOCK_HOPS_INHERIT 0xFE #define FLOOD_PACKET_FILTER_FILE "/flood_filter" #define FLOOD_PACKET_FILTER_BLACKLIST_FILE "/flood_filter_bl" #define FLOOD_CHANNEL_SCOPE_FILE "/flood_ch_scope" @@ -113,17 +117,23 @@ extern "C" caddr_t _sbrk(int increment); #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 -#define FLOOD_PACKET_FILTER_TXT_MSG_PROTECTED_HOPS 5 -#define DEFAULT_FLOOD_CHANNEL_BLOCK_NAME "#wardriving" +#define DEFAULT_WARDRIVING_CHANNEL "#wardriving" static const char FLOOD_PACKET_FILTER_USAGE[] = +#if MESH_ENABLE_FLOOD_RULE_ENGINE + "Err - use: set flood.rule[.n] type= [hops=] [...]"; +#else "Err - use: set flood.filter[.n] [hops] [path=blacklist] [scope=] [require=region] [tx=slow] [suspend=tempradio]"; +#endif static const char FLOOD_PACKET_FILTER_DUPLICATE[] = "Err - duplicate filter option"; static const char FLOOD_CHANNEL_SCOPE_USAGE[] = - "Err - use: set flood.channel.scope[.n] [path=blacklist|path=bucket:1-6] [tx=slow]"; -#ifndef DEFAULT_FLOOD_CHANNEL_BLOCK_HOPS - #define DEFAULT_FLOOD_CHANNEL_BLOCK_HOPS 4 +#if defined(STM32_PLATFORM) + "Err - bad scope matcher"; +#else + "Err - use: set flood.channel.scope[.n] [...]"; +#endif +#ifndef DEFAULT_WARDRIVING_MAX_HOPS + #define DEFAULT_WARDRIVING_MAX_HOPS 4 #endif #ifndef REPEATERS_CHANNEL_KEY_HEX @@ -344,7 +354,7 @@ static bool buildRepeatersChannel(mesh::GroupChannel& channel) { return true; } -static File openFloodChannelBlockRead(FILESYSTEM* fs, const char* filename) { +static File openFloodSettingsRead(FILESYSTEM* fs, const char* filename) { #if defined(RP2040_PLATFORM) return fs->open(filename, "r"); #else @@ -352,7 +362,7 @@ static File openFloodChannelBlockRead(FILESYSTEM* fs, const char* filename) { #endif } -static File openFloodChannelBlockWrite(FILESYSTEM* fs, const char* filename) { +static File openFloodSettingsWrite(FILESYSTEM* fs, const char* filename) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) fs->remove(filename); return fs->open(filename, FILE_O_WRITE); @@ -867,64 +877,19 @@ void MyMesh::sendClientReplyWithFallbackScope(ClientInfo* client, mesh::Packet* } } -uint8_t MyMesh::resolveFloodChannelBlockHops(uint8_t max_hops) const { - return max_hops == FLOOD_CHANNEL_BLOCK_HOPS_INHERIT ? _prefs.flood_channel_block_max_hops : max_hops; -} - -bool MyMesh::floodChannelBlockHopApplies(const mesh::Packet* packet, uint8_t max_hops) const { - if (packet == NULL) { - return false; - } - max_hops = resolveFloodChannelBlockHops(max_hops); - return max_hops == FLOOD_CHANNEL_BLOCK_HOPS_ALL || packet->getPathHashCount() > max_hops; -} - bool MyMesh::floodChannelDataHopApplies(const mesh::Packet* packet) const { if (packet == NULL) { return false; } uint8_t max_hops = _prefs.flood_channel_data_max_hops; - return max_hops == FLOOD_CHANNEL_BLOCK_HOPS_ALL || packet->getPathHashCount() > max_hops; -} - -bool MyMesh::floodChannelBlockMatches(const FloodChannelBlockEntry& entry, const mesh::Packet* packet) const { - if (!entry.active || packet == NULL || !packet->isRouteFlood()) { - return false; - } - if (!floodChannelBlockHopApplies(packet, entry.max_hops)) { - return false; - } - uint8_t type = packet->getPayloadType(); - if (type != PAYLOAD_TYPE_GRP_TXT && type != PAYLOAD_TYPE_GRP_DATA) { - return false; - } - if (packet->payload_len <= PATH_HASH_SIZE + CIPHER_MAC_SIZE || packet->payload[0] != entry.hash_prefix[0]) { - return false; - } - - uint8_t data[MAX_PACKET_PAYLOAD]; - int len = mesh::Utils::MACThenDecrypt(entry.secret, data, &packet->payload[PATH_HASH_SIZE], - packet->payload_len - PATH_HASH_SIZE); - return len > 0; -} - -bool MyMesh::shouldBlockFloodChannelForward(const mesh::Packet* packet) const { - for (int i = 0; i < FLOOD_CHANNEL_BLOCK_SLOTS; i++) { - if (floodChannelBlockMatches(flood_channel_blocks[i], packet)) { - MESH_DEBUG_PRINTLN("allowPacketForward: flood.channel.block matched slot=%d name=%s hops=%d", - i + 1, flood_channel_blocks[i].name, packet->getPathHashCount()); - return true; - } - } - return false; + return max_hops == FLOOD_CHANNEL_HOPS_ALL || packet->getPathHashCount() > max_hops; } bool MyMesh::allowPacketForward(const mesh::Packet *packet) { if (_prefs.disable_fwd) return false; if (packet->isRouteFlood()) { - const bool trace = packet->getPayloadType() == PAYLOAD_TYPE_TRACE; if (packet->getPathHashCount() >= _prefs.flood_max) return false; - if (!trace && packet->getRouteType() == ROUTE_TYPE_FLOOD + if (packet->getRouteType() == ROUTE_TYPE_FLOOD && packet->getPathHashCount() >= _prefs.flood_max_unscoped) return false; if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && packet->getPathHashCount() >= _prefs.flood_max_advert) return false; #if !defined(PORTABLE_MQTT_OBSERVER) @@ -936,7 +901,6 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { return false; } if (shouldBlockFloodPacketForward(packet)) return false; - if (shouldBlockFloodChannelForward(packet)) return false; #endif } if (packet->isRouteFlood() && recv_pkt_channel_scope_rejected) { @@ -944,8 +908,8 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { "allowPacketForward: flood.channel.scope.require rejected incoming scope"); return false; } - if (packet->isRouteFlood() && packet->getPayloadType() != PAYLOAD_TYPE_TRACE - && recv_pkt_region == NULL && !recv_pkt_filter_scope_set + if (packet->isRouteFlood() && recv_pkt_region == NULL + && !recv_pkt_regionless_scope_set && !recv_pkt_channel_scope_bypass) { MESH_DEBUG_PRINTLN("allowPacketForward: unknown transport code, or wildcard not allowed for FLOOD packet"); return false; @@ -969,6 +933,7 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { // for a message this repeater will actually retransmit. #if !defined(PORTABLE_MQTT_OBSERVER) if (packet->isRouteFlood() && shouldBlockFloodGroupTextForward(packet)) return false; + if (packet->isRouteFlood()) commitFloodPacketFilterRates(packet); // Normal path mode accepts clock evidence only from packets this node would // forward. Edge mode observes verified evidence on the receive path instead, // so repeat off and other forwarding filters do not hide a single upstream @@ -1089,10 +1054,11 @@ bool MyMesh::evaluateScopeRewriteTiming(const mesh::Packet* packet, #else if (packet == NULL || !packet->isRouteFlood()) return false; - bool incoming_region_allowed = packet->getPayloadType() == PAYLOAD_TYPE_TRACE; + bool incoming_region_allowed = false; + RegionEntry* incoming_region = NULL; if (packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { - incoming_region_allowed = - region_map.findMatch(packet, REGION_DENY_FLOOD) != NULL; + incoming_region = region_map.findMatch(packet, REGION_DENY_FLOOD); + incoming_region_allowed = incoming_region != NULL; } else if (packet->getRouteType() == ROUTE_TYPE_FLOOD) { incoming_region_allowed = (region_map.getWildcard().flags & REGION_DENY_FLOOD) == 0; @@ -1116,10 +1082,14 @@ bool MyMesh::evaluateScopeRewriteTiming(const mesh::Packet* packet, } mesh::Packet candidate = *packet; - bool scope_changed = applyFloodChannelScope(&candidate, fast_track, false); + uint32_t filter_match_mask = evaluateFloodPacketFilterMatches( + packet, incoming_region_allowed, incoming_region); + bool channel_regionless_scope_set = false; + bool scope_changed = applyFloodChannelScope( + &candidate, fast_track, channel_regionless_scope_set, false); bool filter_scope_set = false; bool filter_fast_track = false; - if (applyFloodPacketFilterScope(&candidate, incoming_region_allowed, + if (applyFloodPacketFilterScope(&candidate, filter_match_mask, filter_scope_set, filter_fast_track, false)) { scope_changed = true; fast_track = filter_fast_track; @@ -2302,15 +2272,17 @@ 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_regionless_scope_set = false; recv_pkt_channel_scope_bypass = false; recv_pkt_channel_scope_rejected = false; + recv_pkt_filter_match_mask = 0; #if !defined(PORTABLE_MQTT_OBSERVER) if (pkt->isRouteFlood()) { - bool incoming_region_allowed = pkt->getPayloadType() == PAYLOAD_TYPE_TRACE; + bool incoming_region_allowed = false; + RegionEntry* incoming_region = NULL; if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { - incoming_region_allowed = - region_map.findMatch(pkt, REGION_DENY_FLOOD) != NULL; + incoming_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); + incoming_region_allowed = incoming_region != NULL; } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { incoming_region_allowed = (region_map.getWildcard().flags & REGION_DENY_FLOOD) == 0; @@ -2335,16 +2307,21 @@ mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { == FloodFilterPolicy::CHANNEL_SCOPE_REQUIRED_REJECTED; if (!recv_pkt_channel_scope_rejected) { + recv_pkt_filter_match_mask = evaluateFloodPacketFilterMatches( + pkt, incoming_region_allowed, incoming_region); + bool channel_regionless_scope_set = false; scope_changed = - applyFloodChannelScope(pkt, fast_track_scope_change); + applyFloodChannelScope(pkt, fast_track_scope_change, + channel_regionless_scope_set); bool filter_scope_set = false; bool filter_fast_track = false; - if (applyFloodPacketFilterScope(pkt, incoming_region_allowed, + if (applyFloodPacketFilterScope(pkt, recv_pkt_filter_match_mask, filter_scope_set, filter_fast_track)) { scope_changed = true; fast_track_scope_change = filter_fast_track; } - recv_pkt_filter_scope_set = filter_scope_set; + recv_pkt_regionless_scope_set = + channel_regionless_scope_set || filter_scope_set; } } #endif @@ -2563,36 +2540,59 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, if (!(flags == TXT_TYPE_PLAIN || flags == TXT_TYPE_CLI_DATA)) { MESH_DEBUG_PRINTLN("onPeerDataRecv: unsupported text type received: flags=%02x", (uint32_t)flags); - } else if (sender_timestamp >= client->last_timestamp) { // prevent replay attacks - bool is_retry = (sender_timestamp == client->last_timestamp); - client->last_timestamp = sender_timestamp; - client->last_activity = getRTCClock()->getCurrentTime(); - + } else { // len can be > original length, but 'text' will be padded with zeroes data[len] = 0; // need to make a C string again, with null terminator - if (flags == TXT_TYPE_PLAIN) { // for legacy CLI, send Acks - uint32_t ack_hash; // calc truncated hash of the message timestamp + text + sender pub_key, to prove - // to sender that we got it - mesh::Utils::sha256((uint8_t *)&ack_hash, 4, data, 5 + strlen((char *)&data[5]), client->id.pub_key, - PUB_KEY_SIZE); - - mesh::Packet *ack = createAck(ack_hash); - sendClientReply(client, ack, TXT_ACK_DELAY, packet->getPathHashSize()); - } - char *command = (char *)&data[5]; - if (!is_retry) { + size_t command_len = strlen(command); + uint32_t command_fingerprint = + mesh::RemoteCliReplyCache::fingerprint(command, command_len); + const bool cached_retry = + remote_cli_reply_cache.matches(client->id.pub_key, sender_timestamp, + command_fingerprint); + + // An old exact match may only replay its stored response. Any stale + // mismatch remains blocked by the normal timestamp replay guard. + if (sender_timestamp < client->last_timestamp && !cached_retry) { + MESH_DEBUG_PRINTLN("onPeerDataRecv: possible replay attack detected"); + } else { + const bool repeated_timestamp = sender_timestamp == client->last_timestamp; + if (sender_timestamp > client->last_timestamp) { + client->last_timestamp = sender_timestamp; + } + client->last_activity = getRTCClock()->getCurrentTime(); + + if (flags == TXT_TYPE_PLAIN) { // for legacy CLI, send Acks + uint32_t ack_hash; // calc truncated hash of the message timestamp + text + sender pub_key, to prove + // to sender that we got it + mesh::Utils::sha256((uint8_t *)&ack_hash, 4, data, 5 + command_len, + client->id.pub_key, PUB_KEY_SIZE); + + mesh::Packet *ack = createAck(ack_hash); + sendClientReply(client, ack, TXT_ACK_DELAY, packet->getPathHashSize()); + } + TransportKey reply_scope; const bool reply_scoped = recv_pkt_region != NULL && !recv_pkt_region->isWildcard() && region_map.getTransportKeysFor(*recv_pkt_region, &reply_scope, 1) > 0; - size_t command_len = strlen(command); - if (!deferred_cli_command.enqueue(i, sender_timestamp, packet->getPathHashSize(), - secret, command, command_len)) { + + if (cached_retry) { + MESH_DEBUG_PRINTLN("onPeerDataRecv: replaying cached remote CLI reply"); + sendRemoteCliReply(client, secret, packet->getPathHashSize(), + sender_timestamp, remote_cli_reply_cache.response(), + reply_scoped ? &reply_scope : NULL); + } else if (repeated_timestamp) { + MESH_DEBUG_PRINTLN("onPeerDataRecv: duplicate remote CLI request has no cached reply"); + } else if (!deferred_cli_command.enqueue(i, sender_timestamp, + packet->getPathHashSize(), secret, + command, command_len)) { const char* error = deferred_cli_command.pending ? "Err - another remote command is still running" : "Err - remote command is too long"; + remote_cli_reply_cache.remember(client->id.pub_key, sender_timestamp, + command_fingerprint, error); sendRemoteCliReply(client, secret, packet->getPathHashSize(), sender_timestamp, error, reply_scoped ? &reply_scope : NULL); @@ -2605,8 +2605,6 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, } } } - } else { - MESH_DEBUG_PRINTLN("onPeerDataRecv: possible replay attack detected"); } } } @@ -2691,9 +2689,16 @@ void __attribute__((noinline)) MyMesh::processDeferredCliCommand() { ClientInfo* client = acl.getClientByIdx(client_index); char* reply = (char*)&reply_data[5]; reply[0] = 0; + const uint32_t command_fingerprint = + mesh::RemoteCliReplyCache::fingerprint( + deferred_cli_command.command, + strlen(deferred_cli_command.command)); handleCommand(deferred_cli_command.sender_timestamp, client, deferred_cli_command.command, reply, client_index, deferred_cli_command.path_hash_size); + remote_cli_reply_cache.remember(client->id.pub_key, + deferred_cli_command.sender_timestamp, + command_fingerprint, reply); sendRemoteCliReply(client, deferred_cli_command.secret, deferred_cli_command.path_hash_size, deferred_cli_command.sender_timestamp, reply, @@ -2851,14 +2856,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_regionless_scope_set = false; recv_pkt_channel_scope_bypass = false; recv_pkt_channel_scope_rejected = false; - memset(flood_channel_blocks, 0, sizeof(flood_channel_blocks)); + recv_pkt_filter_match_mask = 0; 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_direct_scopes, 0, + sizeof(flood_channel_direct_scopes)); memset(flood_channel_scope_requirements, 0, sizeof(flood_channel_scope_requirements)); memset(flood_group_moderation, 0, sizeof(flood_group_moderation)); @@ -2931,8 +2938,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.flood_retry_bridge_enabled = 0; _prefs.flood_retry_advert_enabled = FLOOD_RETRY_ADVERT_DEFAULT; _prefs.flood_channel_data_enabled = 1; - _prefs.flood_channel_block_max_hops = FLOOD_CHANNEL_BLOCK_HOPS_ALL; - _prefs.flood_channel_data_max_hops = FLOOD_CHANNEL_BLOCK_HOPS_ALL; + _prefs.legacy_flood_channel_block_max_hops = FLOOD_CHANNEL_HOPS_ALL; + _prefs.flood_channel_data_max_hops = FLOOD_CHANNEL_HOPS_ALL; _prefs.battery_alert_enabled = 0; _prefs.battery_alert_low_percent = BATTERY_ALERT_LOW_PERCENT_DEFAULT; _prefs.battery_alert_critical_percent = BATTERY_ALERT_CRITICAL_PERCENT_DEFAULT; @@ -3018,9 +3025,13 @@ void MyMesh::begin(FILESYSTEM *fs) { // TODO: key_store.begin(); region_map.load(_fs); #if !defined(PORTABLE_MQTT_OBSERVER) - loadFloodChannelBlocks(); loadFloodPacketFilterBlacklist(); - loadFloodPacketFilters(); + bool flood_filters_loaded = loadFloodPacketFilters(); +#if MESH_ENABLE_FLOOD_RULE_ENGINE + if (flood_filters_loaded) migrateLegacyFloodChannelBlocks(); +#else + (void)flood_filters_loaded; +#endif loadFloodChannelScopes(); loadFloodChannelScopeRequirements(); loadFloodGroupModeration(); @@ -4237,120 +4248,6 @@ void MyMesh::onDefaultRegionChanged(const RegionEntry* r) { } } -void MyMesh::clearFloodChannelBlockEntry(FloodChannelBlockEntry& entry) { - memset(&entry, 0, sizeof(entry)); -} - -void MyMesh::deriveFloodChannelBlockPrefix(const uint8_t* secret, uint8_t key_len, - uint8_t prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]) const { - mesh::Utils::sha256(prefix, FLOOD_CHANNEL_BLOCK_PREFIX_LEN, secret, key_len); -} - -void MyMesh::seedDefaultFloodChannelBlocks() { - auto& entry = flood_channel_blocks[0]; - clearFloodChannelBlockEntry(entry); - entry.active = true; - entry.key_len = CIPHER_KEY_SIZE; - entry.max_hops = DEFAULT_FLOOD_CHANNEL_BLOCK_HOPS; - mesh::Utils::sha256(entry.secret, CIPHER_KEY_SIZE, - (const uint8_t*)DEFAULT_FLOOD_CHANNEL_BLOCK_NAME, - strlen(DEFAULT_FLOOD_CHANNEL_BLOCK_NAME)); - memset(&entry.secret[CIPHER_KEY_SIZE], 0, PUB_KEY_SIZE - CIPHER_KEY_SIZE); - deriveFloodChannelBlockPrefix(entry.secret, entry.key_len, entry.hash_prefix); - StrHelper::strncpy(entry.name, DEFAULT_FLOOD_CHANNEL_BLOCK_NAME, sizeof(entry.name)); -} - -void MyMesh::loadFloodChannelBlocks() { - memset(flood_channel_blocks, 0, sizeof(flood_channel_blocks)); - if (_fs == NULL) { - return; - } - if (!_fs->exists(FLOOD_CHANNEL_BLOCK_FILE)) { - seedDefaultFloodChannelBlocks(); - saveFloodChannelBlocks(); - return; - } - - File file = openFloodChannelBlockRead(_fs, FLOOD_CHANNEL_BLOCK_FILE); - if (!file) { - return; - } - - uint8_t magic[4]; - uint8_t count = 0; - bool success = file.read(magic, sizeof(magic)) == sizeof(magic) - && memcmp(magic, "FCB2", sizeof(magic)) == 0 - && file.read(&count, sizeof(count)) == sizeof(count); - - for (int i = 0; success && i < count && i < FLOOD_CHANNEL_BLOCK_SLOTS; i++) { - uint8_t active = 0; - uint8_t key_len = 0; - uint8_t max_hops = FLOOD_CHANNEL_BLOCK_HOPS_INHERIT; - uint8_t hash_prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]; - uint8_t secret[PUB_KEY_SIZE]; - char name[FLOOD_CHANNEL_BLOCK_NAME_LEN]; - - success = file.read(&active, sizeof(active)) == sizeof(active); - success = success && file.read(&key_len, sizeof(key_len)) == sizeof(key_len); - success = success && file.read(&max_hops, sizeof(max_hops)) == sizeof(max_hops); - success = success && file.read(hash_prefix, sizeof(hash_prefix)) == sizeof(hash_prefix); - success = success && file.read(secret, sizeof(secret)) == sizeof(secret); - success = success && file.read((uint8_t*)name, sizeof(name)) == sizeof(name); - if (!success) { - break; - } - - name[sizeof(name) - 1] = 0; - if (active && (key_len == CIPHER_KEY_SIZE || key_len == PUB_KEY_SIZE) && name[0] != 0) { - auto& entry = flood_channel_blocks[i]; - entry.active = true; - entry.key_len = key_len; - entry.max_hops = (max_hops == FLOOD_CHANNEL_BLOCK_HOPS_ALL - || max_hops == FLOOD_CHANNEL_BLOCK_HOPS_INHERIT - || (max_hops >= 1 && max_hops <= 7)) ? max_hops : FLOOD_CHANNEL_BLOCK_HOPS_INHERIT; - 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); - } - deriveFloodChannelBlockPrefix(entry.secret, entry.key_len, entry.hash_prefix); - StrHelper::strncpy(entry.name, name, sizeof(entry.name)); - } - } - - file.close(); -} - -bool MyMesh::saveFloodChannelBlocks() { - if (_fs == NULL) { - return false; - } - - File file = openFloodChannelBlockWrite(_fs, FLOOD_CHANNEL_BLOCK_FILE); - if (!file) { - return false; - } - - const uint8_t magic[4] = {'F', 'C', 'B', '2'}; - uint8_t count = FLOOD_CHANNEL_BLOCK_SLOTS; - bool success = file.write(magic, sizeof(magic)) == sizeof(magic); - success = success && file.write(&count, sizeof(count)) == sizeof(count); - - for (int i = 0; success && i < FLOOD_CHANNEL_BLOCK_SLOTS; i++) { - const auto& entry = flood_channel_blocks[i]; - uint8_t active = entry.active ? 1 : 0; - uint8_t max_hops = entry.active ? entry.max_hops : FLOOD_CHANNEL_BLOCK_HOPS_INHERIT; - success = file.write(&active, sizeof(active)) == sizeof(active); - success = success && file.write(&entry.key_len, sizeof(entry.key_len)) == sizeof(entry.key_len); - success = success && file.write(&max_hops, sizeof(max_hops)) == sizeof(max_hops); - success = success && file.write(entry.hash_prefix, sizeof(entry.hash_prefix)) == sizeof(entry.hash_prefix); - success = success && file.write(entry.secret, sizeof(entry.secret)) == sizeof(entry.secret); - success = success && file.write((const uint8_t*)entry.name, sizeof(entry.name)) == sizeof(entry.name); - } - - file.close(); - return success; -} - static const char* skipFloodFilterSpaces(const char* text) { while (text != NULL && *text == ' ') text++; return text == NULL ? "" : text; @@ -4523,9 +4420,23 @@ static bool normalizeFloodFilterScopeName(const char* text, char* dest, size_t d } static bool isValidStoredFloodFilterScopeName(const char* text) { - char normalized[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN]; - return normalizeFloodFilterScopeName(text, normalized, sizeof(normalized)) - && strcmp(text, normalized) == 0; + if (text[0] != '#' || text[1] == 0) return false; + for (size_t i = 1; i < FLOOD_PACKET_FILTER_SCOPE_NAME_LEN; i++) { + uint8_t c = (uint8_t)text[i]; + if (c == 0) return true; + if (!RegionMap::is_name_char(c) || c == '#' || c == '$') return false; + } + return false; +} + +static bool isValidStoredFloodRuleRegionName(const char* text) { + if (text == NULL || text[0] == 0 || strcmp(text, "*") == 0) return false; + for (size_t i = 0; i < FLOOD_PACKET_FILTER_SCOPE_NAME_LEN; i++) { + uint8_t c = (uint8_t)text[i]; + if (c == 0) return true; + if (!RegionMap::is_name_char(c)) return false; + } + return false; } static void deriveFloodFilterScopeKey(const char* scope_name, TransportKey& scope) { @@ -4533,20 +4444,6 @@ static void deriveFloodFilterScopeKey(const char* scope_name, TransportKey& scop (const uint8_t*)scope_name, strlen(scope_name)); } -static uint8_t floodPacketFilterProtectedHops(const mesh::Packet* packet) { - if (packet == NULL) return 0; - switch (packet->getPayloadType()) { - case PAYLOAD_TYPE_RESPONSE: - case PAYLOAD_TYPE_ANON_REQ: - case PAYLOAD_TYPE_PATH: - return FLOOD_PACKET_FILTER_LOGIN_PROTECTED_HOPS; - case PAYLOAD_TYPE_TXT_MSG: - return FLOOD_PACKET_FILTER_TXT_MSG_PROTECTED_HOPS; - default: - return 0; - } -} - static bool parseFloodPacketFilterBlacklist( const char* text, uint8_t ids[FLOOD_PACKET_FILTER_BLACKLIST_REPLACE_MAX] @@ -4593,7 +4490,7 @@ void MyMesh::loadFloodPacketFilterBlacklist() { 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); + File file = openFloodSettingsRead(_fs, FLOOD_PACKET_FILTER_BLACKLIST_FILE); if (!file) return; uint8_t magic[4]; @@ -4624,7 +4521,7 @@ void MyMesh::loadFloodPacketFilterBlacklist() { bool MyMesh::saveFloodPacketFilterBlacklist() { if (_fs == NULL) return false; - File file = openFloodChannelBlockWrite(_fs, FLOOD_PACKET_FILTER_BLACKLIST_FILE); + File file = openFloodSettingsWrite(_fs, FLOOD_PACKET_FILTER_BLACKLIST_FILE); if (!file) return false; const uint8_t magic[4] = {'F', 'B', 'L', '1'}; @@ -4829,6 +4726,19 @@ void MyMesh::deleteFloodPacketFilterBlacklist(const char* args, char* reply) { } } +static bool parseFloodModerationUnsigned(const char* text, uint32_t maximum, + uint32_t& value); +static bool parseFloodModerationChannel( + const char* text, uint8_t secret[PUB_KEY_SIZE], uint8_t& key_len, + uint8_t hash_prefix[FLOOD_CHANNEL_KEY_PREFIX_LEN], char* name, + size_t name_len); +static bool parseFloodModerationPath( + const char* text, uint8_t& hash_size, uint8_t& path_hops, + uint8_t path[FLOOD_GROUP_MODERATION_PATH_BYTES_MAX]); +static void formatFloodModerationPath( + char* dest, size_t dest_len, uint8_t hash_size, uint8_t path_hops, + const uint8_t path[FLOOD_GROUP_MODERATION_PATH_BYTES_MAX]); + void MyMesh::seedDefaultFloodPacketFilters() { auto& entry = flood_packet_filters[0]; memset(&entry, 0, sizeof(entry)); @@ -4837,36 +4747,496 @@ void MyMesh::seedDefaultFloodPacketFilters() { entry.min_hops = 0; entry.max_hops = FLOOD_PACKET_FILTER_MAX_HOPS; entry.suspend_on_temp_radio = true; +#if MESH_ENABLE_FLOOD_RULE_ENGINE + entry.drop_on_match = true; + + // Preserve the former channel-block default as a normal FPF7 rule. Channel + // authentication limits this any-type row to GRP_TXT and GRP_DATA packets. + auto& wardriving = flood_packet_filters[1]; + memset(&wardriving, 0, sizeof(wardriving)); + wardriving.active = true; + wardriving.payload_type = FLOOD_PACKET_FILTER_ANY_TYPE; + wardriving.min_hops = DEFAULT_WARDRIVING_MAX_HOPS + 1; + wardriving.max_hops = FLOOD_PACKET_FILTER_MAX_HOPS; + wardriving.channel_key_len = CIPHER_KEY_SIZE; + mesh::Utils::sha256( + wardriving.channel_secret, CIPHER_KEY_SIZE, + (const uint8_t*)DEFAULT_WARDRIVING_CHANNEL, + strlen(DEFAULT_WARDRIVING_CHANNEL)); + mesh::Utils::sha256( + &wardriving.channel_hash, sizeof(wardriving.channel_hash), + wardriving.channel_secret, wardriving.channel_key_len); + StrHelper::strncpy(wardriving.channel_name, DEFAULT_WARDRIVING_CHANNEL, + sizeof(wardriving.channel_name)); + wardriving.drop_on_match = true; +#endif } -void MyMesh::loadFloodPacketFilters() { +#if MESH_ENABLE_FLOOD_RULE_ENGINE +bool MyMesh::loadFloodPacketFilters() { memset(flood_packet_filters, 0, sizeof(flood_packet_filters)); if (_fs == NULL) { seedDefaultFloodPacketFilters(); - return; + return true; } if (!_fs->exists(FLOOD_PACKET_FILTER_FILE)) { seedDefaultFloodPacketFilters(); - saveFloodPacketFilters(); - return; + return saveFloodPacketFilters(); } - File file = openFloodChannelBlockRead(_fs, FLOOD_PACKET_FILTER_FILE); - if (!file) return; + File file = openFloodSettingsRead(_fs, FLOOD_PACKET_FILTER_FILE); + if (!file) return false; - FloodPacketFilterEntry loaded[FLOOD_PACKET_FILTER_SLOTS]; - memset(loaded, 0, sizeof(loaded)); + FloodPacketFilterEntry* loaded = flood_packet_filters; uint8_t magic[4]; uint8_t count = 0; 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; - 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) + bool version_7 = success && memcmp(magic, "FPF7", sizeof(magic)) == 0; + success = (version_6 || version_7) + && 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; + uint8_t drop_on_match = 0; + uint8_t rate_limit_enabled = 0; + uint8_t stop_on_match = 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); + success = success && file.read(&suspend_on_temp_radio, + sizeof(suspend_on_temp_radio)) + == sizeof(suspend_on_temp_radio); + success = 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; + success = success && file.read(&match_blacklisted_path, + sizeof(match_blacklisted_path)) + == sizeof(match_blacklisted_path); + success = success && file.read(&scope_requires_region_match, + sizeof(scope_requires_region_match)) + == sizeof(scope_requires_region_match); + success = success && file.read(&scope_uses_slow_timing, + sizeof(scope_uses_slow_timing)) + == sizeof(scope_uses_slow_timing); + if (success && version_7) { + success = file.read(&loaded[i].incoming_scope_kind, + sizeof(loaded[i].incoming_scope_kind)) + == sizeof(loaded[i].incoming_scope_kind); + success = success + && file.read((uint8_t*)loaded[i].incoming_scope_name, + sizeof(loaded[i].incoming_scope_name)) + == sizeof(loaded[i].incoming_scope_name); + success = success + && file.read(&loaded[i].channel_key_len, + sizeof(loaded[i].channel_key_len)) + == sizeof(loaded[i].channel_key_len); + success = success + && file.read(loaded[i].channel_secret, + sizeof(loaded[i].channel_secret)) + == sizeof(loaded[i].channel_secret); + success = success + && file.read((uint8_t*)loaded[i].channel_name, + sizeof(loaded[i].channel_name)) + == sizeof(loaded[i].channel_name); + success = success + && file.read(&loaded[i].path_hash_size, + sizeof(loaded[i].path_hash_size)) + == sizeof(loaded[i].path_hash_size); + success = success + && file.read(&loaded[i].path_hops, + sizeof(loaded[i].path_hops)) + == sizeof(loaded[i].path_hops); + success = success + && file.read(loaded[i].path, sizeof(loaded[i].path)) + == sizeof(loaded[i].path); + success = success + && file.read(&drop_on_match, sizeof(drop_on_match)) + == sizeof(drop_on_match); + success = success + && file.read(&rate_limit_enabled, sizeof(rate_limit_enabled)) + == sizeof(rate_limit_enabled); + success = success + && file.read((uint8_t*)&loaded[i].rate_per_minute, + sizeof(loaded[i].rate_per_minute)) + == sizeof(loaded[i].rate_per_minute); + success = success + && file.read((uint8_t*)loaded[i].target_region_name, + sizeof(loaded[i].target_region_name)) + == sizeof(loaded[i].target_region_name); + success = success + && file.read(&loaded[i].priority, sizeof(loaded[i].priority)) + == sizeof(loaded[i].priority); + success = success + && file.read(&stop_on_match, sizeof(stop_on_match)) + == sizeof(stop_on_match); + } else { + loaded[i].incoming_scope_kind = scope_requires_region_match + ? FloodFilterPolicy::RULE_IN_ALLOWED + : FloodFilterPolicy::RULE_IN_ANY; + drop_on_match = loaded[i].scope_name[0] == 0 ? 1 : 0; + } + 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_uses_slow_timing = scope_uses_slow_timing != 0; + loaded[i].drop_on_match = drop_on_match != 0; + loaded[i].rate_limit_enabled = rate_limit_enabled != 0; + loaded[i].stop_on_match = stop_on_match != 0; + if (success && (active > 1 || suspend_on_temp_radio > 1 + || match_blacklisted_path > 1 || scope_requires_region_match > 1 + || scope_uses_slow_timing > 1 || drop_on_match > 1 + || rate_limit_enabled > 1 || stop_on_match > 1)) { + success = false; + } + if (!success) break; + if (!loaded[i].active) { + memset(&loaded[i], 0, sizeof(loaded[i])); + continue; + } + + bool direct_target = loaded[i].scope_name[0] != 0; + bool target_name_terminated = memchr( + loaded[i].target_region_name, 0, + sizeof(loaded[i].target_region_name)) != NULL; + bool region_target = target_name_terminated + && loaded[i].target_region_name[0] != 0; + bool input_name_terminated = memchr( + loaded[i].incoming_scope_name, 0, + sizeof(loaded[i].incoming_scope_name)) != NULL; + bool channel_name_terminated = memchr( + loaded[i].channel_name, 0, sizeof(loaded[i].channel_name)) != NULL; + bool incoming_valid = loaded[i].incoming_scope_kind + <= FloodFilterPolicy::RULE_IN_REGION; + if (incoming_valid + && loaded[i].incoming_scope_kind == FloodFilterPolicy::RULE_IN_SCOPE) { + incoming_valid = input_name_terminated + && isValidStoredFloodFilterScopeName( + loaded[i].incoming_scope_name); + } else if (incoming_valid + && loaded[i].incoming_scope_kind == FloodFilterPolicy::RULE_IN_REGION) { + incoming_valid = input_name_terminated + && isValidStoredFloodRuleRegionName( + loaded[i].incoming_scope_name); + } else if (incoming_valid) { + incoming_valid = input_name_terminated + && loaded[i].incoming_scope_name[0] == 0; + } + + bool channel_valid = loaded[i].channel_key_len == 0 + || loaded[i].channel_key_len == CIPHER_KEY_SIZE + || loaded[i].channel_key_len == PUB_KEY_SIZE; + if (channel_valid && loaded[i].channel_key_len == 0) { + channel_valid = channel_name_terminated + && loaded[i].channel_name[0] == 0; + } else if (channel_valid) { + channel_valid = channel_name_terminated + && loaded[i].channel_name[0] != 0; + if (channel_valid) { + mesh::Utils::sha256(&loaded[i].channel_hash, + sizeof(loaded[i].channel_hash), + loaded[i].channel_secret, + loaded[i].channel_key_len); + } + } + if (channel_valid && loaded[i].channel_key_len != 0) { + channel_valid = loaded[i].payload_type == FLOOD_PACKET_FILTER_ANY_TYPE + || loaded[i].payload_type == PAYLOAD_TYPE_GRP_TXT + || loaded[i].payload_type == PAYLOAD_TYPE_GRP_DATA; + } + + bool path_valid = (loaded[i].path_hash_size == 0 + && loaded[i].path_hops == 0) + || (!loaded[i].match_blacklisted_path + && loaded[i].path_hash_size >= 1 + && loaded[i].path_hash_size <= 3 + && loaded[i].path_hops >= 1 + && loaded[i].path_hops + <= FLOOD_PACKET_FILTER_PATH_PREFIX_HOPS_MAX); + bool action_valid = loaded[i].drop_on_match || direct_target + || region_target || loaded[i].rate_limit_enabled + || loaded[i].stop_on_match; + if (!((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 + && (!direct_target + || isValidStoredFloodFilterScopeName(loaded[i].scope_name)) + && target_name_terminated + && (!region_target + || isValidStoredFloodRuleRegionName( + loaded[i].target_region_name)) + && !(direct_target && region_target) + && !(loaded[i].drop_on_match && (direct_target || region_target)) + && !(loaded[i].drop_on_match && loaded[i].rate_limit_enabled) + && (!loaded[i].rate_limit_enabled + || loaded[i].rate_per_minute + < FLOOD_GROUP_MODERATION_RATE_UNLIMITED) + && (!loaded[i].scope_uses_slow_timing + || direct_target || region_target) + && incoming_valid && channel_valid && path_valid + && action_valid)) { + success = false; + } + } + file.close(); + + // A truncated or invalid file fails open; filtering must never be enabled by corrupt bytes. + if (!success) memset(flood_packet_filters, 0, sizeof(flood_packet_filters)); + return success; +} + +void MyMesh::migrateLegacyFloodChannelBlocks() { + if (_fs == NULL || !_fs->exists(LEGACY_FLOOD_CHANNEL_BLOCK_FILE)) { + return; + } + + struct LegacyRow { + uint8_t active; + uint8_t key_len; + uint8_t max_hops; + uint8_t secret[PUB_KEY_SIZE]; + char name[LEGACY_FLOOD_CHANNEL_BLOCK_NAME_LEN]; + }; + LegacyRow rows[LEGACY_FLOOD_CHANNEL_BLOCK_SLOTS]; + memset(rows, 0, sizeof(rows)); + + File file = openFloodSettingsRead(_fs, LEGACY_FLOOD_CHANNEL_BLOCK_FILE); + if (!file) return; + + uint8_t magic[4]; + uint8_t count = 0; + bool success = file.read(magic, sizeof(magic)) == sizeof(magic) + && memcmp(magic, "FCB2", sizeof(magic)) == 0 + && file.read(&count, sizeof(count)) == sizeof(count) + && count <= LEGACY_FLOOD_CHANNEL_BLOCK_SLOTS; + for (int i = 0; success && i < count; i++) { + uint8_t ignored_hash_prefix[FLOOD_CHANNEL_KEY_PREFIX_LEN]; + success = file.read(&rows[i].active, sizeof(rows[i].active)) + == sizeof(rows[i].active) + && file.read(&rows[i].key_len, sizeof(rows[i].key_len)) + == sizeof(rows[i].key_len) + && file.read(&rows[i].max_hops, sizeof(rows[i].max_hops)) + == sizeof(rows[i].max_hops) + && file.read(ignored_hash_prefix, sizeof(ignored_hash_prefix)) + == sizeof(ignored_hash_prefix) + && file.read(rows[i].secret, sizeof(rows[i].secret)) + == sizeof(rows[i].secret) + && file.read((uint8_t*)rows[i].name, sizeof(rows[i].name)) + == sizeof(rows[i].name); + if (!success || rows[i].active > 1) break; + if (rows[i].active + && !((rows[i].key_len == CIPHER_KEY_SIZE + || rows[i].key_len == PUB_KEY_SIZE) + && memchr(rows[i].name, 0, sizeof(rows[i].name)) != NULL + && rows[i].name[0] != 0 + && (rows[i].max_hops == FLOOD_CHANNEL_HOPS_ALL + || rows[i].max_hops + == LEGACY_FLOOD_CHANNEL_BLOCK_HOPS_INHERIT + || (rows[i].max_hops >= 1 && rows[i].max_hops <= 7)))) { + success = false; + } + } + file.close(); + if (!success) { + MESH_DEBUG_PRINTLN("legacy channel-block migration skipped: invalid file"); + return; + } + + uint8_t append_slots[LEGACY_FLOOD_CHANNEL_BLOCK_SLOTS]; + uint8_t append_count = 0; + for (int row_index = 0; row_index < count; row_index++) { + const LegacyRow& row = rows[row_index]; + if (!row.active) continue; + + uint8_t effective_max_hops = row.max_hops; + if (effective_max_hops == LEGACY_FLOOD_CHANNEL_BLOCK_HOPS_INHERIT) { + effective_max_hops = _prefs.legacy_flood_channel_block_max_hops; + } + uint8_t min_hops = effective_max_hops == FLOOD_CHANNEL_HOPS_ALL + ? 0 : effective_max_hops + 1; + + bool duplicate = false; + int free_slot = -1; + for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + const auto& entry = flood_packet_filters[i]; + if (!entry.active) { + if (free_slot < 0) free_slot = i; + continue; + } + duplicate = entry.payload_type == FLOOD_PACKET_FILTER_ANY_TYPE + && entry.min_hops == min_hops + && entry.max_hops == FLOOD_PACKET_FILTER_MAX_HOPS + && !entry.suspend_on_temp_radio + && entry.scope_name[0] == 0 + && !entry.match_blacklisted_path + && !entry.scope_uses_slow_timing + && entry.incoming_scope_kind == FloodFilterPolicy::RULE_IN_ANY + && entry.incoming_scope_name[0] == 0 + && entry.channel_key_len == row.key_len + && memcmp(entry.channel_secret, row.secret, row.key_len) == 0 + && entry.path_hash_size == 0 && entry.path_hops == 0 + && entry.target_region_name[0] == 0 + && entry.drop_on_match && !entry.rate_limit_enabled + && !entry.stop_on_match; + if (duplicate) break; + } + if (duplicate) continue; + if (free_slot < 0) { + for (int i = 0; i < append_count; i++) { + memset(&flood_packet_filters[append_slots[i]], 0, + sizeof(flood_packet_filters[append_slots[i]])); + } + MESH_DEBUG_PRINTLN( + "legacy channel-block migration skipped: FPF7 table full"); + return; + } + + auto& entry = flood_packet_filters[free_slot]; + memset(&entry, 0, sizeof(entry)); + entry.active = true; + entry.payload_type = FLOOD_PACKET_FILTER_ANY_TYPE; + entry.min_hops = min_hops; + entry.max_hops = FLOOD_PACKET_FILTER_MAX_HOPS; + entry.incoming_scope_kind = FloodFilterPolicy::RULE_IN_ANY; + entry.channel_key_len = row.key_len; + memcpy(entry.channel_secret, row.secret, sizeof(entry.channel_secret)); + if (entry.channel_key_len == CIPHER_KEY_SIZE) { + memset(&entry.channel_secret[CIPHER_KEY_SIZE], 0, + PUB_KEY_SIZE - CIPHER_KEY_SIZE); + } + mesh::Utils::sha256(&entry.channel_hash, sizeof(entry.channel_hash), + entry.channel_secret, entry.channel_key_len); + StrHelper::strncpy(entry.channel_name, row.name, + sizeof(entry.channel_name)); + entry.drop_on_match = true; + append_slots[append_count++] = (uint8_t)free_slot; + } + + if (append_count != 0 && !saveFloodPacketFilters()) { + for (int i = 0; i < append_count; i++) { + memset(&flood_packet_filters[append_slots[i]], 0, + sizeof(flood_packet_filters[append_slots[i]])); + } + MESH_DEBUG_PRINTLN("legacy channel-block migration skipped: save failed"); + return; + } + + // Once every active legacy row exists in FPF7, the retired file is no + // longer needed. A failed remove is harmless because duplicate detection + // makes the migration idempotent. + _fs->remove(LEGACY_FLOOD_CHANNEL_BLOCK_FILE); +} + +bool MyMesh::saveFloodPacketFilters() { + if (_fs == NULL) return false; + File file = openFloodSettingsWrite(_fs, FLOOD_PACKET_FILTER_FILE); + if (!file) return false; + + const uint8_t magic[4] = {'F', 'P', 'F', '7'}; + uint8_t count = FLOOD_PACKET_FILTER_SLOTS; + bool success = file.write(magic, sizeof(magic)) == sizeof(magic) + && file.write(&count, sizeof(count)) == sizeof(count); + for (int i = 0; success && i < FLOOD_PACKET_FILTER_SLOTS; i++) { + 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.incoming_scope_kind == FloodFilterPolicy::RULE_IN_ALLOWED + ? 1 : 0; + uint8_t scope_uses_slow_timing = + entry.scope_uses_slow_timing ? 1 : 0; + uint8_t drop_on_match = entry.drop_on_match ? 1 : 0; + uint8_t rate_limit_enabled = entry.rate_limit_enabled ? 1 : 0; + uint8_t stop_on_match = entry.stop_on_match ? 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); + success = success && file.write(&entry.incoming_scope_kind, + sizeof(entry.incoming_scope_kind)) + == sizeof(entry.incoming_scope_kind); + success = success && file.write( + (const uint8_t*)entry.incoming_scope_name, + sizeof(entry.incoming_scope_name)) == sizeof(entry.incoming_scope_name); + success = success && file.write(&entry.channel_key_len, + sizeof(entry.channel_key_len)) + == sizeof(entry.channel_key_len); + success = success && file.write(entry.channel_secret, + sizeof(entry.channel_secret)) + == sizeof(entry.channel_secret); + success = success && file.write((const uint8_t*)entry.channel_name, + sizeof(entry.channel_name)) + == sizeof(entry.channel_name); + success = success && file.write(&entry.path_hash_size, + sizeof(entry.path_hash_size)) + == sizeof(entry.path_hash_size); + success = success && file.write(&entry.path_hops, + sizeof(entry.path_hops)) + == sizeof(entry.path_hops); + success = success && file.write(entry.path, sizeof(entry.path)) + == sizeof(entry.path); + success = success && file.write(&drop_on_match, sizeof(drop_on_match)) + == sizeof(drop_on_match); + success = success && file.write(&rate_limit_enabled, + sizeof(rate_limit_enabled)) + == sizeof(rate_limit_enabled); + success = success && file.write((const uint8_t*)&entry.rate_per_minute, + sizeof(entry.rate_per_minute)) + == sizeof(entry.rate_per_minute); + success = success && file.write( + (const uint8_t*)entry.target_region_name, + sizeof(entry.target_region_name)) == sizeof(entry.target_region_name); + success = success && file.write(&entry.priority, sizeof(entry.priority)) + == sizeof(entry.priority); + success = success && file.write(&stop_on_match, sizeof(stop_on_match)) + == sizeof(stop_on_match); + } + file.close(); + return success; +} +#else +bool MyMesh::loadFloodPacketFilters() { + memset(flood_packet_filters, 0, sizeof(flood_packet_filters)); + if (_fs == NULL) { + seedDefaultFloodPacketFilters(); + return true; + } + if (!_fs->exists(FLOOD_PACKET_FILTER_FILE)) { + seedDefaultFloodPacketFilters(); + return saveFloodPacketFilters(); + } + + File file = openFloodSettingsRead(_fs, FLOOD_PACKET_FILTER_FILE); + if (!file) return false; + + FloodPacketFilterEntry* loaded = flood_packet_filters; + uint8_t magic[4]; + uint8_t count = 0; + bool success = file.read(magic, sizeof(magic)) == sizeof(magic); + bool version_6 = success && memcmp(magic, "FPF6", sizeof(magic)) == 0; + success = version_6 && file.read(&count, sizeof(count)) == sizeof(count) && count <= FLOOD_PACKET_FILTER_SLOTS; @@ -4880,31 +5250,23 @@ void MyMesh::loadFloodPacketFilters() { 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 || 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); - } + success = success && file.read(&suspend_on_temp_radio, + sizeof(suspend_on_temp_radio)) + == sizeof(suspend_on_temp_radio); + success = 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; + success = success && file.read(&match_blacklisted_path, + sizeof(match_blacklisted_path)) + == sizeof(match_blacklisted_path); + success = success && file.read(&scope_requires_region_match, + sizeof(scope_requires_region_match)) + == sizeof(scope_requires_region_match); + success = 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; @@ -4941,12 +5303,13 @@ void MyMesh::loadFloodPacketFilters() { file.close(); // A truncated or invalid file fails open; filtering must never be enabled by corrupt bytes. - if (success) memcpy(flood_packet_filters, loaded, sizeof(flood_packet_filters)); + if (!success) memset(flood_packet_filters, 0, sizeof(flood_packet_filters)); + return success; } bool MyMesh::saveFloodPacketFilters() { if (_fs == NULL) return false; - File file = openFloodChannelBlockWrite(_fs, FLOOD_PACKET_FILTER_FILE); + File file = openFloodSettingsWrite(_fs, FLOOD_PACKET_FILTER_FILE); if (!file) return false; const uint8_t magic[4] = {'F', 'P', 'F', '6'}; @@ -4982,6 +5345,7 @@ bool MyMesh::saveFloodPacketFilters() { file.close(); return success; } +#endif bool MyMesh::floodPacketFilterBlacklistMatches(const mesh::Packet* packet) const { static_assert(FLOOD_PACKET_FILTER_PATH_ID_SIZE @@ -4992,29 +5356,141 @@ bool MyMesh::floodPacketFilterBlacklistMatches(const mesh::Packet* packet) const flood_packet_filter_blacklist_count); } -bool MyMesh::floodPacketFilterMatches(const FloodPacketFilterEntry& entry, - const mesh::Packet* packet) const { +#if MESH_ENABLE_FLOOD_RULE_ENGINE +bool MyMesh::floodPacketFilterFieldsMatch( + const FloodPacketFilterEntry& entry, const mesh::Packet* packet, + bool incoming_is_scoped, uint16_t incoming_transport_code, + bool incoming_region_allowed, + const RegionEntry* incoming_region) 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) { + if (!FloodFilterPolicy::pathStartsWith( + packet, entry.path_hash_size, entry.path_hops, entry.path)) { return false; } + + uint8_t type = packet->getPayloadType(); 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; } + if (entry.incoming_scope_kind == FloodFilterPolicy::RULE_IN_REGION) { + if (incoming_region == NULL + || !RegionNameUtils::equivalent( + entry.incoming_scope_name, incoming_region->name)) { + return false; + } + } else { + uint16_t wanted_transport_code = 0; + if (entry.incoming_scope_kind == FloodFilterPolicy::RULE_IN_SCOPE) { + TransportKey incoming_scope; + deriveFloodFilterScopeKey(entry.incoming_scope_name, incoming_scope); + wanted_transport_code = incoming_scope.calcTransportCode(packet); + } + if (!FloodFilterPolicy::ruleIncomingScopeMatches( + entry.incoming_scope_kind, incoming_is_scoped, + incoming_transport_code, incoming_region_allowed, + wanted_transport_code)) { + return false; + } + } + + if (entry.channel_key_len != 0) { + if ((type != PAYLOAD_TYPE_GRP_TXT && type != PAYLOAD_TYPE_GRP_DATA) + || packet->payload_len + < PATH_HASH_SIZE + CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE + || ((packet->payload_len - PATH_HASH_SIZE - CIPHER_MAC_SIZE) + % CIPHER_BLOCK_SIZE) != 0 + || packet->payload[0] != entry.channel_hash) { + return false; + } + } + return true; } +bool MyMesh::authenticateFloodPacketFilterChannel( + const FloodPacketFilterEntry& entry, + const mesh::Packet* packet) const { + if (entry.channel_key_len == 0) return true; + uint8_t data[MAX_PACKET_PAYLOAD]; + return mesh::Utils::MACThenDecrypt( + entry.channel_secret, data, &packet->payload[PATH_HASH_SIZE], + packet->payload_len - PATH_HASH_SIZE) > 0; +} + +int MyMesh::nextFloodPacketFilterMatch(uint32_t match_mask, + uint32_t visited_mask) const { + uint8_t priorities[FLOOD_PACKET_FILTER_SLOTS]; + for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + priorities[i] = flood_packet_filters[i].priority; + } + return FloodFilterPolicy::nextOrderedRule( + match_mask, visited_mask, priorities, FLOOD_PACKET_FILTER_SLOTS); +} + +uint32_t MyMesh::applyFloodPacketFilterStop(uint32_t match_mask) const { + uint8_t priorities[FLOOD_PACKET_FILTER_SLOTS]; + uint8_t stop_flags[FLOOD_PACKET_FILTER_SLOTS]; + for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + priorities[i] = flood_packet_filters[i].priority; + stop_flags[i] = flood_packet_filters[i].stop_on_match ? 1 : 0; + } + return FloodFilterPolicy::truncateRulesAtStop( + match_mask, priorities, stop_flags, FLOOD_PACKET_FILTER_SLOTS); +} + +uint32_t MyMesh::evaluateFloodPacketFilterMatches( + const mesh::Packet* packet, bool incoming_region_allowed, + const RegionEntry* incoming_region) const { + static_assert(FLOOD_PACKET_FILTER_SLOTS <= 32, + "flood filter match mask supports at most 32 slots"); + if (packet == NULL || !packet->isRouteFlood()) return 0; + bool incoming_is_scoped = + packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD; + uint16_t incoming_transport_code = incoming_is_scoped + ? packet->transport_codes[0] : 0; + bool channel_auth_checked[FLOOD_PACKET_FILTER_SLOTS] = { false }; + bool channel_auth_valid[FLOOD_PACKET_FILTER_SLOTS] = { false }; + uint32_t matches = 0; + for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + const auto& entry = flood_packet_filters[i]; + if (!floodPacketFilterFieldsMatch( + entry, packet, incoming_is_scoped, incoming_transport_code, + incoming_region_allowed, incoming_region)) { + continue; + } + bool authenticated = true; + if (entry.channel_key_len != 0) { + int cached = -1; + for (int j = 0; j < i; j++) { + if (channel_auth_checked[j] + && FloodFilterPolicy::sameChannelKey( + flood_packet_filters[j].channel_key_len, + flood_packet_filters[j].channel_secret, + entry.channel_key_len, entry.channel_secret)) { + cached = j; + break; + } + } + authenticated = cached >= 0 + ? channel_auth_valid[cached] + : authenticateFloodPacketFilterChannel(entry, packet); + channel_auth_checked[i] = true; + channel_auth_valid[i] = authenticated; + } + if (authenticated) matches |= (uint32_t)1U << i; + } + return applyFloodPacketFilterStop(matches); +} + bool MyMesh::applyFloodPacketFilterScope(mesh::Packet* packet, - bool incoming_region_allowed, + uint32_t match_mask, bool& scope_set, bool& fast_track, bool log_change) { @@ -5022,16 +5498,31 @@ bool MyMesh::applyFloodPacketFilterScope(mesh::Packet* packet, fast_track = false; if (packet == NULL || !packet->isRouteFlood()) return false; - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + uint32_t visited = 0; + while (true) { + int i = nextFloodPacketFilterMatch(match_mask, visited); + if (i < 0) break; + visited |= (uint32_t)1U << 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)) { + if (entry.scope_name[0] == 0 + && entry.target_region_name[0] == 0) { continue; } TransportKey scope; - deriveFloodFilterScopeKey(entry.scope_name, scope); + const char* target_name = entry.scope_name; + if (entry.scope_name[0] != 0) { + deriveFloodFilterScopeKey(entry.scope_name, scope); + } else { + RegionEntry* region = region_map.findByName(entry.target_region_name); + if (region == NULL || region->isWildcard() + || (region->flags & REGION_DENY_FLOOD) != 0 + || region_map.getTransportKeysFor(*region, &scope, 1) <= 0 + || scope.isNull()) { + continue; + } + target_name = region->name; + } uint16_t transport_code = scope.calcTransportCode(packet); bool scope_changed = FloodFilterPolicy::setTransportScope(packet, transport_code); @@ -5042,7 +5533,7 @@ bool MyMesh::applyFloodPacketFilterScope(mesh::Packet* packet, 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, + target_name, entry.scope_uses_slow_timing ? "slow" : "fast"); } return scope_changed; @@ -5055,30 +5546,812 @@ bool MyMesh::shouldBlockFloodPacketForward(const mesh::Packet* packet) const { uint8_t type = packet->getPayloadType(); uint8_t hops = packet->getPathHashCount(); - // 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 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", - type, hops); - return false; - } - - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + uint32_t visited = 0; + while (true) { + int i = nextFloodPacketFilterMatch(recv_pkt_filter_match_mask, visited); + if (i < 0) break; + visited |= (uint32_t)1U << i; const auto& entry = flood_packet_filters[i]; - // 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); + bool blocked = entry.drop_on_match; + if (entry.rate_limit_enabled) { + uint32_t now = _ms->getMillis(); + if (FloodFilterPolicy::rateLimitReached( + entry.rate_window_active, now, entry.rate_window_started, + entry.rate_window_count, entry.rate_per_minute)) { + blocked = true; + } + } + if (blocked) { + MESH_DEBUG_PRINTLN("allowPacketForward: flood.filter matched slot=%d type=%d hops=%d", + i + 1, type, hops); return true; } } return false; } +void MyMesh::commitFloodPacketFilterRates(const mesh::Packet* packet) { + if (packet == NULL || !packet->isRouteFlood()) return; + uint32_t now = _ms->getMillis(); + for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + auto& entry = flood_packet_filters[i]; + if ((recv_pkt_filter_match_mask & ((uint32_t)1U << i)) == 0 + || !entry.rate_limit_enabled) { + continue; + } + if (!entry.rate_window_active + || now - entry.rate_window_started >= 60000UL) { + entry.rate_window_active = true; + entry.rate_window_started = now; + entry.rate_window_count = 0; + } + if (entry.rate_window_count < 0xFFFF) entry.rate_window_count++; + } +} +#else +bool MyMesh::floodPacketFilterFieldsMatch( + const FloodPacketFilterEntry& entry, const mesh::Packet* packet, + bool incoming_is_scoped, uint16_t incoming_transport_code, + bool incoming_region_allowed, + const RegionEntry* incoming_region) const { + (void)incoming_is_scoped; + (void)incoming_transport_code; + (void)incoming_region_allowed; + (void)incoming_region; + 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(); + uint8_t hops = packet->getPathHashCount(); + return (entry.payload_type == FLOOD_PACKET_FILTER_ANY_TYPE + || entry.payload_type == type) + && hops >= entry.min_hops && hops <= entry.max_hops; +} + +uint32_t MyMesh::evaluateFloodPacketFilterMatches( + const mesh::Packet* packet, bool incoming_region_allowed, + const RegionEntry* incoming_region) const { + (void)incoming_region; + uint32_t matches = 0; + for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + const auto& entry = flood_packet_filters[i]; + if (floodPacketFilterFieldsMatch(entry, packet, false, 0, + incoming_region_allowed, NULL) + && (!entry.scope_requires_region_match + || incoming_region_allowed)) { + matches |= (uint32_t)1U << i; + } + } + return matches; +} + +bool MyMesh::applyFloodPacketFilterScope(mesh::Packet* packet, + uint32_t match_mask, + 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 ((match_mask & ((uint32_t)1U << i)) == 0 + || entry.scope_name[0] == 0) continue; + + TransportKey scope; + deriveFloodFilterScopeKey(entry.scope_name, scope); + bool scope_changed = FloodFilterPolicy::setTransportScope( + packet, scope.calcTransportCode(packet)); + 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(); + uint8_t hops = packet->getPathHashCount(); + for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + const auto& entry = flood_packet_filters[i]; + if ((recv_pkt_filter_match_mask & ((uint32_t)1U << i)) != 0 + && entry.scope_name[0] == 0) { + 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; + } + } + return false; +} + +void MyMesh::commitFloodPacketFilterRates(const mesh::Packet* packet) { + (void)packet; +} +#endif + +#if MESH_ENABLE_FLOOD_RULE_ENGINE +static bool floodRuleRegionNamePresent(const RegionMap& map, + const char* name) { + for (int i = 0; i < map.getCount(); i++) { + const RegionEntry* region = map.getByIdx(i); + if (region != NULL + && RegionNameUtils::equivalent(region->name, name)) return true; + } + return false; +} + +void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len) const { + if (index < 0 || index >= FLOOD_PACKET_FILTER_SLOTS || !flood_packet_filters[index].active) { + snprintf(reply, reply_len, "Err - empty filter slot"); + return; + } + const auto& entry = flood_packet_filters[index]; + char hops[12]; + char prefix[32]; + char incoming[48]; + char action[64]; + char rate[24]; + formatFloodFilterHopSpec(hops, sizeof(hops), entry.min_hops, entry.max_hops); + if (entry.match_blacklisted_path) { + strcpy(prefix, "blacklist"); + } else { + formatFloodModerationPath(prefix, sizeof(prefix), entry.path_hash_size, + entry.path_hops, entry.path); + } + + switch (entry.incoming_scope_kind) { + case FloodFilterPolicy::RULE_IN_NONE: + strcpy(incoming, "none"); + break; + case FloodFilterPolicy::RULE_IN_SCOPED: + strcpy(incoming, "scoped"); + break; + case FloodFilterPolicy::RULE_IN_ALLOWED: + strcpy(incoming, "allowed"); + break; + case FloodFilterPolicy::RULE_IN_UNKNOWN: + strcpy(incoming, "unknown"); + break; + case FloodFilterPolicy::RULE_IN_SCOPE: + snprintf(incoming, sizeof(incoming), "scope:%s", + entry.incoming_scope_name); + break; + case FloodFilterPolicy::RULE_IN_REGION: { + snprintf(incoming, sizeof(incoming), "region:%s%s", + entry.incoming_scope_name, + floodRuleRegionNamePresent( + region_map, entry.incoming_scope_name) ? "" : "?"); + break; + } + default: + strcpy(incoming, "any"); + break; + } + + action[0] = 0; + if (entry.drop_on_match) { + strcpy(action, "drop"); + } else if (entry.scope_name[0] != 0) { + snprintf(action, sizeof(action), "scope=%s", entry.scope_name); + } else if (entry.target_region_name[0] != 0) { + snprintf(action, sizeof(action), "region=%s%s", + entry.target_region_name, + floodRuleRegionNamePresent( + region_map, entry.target_region_name) ? "" : "?"); + } + rate[0] = 0; + if (entry.rate_limit_enabled) { + snprintf(rate, sizeof(rate), "%srate=%u/min", + action[0] == 0 ? "" : " ", + (unsigned int)entry.rate_per_minute); + } + int written = snprintf( + reply, reply_len, + "> %d type=%s hops=%s channel=%s prefix=%s in=%s %s%s priority=%u%s%s%s", + index + 1, floodFilterPayloadTypeName(entry.payload_type), hops, + entry.channel_key_len == 0 ? "*" : entry.channel_name, + prefix, incoming, action, rate, + (unsigned int)entry.priority, + entry.stop_on_match ? " stop" : "", + entry.scope_uses_slow_timing ? " tx=slow" : "", + entry.suspend_on_temp_radio ? " suspend=tempradio" : ""); + if (written >= 0 && (size_t)written < reply_len) return; + + // A fully populated row can exceed the 160-byte CLI reply. Fall back to a + // compact, accepted-by-set spelling instead of silently hiding tail fields. + char compact_type[4]; + if (entry.payload_type == FLOOD_PACKET_FILTER_ANY_TYPE) { + strcpy(compact_type, "any"); + } else { + snprintf(compact_type, sizeof(compact_type), "%u", + (unsigned int)entry.payload_type); + } + char compact_incoming[40]; + switch (entry.incoming_scope_kind) { + case FloodFilterPolicy::RULE_IN_NONE: + strcpy(compact_incoming, "n"); + break; + case FloodFilterPolicy::RULE_IN_SCOPED: + strcpy(compact_incoming, "s"); + break; + case FloodFilterPolicy::RULE_IN_ALLOWED: + strcpy(compact_incoming, "a"); + break; + case FloodFilterPolicy::RULE_IN_UNKNOWN: + strcpy(compact_incoming, "u"); + break; + case FloodFilterPolicy::RULE_IN_SCOPE: + snprintf(compact_incoming, sizeof(compact_incoming), "s:%s", + entry.incoming_scope_name); + break; + case FloodFilterPolicy::RULE_IN_REGION: { + snprintf(compact_incoming, sizeof(compact_incoming), "r:%s", + entry.incoming_scope_name); + break; + } + default: + strcpy(compact_incoming, "*"); + break; + } + + char compact_action[40]; + if (entry.drop_on_match) { + strcpy(compact_action, " drop"); + } else if (entry.scope_name[0] != 0) { + snprintf(compact_action, sizeof(compact_action), " scope=%s", + entry.scope_name); + } else if (entry.target_region_name[0] != 0) { + snprintf(compact_action, sizeof(compact_action), " region=%s", + entry.target_region_name); + } else { + compact_action[0] = 0; + } + char compact_rate[12]; + compact_rate[0] = 0; + if (entry.rate_limit_enabled) { + snprintf(compact_rate, sizeof(compact_rate), " q=%u", + (unsigned int)entry.rate_per_minute); + } + char compact_flags[8]; + compact_flags[0] = 0; + if (entry.scope_uses_slow_timing || entry.suspend_on_temp_radio) { + snprintf(compact_flags, sizeof(compact_flags), " f=%s%s", + entry.scope_uses_slow_timing ? "s" : "", + entry.suspend_on_temp_radio ? "t" : ""); + } + snprintf(reply, reply_len, + ">%d %s %s c=%s p=%s i=%s%s%s pri=%u%s%s", + index + 1, compact_type, hops, + entry.channel_key_len == 0 ? "*" : entry.channel_name, + prefix, compact_incoming, compact_action, compact_rate, + (unsigned int)entry.priority, + entry.stop_on_match ? " stop" : "", + compact_flags); +} + +void MyMesh::formatFloodPacketFilters(const char* args, char* reply) const { + const char* selector = skipFloodFilterSpaces(args); + if (*selector == '.') selector = skipFloodFilterSpaces(selector + 1); + if (*selector != 0) { + uint8_t slot; + if (!parseFloodFilterUnsigned(selector, FLOOD_PACKET_FILTER_SLOTS, slot) || slot == 0) { + snprintf(reply, 160, "Err - filter slot must be 1-%d", FLOOD_PACKET_FILTER_SLOTS); + return; + } + formatFloodPacketFilterDetail(slot - 1, reply, 160); + return; + } + + size_t used = (size_t)snprintf(reply, 160, ">"); + int active_count = 0; + bool truncated = false; + for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + const auto& entry = flood_packet_filters[i]; + if (!entry.active) continue; + active_count++; + char hops[12]; + char item[120]; + char target[44]; + char priority[8]; + formatFloodFilterHopSpec(hops, sizeof(hops), entry.min_hops, entry.max_hops); + if (entry.drop_on_match) { + strcpy(target, "!drop"); + } else if (entry.scope_name[0] != 0) { + snprintf(target, sizeof(target), ">%s", entry.scope_name); + } else if (entry.target_region_name[0] != 0) { + snprintf(target, sizeof(target), ">r:%s", + entry.target_region_name); + } else { + target[0] = 0; + } + priority[0] = 0; + if (entry.priority != 0) { + snprintf(priority, sizeof(priority), "^%u", + (unsigned int)entry.priority); + } + snprintf(item, sizeof(item), " %d=%s@%s%s%s%s%s%s%s%s", + i + 1, floodFilterPayloadTypeName(entry.payload_type), hops, + entry.match_blacklisted_path ? "?blacklist" : "", + target, priority, + entry.stop_on_match ? "~stop" : "", + entry.rate_limit_enabled ? "~rate" : "", + 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; + break; + } + memcpy(&reply[used], item, item_len + 1); + used += item_len; + } + if (active_count == 0) { + strcpy(reply, "> off"); + } else if (truncated) { + StrHelper::strncpy(&reply[used], " ...", 160 - used); + } +} + +void MyMesh::setFloodPacketFilter(const char* args, char* reply, + bool require_explicit_action) { + 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 - filter slot must be 1-%d", FLOOD_PACKET_FILTER_SLOTS); + return; + } + memcpy(slot_text, slot_start, slot_len); + slot_text[slot_len] = 0; + uint8_t slot; + if (!parseFloodFilterUnsigned(slot_text, FLOOD_PACKET_FILTER_SLOTS, slot) || slot == 0) { + snprintf(reply, 160, "Err - filter slot must be 1-%d", FLOOD_PACKET_FILTER_SLOTS); + return; + } + requested_slot = slot - 1; + if (*cursor != ' ') { + strcpy(reply, FLOOD_PACKET_FILTER_USAGE); + return; + } + } + cursor = skipFloodFilterSpaces(cursor); + if (strlen(cursor) >= 192) { + strcpy(reply, "Err - rule parameters too long"); + return; + } + + char params[192]; + strcpy(params, cursor); + char* tokens[18]; + int token_count = 0; + char* token = params; + while (*token != 0) { + if (token_count >= 18) { + strcpy(reply, "Err - too many rule parameters"); + return; + } + tokens[token_count++] = token; + char* separator = strchr(token, ' '); + if (separator == NULL) break; + *separator++ = 0; + while (*separator == ' ') separator++; + token = separator; + } + if (token_count == 0) { + strcpy(reply, FLOOD_PACKET_FILTER_USAGE); + return; + } + + uint8_t payload_type; + const char* type_text = floodFilterAsciiStartsWith(tokens[0], "type=") + ? tokens[0] + strlen("type=") : tokens[0]; + if (!parseFloodFilterPayloadType(type_text, payload_type)) { + strcpy(reply, "Err - packet type must be name, any, 0-15, or 0x00-0x0F"); + return; + } + + uint8_t min_hops = 0; + 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 path_set = false; + uint8_t path_hash_size = 0; + uint8_t path_hops = 0; + uint8_t path[FLOOD_PACKET_FILTER_PATH_PREFIX_BYTES_MAX]; + memset(path, 0, sizeof(path)); + uint8_t incoming_scope_kind = FloodFilterPolicy::RULE_IN_ANY; + char incoming_scope_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN]; + memset(incoming_scope_name, 0, sizeof(incoming_scope_name)); + bool incoming_set = false; + uint8_t channel_key_len = 0; + uint8_t channel_hash = 0; + uint8_t channel_secret[PUB_KEY_SIZE]; + memset(channel_secret, 0, sizeof(channel_secret)); + char channel_name[FLOOD_GROUP_MODERATION_NAME_LEN]; + memset(channel_name, 0, sizeof(channel_name)); + bool channel_set = 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)); + char target_region_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN]; + memset(target_region_name, 0, sizeof(target_region_name)); + bool target_set = false; + bool drop_on_match = false; + bool drop_set = false; + bool rate_limit_enabled = false; + uint16_t rate_per_minute = 0; + uint8_t priority = 0; + bool priority_set = false; + bool stop_on_match = false; + for (int i = 1; i < token_count; i++) { + if (floodFilterAsciiEqual(tokens[i], "suspend=tempradio")) { + if (suspend_on_temp_radio) { + strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE); + return; + } + suspend_on_temp_radio = true; + } else if (floodFilterAsciiStartsWith(tokens[i], "f=")) { + const char* flags = tokens[i] + 2; + if (*flags == 0) { + strcpy(reply, "Err - compact flags are s and/or t"); + return; + } + while (*flags != 0) { + if (*flags == 's' || *flags == 'S') { + if (scope_timing_set) { + strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE); + return; + } + scope_timing_set = true; + scope_uses_slow_timing = true; + } else if (*flags == 't' || *flags == 'T') { + if (suspend_on_temp_radio) { + strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE); + return; + } + suspend_on_temp_radio = true; + } else { + strcpy(reply, "Err - compact flags are s and/or t"); + return; + } + flags++; + } + } else if (floodFilterAsciiEqual(tokens[i], "path=blacklist")) { + if (path_set) { + strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE); + return; + } + path_set = true; + match_blacklisted_path = true; + } else if (floodFilterAsciiEqual(tokens[i], "require=region")) { + if (incoming_set) { + strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE); + return; + } + incoming_set = true; + incoming_scope_kind = FloodFilterPolicy::RULE_IN_ALLOWED; + } else if (floodFilterAsciiStartsWith(tokens[i], "in=") + || floodFilterAsciiStartsWith(tokens[i], "i=")) { + if (incoming_set) { + strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE); + return; + } + incoming_set = true; + const bool compact = floodFilterAsciiStartsWith(tokens[i], "i="); + const char* value = tokens[i] + (compact ? 2 : strlen("in=")); + if (floodFilterAsciiEqual(value, "any") + || (compact && floodFilterAsciiEqual(value, "*"))) { + incoming_scope_kind = FloodFilterPolicy::RULE_IN_ANY; + } else if (floodFilterAsciiEqual(value, "none") + || floodFilterAsciiEqual(value, "unscoped") + || (compact && floodFilterAsciiEqual(value, "n"))) { + incoming_scope_kind = FloodFilterPolicy::RULE_IN_NONE; + } else if (floodFilterAsciiEqual(value, "scoped")) { + incoming_scope_kind = FloodFilterPolicy::RULE_IN_SCOPED; + } else if (compact && floodFilterAsciiEqual(value, "s")) { + incoming_scope_kind = FloodFilterPolicy::RULE_IN_SCOPED; + } else if (floodFilterAsciiEqual(value, "allowed") + || floodFilterAsciiEqual(value, "known") + || (compact && floodFilterAsciiEqual(value, "a"))) { + incoming_scope_kind = FloodFilterPolicy::RULE_IN_ALLOWED; + } else if (floodFilterAsciiEqual(value, "unknown") + || (compact && floodFilterAsciiEqual(value, "u"))) { + incoming_scope_kind = FloodFilterPolicy::RULE_IN_UNKNOWN; + } else if (floodFilterAsciiStartsWith(value, "scope:") + || (compact && floodFilterAsciiStartsWith(value, "s:"))) { + incoming_scope_kind = FloodFilterPolicy::RULE_IN_SCOPE; + if (!normalizeFloodFilterScopeName( + value + (floodFilterAsciiStartsWith(value, "scope:") + ? strlen("scope:") : 2), incoming_scope_name, + sizeof(incoming_scope_name))) { + strcpy(reply, "Err - bad incoming scope name"); + return; + } + } else if (floodFilterAsciiStartsWith(value, "region:") + || (compact && floodFilterAsciiStartsWith(value, "r:"))) { + RegionEntry* region = region_map.findByNamePrefix( + value + (floodFilterAsciiStartsWith(value, "region:") + ? strlen("region:") : 2)); + if (region == NULL || region->isWildcard()) { + strcpy(reply, "Err - bad incoming region"); + return; + } + incoming_scope_kind = FloodFilterPolicy::RULE_IN_REGION; + StrHelper::strzcpy(incoming_scope_name, region->name, + sizeof(incoming_scope_name)); + } else { + strcpy(reply, "Err - in=any|none|scoped|allowed|unknown|scope:name|region:name"); + return; + } + } else if (floodFilterAsciiStartsWith(tokens[i], "priority=") + || floodFilterAsciiStartsWith(tokens[i], "pri=")) { + if (priority_set) { + strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE); + return; + } + const char* value = strchr(tokens[i], '=') + 1; + uint32_t parsed = 0; + if (!parseFloodModerationUnsigned(value, 255, parsed)) { + strcpy(reply, "Err - priority must be 0-255"); + return; + } + priority = (uint8_t)parsed; + priority_set = true; + } else if (floodFilterAsciiEqual(tokens[i], "stop") + || floodFilterAsciiEqual(tokens[i], "action=stop")) { + if (stop_on_match) { + strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE); + return; + } + stop_on_match = true; + } else if (floodFilterAsciiStartsWith(tokens[i], "tx=")) { + if (scope_timing_set) { + strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE); + 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 (target_set || drop_set) { + strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE); + 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; + } + target_set = true; + } else if (floodFilterAsciiStartsWith(tokens[i], "region=")) { + if (target_set || drop_set) { + strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE); + return; + } + RegionEntry* region = region_map.findByNamePrefix( + tokens[i] + strlen("region=")); + TransportKey target_scope; + if (region == NULL || region->isWildcard() + || (region->flags & REGION_DENY_FLOOD) != 0 + || region_map.getTransportKeysFor(*region, &target_scope, 1) <= 0 + || target_scope.isNull()) { + strcpy(reply, "Err - bad target region"); + return; + } + StrHelper::strzcpy(target_region_name, region->name, + sizeof(target_region_name)); + target_set = true; + } else if (floodFilterAsciiEqual(tokens[i], "drop") + || floodFilterAsciiEqual(tokens[i], "action=drop")) { + if (drop_set || target_set) { + strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE); + return; + } + drop_on_match = true; + drop_set = true; + } else if (floodFilterAsciiStartsWith(tokens[i], "rate=") + || floodFilterAsciiStartsWith(tokens[i], "q=")) { + if (rate_limit_enabled) { + strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE); + return; + } + char rate_text[24]; + const bool compact = floodFilterAsciiStartsWith(tokens[i], "q="); + StrHelper::strncpy(rate_text, + tokens[i] + (compact ? 2 : strlen("rate=")), + sizeof(rate_text)); + char* slash = strchr(rate_text, '/'); + if ((!compact && slash == NULL) + || (slash != NULL && !(strcmp(slash, "/min") == 0 + || strcmp(slash, "/m") == 0))) { + strcpy(reply, "Err - rate format is X/min"); + return; + } + if (slash != NULL) *slash = 0; + uint32_t parsed = 0; + if (!parseFloodModerationUnsigned( + rate_text, FLOOD_GROUP_MODERATION_RATE_UNLIMITED - 1, + parsed)) { + strcpy(reply, "Err - rate must be 0-65534/min"); + return; + } + rate_per_minute = (uint16_t)parsed; + rate_limit_enabled = true; + } else if (floodFilterAsciiStartsWith(tokens[i], "channel=") + || floodFilterAsciiStartsWith(tokens[i], "c=")) { + if (channel_set) { + strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE); + return; + } + channel_set = true; + const char* value = tokens[i] + + (floodFilterAsciiStartsWith(tokens[i], "c=") + ? 2 : strlen("channel=")); + if (!floodFilterAsciiEqual(value, "*")) { + if (value[0] == '#' + && strlen(value) >= sizeof(channel_name)) { + strcpy(reply, "Err - channel name is too long"); + return; + } + uint8_t hash_prefix[FLOOD_CHANNEL_KEY_PREFIX_LEN]; + if (!parseFloodModerationChannel( + value, channel_secret, channel_key_len, hash_prefix, + channel_name, sizeof(channel_name))) { + strcpy(reply, "Err - channel must be *, public, #channel, or a key"); + return; + } + channel_hash = hash_prefix[0]; + } + } else if (floodFilterAsciiStartsWith(tokens[i], "prefix=") + || floodFilterAsciiStartsWith(tokens[i], "p=") + || (floodFilterAsciiStartsWith(tokens[i], "path=") + && !floodFilterAsciiEqual(tokens[i], "path=blacklist"))) { + if (path_set) { + strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE); + return; + } + const char* value = strchr(tokens[i], '=') + 1; + static_assert(FLOOD_PACKET_FILTER_PATH_PREFIX_BYTES_MAX + == FLOOD_GROUP_MODERATION_PATH_BYTES_MAX, + "rule and moderation path buffers must agree"); + if (!parseFloodModerationPath(value, path_hash_size, path_hops, + path)) { + strcpy(reply, "Err - prefix is * or 1-3 comma-separated 1/2/3-byte IDs"); + return; + } + path_set = true; + } else if (floodFilterAsciiStartsWith(tokens[i], "hops=")) { + if (hops_set || !parseFloodFilterHopSpec( + tokens[i] + strlen("hops="), min_hops, max_hops)) { + strcpy(reply, "Err - hops must be all, N, N+, or N-M (0-63)"); + return; + } + hops_set = true; + } else if (!hops_set + && parseFloodFilterHopSpec(tokens[i], min_hops, max_hops)) { + hops_set = true; + } else { + strcpy(reply, FLOOD_PACKET_FILTER_USAGE); + return; + } + } + if (scope_timing_set && !target_set) { + strcpy(reply, "Err - tx timing requires scope= or region="); + return; + } + if (drop_on_match && rate_limit_enabled) { + strcpy(reply, "Err - drop cannot be combined with rate"); + return; + } + if (channel_key_len != 0 && payload_type != FLOOD_PACKET_FILTER_ANY_TYPE + && payload_type != PAYLOAD_TYPE_GRP_TXT + && payload_type != PAYLOAD_TYPE_GRP_DATA) { + strcpy(reply, "Err - channel matcher requires type=any|grp_txt|grp_data"); + return; + } + bool action_set = drop_set || target_set || rate_limit_enabled + || stop_on_match; + if (require_explicit_action && !action_set) { + strcpy(reply, + "Err - flood.rule requires drop, scope=, region=, rate=, or stop"); + return; + } + // Preserve the positional flood.filter behavior. The flood.rule alias is + // strict so an omitted action cannot silently install a deny rule. + if (!action_set) drop_on_match = true; + + FloodPacketFilterEntry candidate; + memset(&candidate, 0, sizeof(candidate)); + candidate.active = true; + candidate.payload_type = payload_type; + candidate.min_hops = min_hops; + candidate.max_hops = max_hops; + candidate.suspend_on_temp_radio = suspend_on_temp_radio; + candidate.match_blacklisted_path = match_blacklisted_path; + candidate.scope_uses_slow_timing = scope_uses_slow_timing; + candidate.incoming_scope_kind = incoming_scope_kind; + StrHelper::strzcpy(candidate.incoming_scope_name, incoming_scope_name, + sizeof(candidate.incoming_scope_name)); + candidate.channel_key_len = channel_key_len; + candidate.channel_hash = channel_hash; + memcpy(candidate.channel_secret, channel_secret, + sizeof(candidate.channel_secret)); + StrHelper::strzcpy(candidate.channel_name, channel_name, + sizeof(candidate.channel_name)); + candidate.path_hash_size = path_hash_size; + candidate.path_hops = path_hops; + memcpy(candidate.path, path, sizeof(candidate.path)); + StrHelper::strzcpy(candidate.target_region_name, target_region_name, + sizeof(candidate.target_region_name)); + candidate.drop_on_match = drop_on_match; + candidate.rate_limit_enabled = rate_limit_enabled; + candidate.rate_per_minute = rate_per_minute; + candidate.priority = priority; + candidate.stop_on_match = stop_on_match; + StrHelper::strzcpy(candidate.scope_name, scope_name, + sizeof(candidate.scope_name)); + + int slot = requested_slot; + if (slot < 0) { + for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + const auto& entry = flood_packet_filters[i]; + if (entry.active + && memcmp(&entry, &candidate, + offsetof(FloodPacketFilterEntry, + rate_window_started)) == 0) { + slot = i; + break; + } + } + } + if (slot < 0) { + for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + if (!flood_packet_filters[i].active) { + slot = i; + break; + } + } + } + if (slot < 0) { + strcpy(reply, "Err - filter table full"); + return; + } + + FloodPacketFilterEntry previous = flood_packet_filters[slot]; + flood_packet_filters[slot] = candidate; + if (!saveFloodPacketFilters()) { + flood_packet_filters[slot] = previous; + strcpy(reply, "Err - unable to save flood filter"); + return; + } + + char detail[160]; + formatFloodPacketFilterDetail(slot, detail, sizeof(detail)); + snprintf(reply, 160, "OK - %s", detail[0] == '>' ? skipFloodFilterSpaces(detail + 1) : detail); +} +#else void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len) const { if (index < 0 || index >= FLOOD_PACKET_FILTER_SLOTS || !flood_packet_filters[index].active) { snprintf(reply, reply_len, "Err - empty filter slot"); @@ -5154,7 +6427,9 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply) const { } } -void MyMesh::setFloodPacketFilter(const char* args, char* reply) { +void MyMesh::setFloodPacketFilter(const char* args, char* reply, + bool require_explicit_action) { + (void)require_explicit_action; const char* cursor = skipFloodFilterSpaces(args); int requested_slot = -1; if (*cursor == '.') { @@ -5330,6 +6605,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply) { formatFloodPacketFilterDetail(slot, detail, sizeof(detail)); snprintf(reply, 160, "OK - %s", detail[0] == '>' ? skipFloodFilterSpaces(detail + 1) : detail); } +#endif void MyMesh::deleteFloodPacketFilter(const char* args, char* reply) { const char* selector = skipFloodFilterSpaces(args); @@ -5368,7 +6644,7 @@ void MyMesh::deleteFloodPacketFilter(const char* args, char* reply) { } static bool isExactFloodChannelScopeSelector(uint8_t selector) { - selector = FloodFilterPolicy::scopeSelectorValue(selector); + selector = FloodFilterPolicy::channelScopeMatchSelectorValue(selector); return selector == CIPHER_KEY_SIZE || selector == PUB_KEY_SIZE; } @@ -5394,7 +6670,7 @@ static uint8_t wildcardFloodChannelScopeSelector(uint8_t type) { } static const char* wildcardFloodChannelScopeName(uint8_t selector) { - selector = FloodFilterPolicy::scopeSelectorValue(selector); + selector = FloodFilterPolicy::channelScopeMatchSelectorValue(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:*"; @@ -5402,9 +6678,13 @@ static const char* wildcardFloodChannelScopeName(uint8_t selector) { } void MyMesh::loadFloodChannelScopes() { + static_assert(sizeof(FloodChannelScopeEntry) == 36, + "channel scope persistence requires 36-byte entries"); memset(flood_channel_scopes, 0, sizeof(flood_channel_scopes)); + memset(flood_channel_direct_scopes, 0, + sizeof(flood_channel_direct_scopes)); if (_fs == NULL || !_fs->exists(FLOOD_CHANNEL_SCOPE_FILE)) return; - File file = openFloodChannelBlockRead(_fs, FLOOD_CHANNEL_SCOPE_FILE); + File file = openFloodSettingsRead(_fs, FLOOD_CHANNEL_SCOPE_FILE); if (!file) return; uint8_t magic[4]; @@ -5414,7 +6694,8 @@ void MyMesh::loadFloodChannelScopes() { bool version_2 = success && memcmp(magic, "FCS2", sizeof(magic)) == 0; bool version_3 = success && memcmp(magic, "FCS3", sizeof(magic)) == 0; bool version_4 = success && memcmp(magic, "FCS4", sizeof(magic)) == 0; - success = (version_1 || version_2 || version_3 || version_4) + bool version_5 = success && memcmp(magic, "FCS5", sizeof(magic)) == 0; + success = (version_1 || version_2 || version_3 || version_4 || version_5) && 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); @@ -5423,6 +6704,26 @@ void MyMesh::loadFloodChannelScopes() { for (int i = retained; success && i < count; i++) { success = file.read((uint8_t*)&discarded, sizeof(discarded)) == sizeof(discarded); } + + if (success && version_5) { + uint8_t direct_count = 0; + success = file.read(&direct_count, sizeof(direct_count)) + == sizeof(direct_count); + uint8_t retained_direct = direct_count < FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS + ? direct_count : FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS; + size_t retained_direct_bytes = (size_t)retained_direct + * FLOOD_PACKET_FILTER_SCOPE_NAME_LEN; + success = success + && file.read((uint8_t*)flood_channel_direct_scopes, + retained_direct_bytes) == retained_direct_bytes; + for (int i = 0; success && i < retained_direct; i++) { + char* name = flood_channel_direct_scopes[i]; + if (name[0] != 0 && !isValidStoredFloodFilterScopeName(name)) { + success = false; + } + } + } + for (int i = 0; success && i < retained; i++) { auto& entry = flood_channel_scopes[i]; if ((version_1 @@ -5436,9 +6737,18 @@ void MyMesh::loadFloodChannelScopes() { success = false; break; } - uint8_t selector = FloodFilterPolicy::scopeSelectorValue(entry.selector); - if (entry.region_id == 0) { + bool direct_target = + FloodFilterPolicy::channelScopeUsesDirectTarget(entry.selector); + uint8_t selector = + FloodFilterPolicy::channelScopeMatchSelectorValue(entry.selector); + if (entry.target_id == 0) { memset(&entry, 0, sizeof(entry)); + } else if (direct_target + && (!version_5 + || entry.target_id > FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS + || flood_channel_direct_scopes[entry.target_id - 1][0] == 0)) { + success = false; + break; } else if (selector <= FLOOD_CHANNEL_SCOPE_OTHER_ANY) { entry.channel_hash = 0; memset(entry.secret, 0, sizeof(entry.secret)); @@ -5455,28 +6765,35 @@ void MyMesh::loadFloodChannelScopes() { file.close(); // A malformed table is inert; corrupt storage must never assign a scope. - if (!success) memset(flood_channel_scopes, 0, sizeof(flood_channel_scopes)); + if (!success) { + memset(flood_channel_scopes, 0, sizeof(flood_channel_scopes)); + memset(flood_channel_direct_scopes, 0, + sizeof(flood_channel_direct_scopes)); + } } bool MyMesh::saveFloodChannelScopes(bool empty_table) { if (_fs == NULL) return false; - File file = openFloodChannelBlockWrite(_fs, FLOOD_CHANNEL_SCOPE_TEMP_FILE); + File file = openFloodSettingsWrite(_fs, FLOOD_CHANNEL_SCOPE_TEMP_FILE); if (!file) return false; - const uint8_t magic[4] = {'F', 'C', 'S', '4'}; - uint8_t count = FLOOD_CHANNEL_SCOPE_SLOTS; + const uint8_t magic[4] = {'F', 'C', 'S', '5'}; + uint8_t count = empty_table ? 0 : FLOOD_CHANNEL_SCOPE_SLOTS; bool success = file.write(magic, sizeof(magic)) == sizeof(magic) && file.write(&count, sizeof(count)) == sizeof(count); - if (success && empty_table) { - FloodChannelScopeEntry empty; - memset(&empty, 0, sizeof(empty)); - for (int i = 0; success && i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) { - success = file.write((const uint8_t*)&empty, sizeof(empty)) == sizeof(empty); - } - } else if (success) { + if (success && count != 0) { success = file.write((const uint8_t*)flood_channel_scopes, sizeof(flood_channel_scopes)) == sizeof(flood_channel_scopes); } + uint8_t direct_count = empty_table ? 0 : FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS; + success = success + && file.write(&direct_count, sizeof(direct_count)) + == sizeof(direct_count); + if (success && direct_count != 0) { + success = file.write((const uint8_t*)flood_channel_direct_scopes, + sizeof(flood_channel_direct_scopes)) + == sizeof(flood_channel_direct_scopes); + } file.close(); if (!success || !_fs->rename(FLOOD_CHANNEL_SCOPE_TEMP_FILE, FLOOD_CHANNEL_SCOPE_FILE)) { _fs->remove(FLOOD_CHANNEL_SCOPE_TEMP_FILE); @@ -5491,7 +6808,7 @@ void MyMesh::loadFloodChannelScopeRequirements() { if (_fs == NULL || !_fs->exists(FLOOD_CHANNEL_SCOPE_REQUIRE_FILE)) return; File file = - openFloodChannelBlockRead(_fs, FLOOD_CHANNEL_SCOPE_REQUIRE_FILE); + openFloodSettingsRead(_fs, FLOOD_CHANNEL_SCOPE_REQUIRE_FILE); if (!file) return; uint8_t magic[4]; @@ -5537,7 +6854,7 @@ void MyMesh::loadFloodChannelScopeRequirements() { bool MyMesh::saveFloodChannelScopeRequirements(bool empty_table) { if (_fs == NULL) return false; - File file = openFloodChannelBlockWrite( + File file = openFloodSettingsWrite( _fs, FLOOD_CHANNEL_SCOPE_REQUIRE_TEMP_FILE); if (!file) return false; @@ -5601,14 +6918,29 @@ bool MyMesh::applyFloodChannelScopeTarget(mesh::Packet* packet, const FloodChannelScopeEntry& entry, bool& scope_changed, bool& fast_track, + bool& regionless_scope_set, bool log_change) { scope_changed = false; fast_track = false; - RegionEntry* region = region_map.findById(entry.region_id); + regionless_scope_set = false; TransportKey scope; - if (region == NULL || region->isWildcard() || (region->flags & REGION_DENY_FLOOD) != 0 - || region_map.getTransportKeysFor(*region, &scope, 1) <= 0 || scope.isNull()) { - return false; + RegionEntry* region = NULL; + const char* target_name = NULL; + bool direct_target = + FloodFilterPolicy::channelScopeUsesDirectTarget(entry.selector); + if (direct_target) { + target_name = flood_channel_direct_scopes[entry.target_id - 1]; + deriveFloodFilterScopeKey(target_name, scope); + regionless_scope_set = true; + } else { + region = region_map.findById(entry.target_id); + if (region == NULL || region->isWildcard() + || (region->flags & REGION_DENY_FLOOD) != 0 + || region_map.getTransportKeysFor(*region, &scope, 1) <= 0 + || scope.isNull()) { + return false; + } + target_name = region->name; } uint16_t transport_code = scope.calcTransportCode(packet); @@ -5619,8 +6951,12 @@ bool MyMesh::applyFloodChannelScopeTarget(mesh::Packet* packet, 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, +#if defined(STM32_PLATFORM) + MESH_DEBUG_PRINTLN("s %u %s %s", +#else + MESH_DEBUG_PRINTLN("force-scoped flood type=%u scope=%s tx=%s", +#endif + (unsigned int)packet->getPayloadType(), target_name, FloodFilterPolicy::scopeUsesSlowTiming(entry.selector) ? "slow" : "fast"); } @@ -5628,6 +6964,7 @@ bool MyMesh::applyFloodChannelScopeTarget(mesh::Packet* packet, } bool MyMesh::applyFloodChannelScope(mesh::Packet* packet, bool& fast_track, + bool& regionless_scope_set, bool log_change) { static_assert(FLOOD_RETRY_BRIDGE_BUCKETS == FloodFilterPolicy::SCOPE_PATH_BRIDGE_BUCKET_COUNT, @@ -5636,11 +6973,9 @@ bool MyMesh::applyFloodChannelScope(mesh::Packet* packet, bool& fast_track, == FloodFilterPolicy::BLACKLIST_ID_SIZE, "channel scope and bridge bucket ID sizes must agree"); fast_track = false; + regionless_scope_set = 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. - if (type == PAYLOAD_TYPE_TRACE) return false; bool valid_channel_layout = (type == PAYLOAD_TYPE_GRP_TXT || type == PAYLOAD_TYPE_GRP_DATA) && packet->payload_len >= PATH_HASH_SIZE + CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE && ((packet->payload_len - PATH_HASH_SIZE - CIPHER_MAC_SIZE) % CIPHER_BLOCK_SIZE) == 0; @@ -5681,7 +7016,7 @@ bool MyMesh::applyFloodChannelScope(mesh::Packet* packet, bool& fast_track, for (int path_pass = 1; path_pass >= 0; path_pass--) { for (int i = 0; i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) { const auto& entry = flood_channel_scopes[i]; - if (entry.region_id == 0 || !isExactFloodChannelScopeSelector(entry.selector) + if (entry.target_id == 0 || !isExactFloodChannelScopeSelector(entry.selector) || packet->payload[0] != entry.channel_hash || !matches_path_pass(entry.selector, path_pass != 0)) { continue; @@ -5689,7 +7024,8 @@ bool MyMesh::applyFloodChannelScope(mesh::Packet* packet, bool& fast_track, if (mesh::Utils::MACThenDecrypt(entry.secret, data, &packet->payload[PATH_HASH_SIZE], packet->payload_len - PATH_HASH_SIZE) > 0) { bool scope_changed; - if (applyFloodChannelScopeTarget(packet, entry, scope_changed, fast_track, + if (applyFloodChannelScopeTarget(packet, entry, scope_changed, + fast_track, regionless_scope_set, log_change)) { return scope_changed; } @@ -5701,11 +7037,13 @@ bool MyMesh::applyFloodChannelScope(mesh::Packet* packet, bool& fast_track, for (int path_pass = 1; path_pass >= 0; path_pass--) { for (int i = 0; i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) { const auto& entry = flood_channel_scopes[i]; - if (entry.region_id != 0 - && FloodFilterPolicy::scopeSelectorValue(entry.selector) == wildcard + if (entry.target_id != 0 + && FloodFilterPolicy::channelScopeMatchSelectorValue(entry.selector) + == wildcard && matches_path_pass(entry.selector, path_pass != 0)) { bool scope_changed; - if (applyFloodChannelScopeTarget(packet, entry, scope_changed, fast_track, + if (applyFloodChannelScopeTarget(packet, entry, scope_changed, + fast_track, regionless_scope_set, log_change)) { return scope_changed; } @@ -5779,7 +7117,7 @@ static int takeFloodModerationToken(const char*& cursor, char* dest, size_t dest } static bool parseFloodModerationChannel(const char* text, uint8_t secret[PUB_KEY_SIZE], - uint8_t& key_len, uint8_t hash_prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN], + uint8_t& key_len, uint8_t hash_prefix[FLOOD_CHANNEL_KEY_PREFIX_LEN], char* name, size_t name_len) { if (text == NULL || *text == 0) return false; memset(secret, 0, PUB_KEY_SIZE); @@ -5799,12 +7137,12 @@ static bool parseFloodModerationChannel(const char* text, uint8_t secret[PUB_KEY } key_len = (uint8_t)(hex_len / 2); if (!mesh::Utils::fromHex(secret, key_len, text)) return false; - mesh::Utils::sha256(hash_prefix, FLOOD_CHANNEL_BLOCK_PREFIX_LEN, secret, key_len); - char prefix_hex[FLOOD_CHANNEL_BLOCK_PREFIX_LEN * 2 + 1]; - mesh::Utils::toHex(prefix_hex, hash_prefix, FLOOD_CHANNEL_BLOCK_PREFIX_LEN); + mesh::Utils::sha256(hash_prefix, FLOOD_CHANNEL_KEY_PREFIX_LEN, secret, key_len); + char prefix_hex[FLOOD_CHANNEL_KEY_PREFIX_LEN * 2 + 1]; + mesh::Utils::toHex(prefix_hex, hash_prefix, FLOOD_CHANNEL_KEY_PREFIX_LEN); snprintf(name, name_len, "key:%s", prefix_hex); } - mesh::Utils::sha256(hash_prefix, FLOOD_CHANNEL_BLOCK_PREFIX_LEN, secret, key_len); + mesh::Utils::sha256(hash_prefix, FLOOD_CHANNEL_KEY_PREFIX_LEN, secret, key_len); return true; } @@ -5834,22 +7172,22 @@ void MyMesh::formatFloodChannelScopeDetail(int index, char* reply, size_t reply_ return; } const auto& entry = flood_channel_scopes[index]; - if (entry.region_id == 0) { + if (entry.target_id == 0) { snprintf(reply, reply_len, "> %d empty", index + 1); return; } char channel[12]; - uint8_t selector = FloodFilterPolicy::scopeSelectorValue(entry.selector); + uint8_t selector = + FloodFilterPolicy::channelScopeMatchSelectorValue(entry.selector); const char* wildcard = wildcardFloodChannelScopeName(selector); if (wildcard != NULL) { strcpy(channel, wildcard); } else { - uint8_t prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]; + uint8_t prefix[FLOOD_CHANNEL_KEY_PREFIX_LEN]; mesh::Utils::sha256(prefix, sizeof(prefix), entry.secret, selector); mesh::Utils::toHex(channel, prefix, sizeof(prefix)); } - RegionEntry* region = region_map.findById(entry.region_id); char path[24]; uint8_t path_selector = FloodFilterPolicy::scopePathSelectorValue(entry.selector); @@ -5867,12 +7205,20 @@ void MyMesh::formatFloodChannelScopeDetail(int index, char* reply, size_t reply_ } const char* timing = FloodFilterPolicy::scopeUsesSlowTiming(entry.selector) ? " tx=slow" : ""; + if (FloodFilterPolicy::channelScopeUsesDirectTarget(entry.selector)) { + snprintf(reply, reply_len, "> %d match=%s%s scope=%s%s", + index + 1, channel, path, + flood_channel_direct_scopes[entry.target_id - 1], timing); + return; + } + + RegionEntry* region = region_map.findById(entry.target_id); if (region != NULL) { snprintf(reply, reply_len, "> %d match=%s%s scope=%s%s", index + 1, channel, path, region->name, timing); } else { snprintf(reply, reply_len, "> %d match=%s%s scope=id:%u?%s", index + 1, - channel, path, (unsigned int)entry.region_id, timing); + channel, path, (unsigned int)entry.target_id, timing); } } @@ -5894,9 +7240,13 @@ void MyMesh::formatFloodChannelScopes(const char* args, char* reply) { int active = 0; for (int i = 0; i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) { - if (flood_channel_scopes[i].region_id != 0) active++; + if (flood_channel_scopes[i].target_id != 0) active++; } +#if defined(STM32_PLATFORM) + snprintf(reply, 160, "> %d/%d", +#else snprintf(reply, 160, "> %d/%d active; use get flood.channel.scope.", +#endif active, FLOOD_CHANNEL_SCOPE_SLOTS); } @@ -5909,9 +7259,9 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) { } char channel_text[80]; - char region_text[32]; + char target_text[40]; if (takeFloodModerationToken(cursor, channel_text, sizeof(channel_text)) != 1 - || takeFloodModerationToken(cursor, region_text, sizeof(region_text)) != 1) { + || takeFloodModerationToken(cursor, target_text, sizeof(target_text)) != 1) { strcpy(reply, FLOOD_CHANNEL_SCOPE_USAGE); return; } @@ -5963,19 +7313,35 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) { return; } - RegionEntry* region = region_map.findByNamePrefix(region_text); - TransportKey scope; - if (region == NULL || region->isWildcard() || (region->flags & REGION_DENY_FLOOD) != 0 - || region_map.getTransportKeysFor(*region, &scope, 1) <= 0 || scope.isNull()) { - strcpy(reply, "Err - bad scope region"); - return; + bool direct_target = floodFilterAsciiStartsWith(target_text, "scope="); + char direct_scope_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN]; + memset(direct_scope_name, 0, sizeof(direct_scope_name)); + RegionEntry* region = NULL; + if (direct_target) { + if (!normalizeFloodFilterScopeName( + target_text + strlen("scope="), direct_scope_name, + sizeof(direct_scope_name))) { + strcpy(reply, + "Err - scope must be a public name of at most 30 characters"); + return; + } + } else { + region = region_map.findByNamePrefix(target_text); + TransportKey scope; + if (region == NULL || region->isWildcard() + || (region->flags & REGION_DENY_FLOOD) != 0 + || region_map.getTransportKeysFor(*region, &scope, 1) <= 0 + || scope.isNull()) { + strcpy(reply, "Err - bad scope region"); + return; + } } uint8_t secret[PUB_KEY_SIZE]; memset(secret, 0, sizeof(secret)); uint8_t selector = FLOOD_CHANNEL_SCOPE_TXT_ANY; uint8_t key_len = 0; - uint8_t hash_prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]; + uint8_t hash_prefix[FLOOD_CHANNEL_KEY_PREFIX_LEN]; memset(hash_prefix, 0, sizeof(hash_prefix)); if (strcmp(channel_text, "*") == 0 || floodFilterAsciiEqual(channel_text, "txt:*")) { selector = FLOOD_CHANNEL_SCOPE_TXT_ANY; @@ -5997,9 +7363,10 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) { int free_slot = -1; for (int i = 0; i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) { const auto& entry = flood_channel_scopes[i]; - if (entry.region_id == 0) { + if (entry.target_id == 0) { if (free_slot < 0) free_slot = i; - } else if (FloodFilterPolicy::scopeSelectorValue(entry.selector) == selector + } else if (FloodFilterPolicy::channelScopeMatchSelectorValue( + entry.selector) == selector && FloodFilterPolicy::scopePathSelectorValue(entry.selector) == path_selector && (!isExactFloodChannelScopeSelector(selector) @@ -6014,19 +7381,74 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) { return; } + int direct_scope_slot = -1; + int changed_direct_scope_slot = -1; + char previous_direct_scope[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN]; + memset(previous_direct_scope, 0, sizeof(previous_direct_scope)); + if (direct_target) { + int reusable_direct_scope_slot = -1; + for (int i = 0; i < FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS; i++) { + if (strcmp(flood_channel_direct_scopes[i], direct_scope_name) == 0) { + direct_scope_slot = i; + break; + } + if (reusable_direct_scope_slot < 0) { + bool referenced = false; + for (int row = 0; row < FLOOD_CHANNEL_SCOPE_SLOTS; row++) { + if (row == slot) continue; + const auto& entry = flood_channel_scopes[row]; + if (entry.target_id == i + 1 + && FloodFilterPolicy::channelScopeUsesDirectTarget( + entry.selector)) { + referenced = true; + break; + } + } + if (!referenced) { + reusable_direct_scope_slot = i; + } + } + } + if (direct_scope_slot < 0) { + direct_scope_slot = reusable_direct_scope_slot; + } + if (direct_scope_slot < 0) { + strcpy(reply, "Err - scope table full"); + return; + } + if (strcmp(flood_channel_direct_scopes[direct_scope_slot], + direct_scope_name) != 0) { + changed_direct_scope_slot = direct_scope_slot; + memcpy(previous_direct_scope, + flood_channel_direct_scopes[direct_scope_slot], + sizeof(previous_direct_scope)); + StrHelper::strzcpy(flood_channel_direct_scopes[direct_scope_slot], + direct_scope_name, + sizeof(flood_channel_direct_scopes[direct_scope_slot])); + } + } + + uint8_t target_selector = + FloodFilterPolicy::encodeChannelScopeTargetSelector(selector, + direct_target); + FloodChannelScopeEntry previous = flood_channel_scopes[slot]; auto& entry = flood_channel_scopes[slot]; memset(&entry, 0, sizeof(entry)); - entry.region_id = region->id; + entry.target_id = direct_target ? direct_scope_slot + 1 : region->id; entry.selector = FloodFilterPolicy::encodeScopeSelector( - selector, slow_timing, path_selector); + target_selector, slow_timing, path_selector); if (isExactFloodChannelScopeSelector(selector)) { entry.channel_hash = hash_prefix[0]; memcpy(entry.secret, secret, sizeof(entry.secret)); } if (!saveFloodChannelScopes()) { entry = previous; + if (changed_direct_scope_slot >= 0) { + memcpy(flood_channel_direct_scopes[changed_direct_scope_slot], + previous_direct_scope, sizeof(previous_direct_scope)); + } strcpy(reply, "Err - save failed"); return; } @@ -6040,6 +7462,8 @@ void MyMesh::deleteFloodChannelScope(const char* args, char* reply) { strcpy(reply, "Err - save failed"); } else { memset(flood_channel_scopes, 0, sizeof(flood_channel_scopes)); + memset(flood_channel_direct_scopes, 0, + sizeof(flood_channel_direct_scopes)); strcpy(reply, "OK"); } return; @@ -6051,7 +7475,7 @@ void MyMesh::deleteFloodChannelScope(const char* args, char* reply) { strcpy(reply, "Err - use: del flood.channel.scope.|all"); return; } - if (flood_channel_scopes[index].region_id == 0) { + if (flood_channel_scopes[index].target_id == 0) { strcpy(reply, "Err - empty scope slot"); return; } @@ -6078,8 +7502,8 @@ void MyMesh::formatFloodChannelScopeRequirementDetail( return; } - uint8_t prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]; - char prefix_text[FLOOD_CHANNEL_BLOCK_PREFIX_LEN * 2 + 1]; + uint8_t prefix[FLOOD_CHANNEL_KEY_PREFIX_LEN]; + char prefix_text[FLOOD_CHANNEL_KEY_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", @@ -6132,7 +7556,7 @@ void MyMesh::setFloodChannelScopeRequirement(const char* args, char* reply) { uint8_t secret[PUB_KEY_SIZE]; uint8_t key_len = 0; - uint8_t hash_prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]; + uint8_t hash_prefix[FLOOD_CHANNEL_KEY_PREFIX_LEN]; char ignored_name[FLOOD_GROUP_MODERATION_NAME_LEN]; if (!parseFloodModerationChannel(channel_text, secret, key_len, hash_prefix, ignored_name, sizeof(ignored_name))) { @@ -6293,7 +7717,7 @@ static bool floodModerationPathMatches(const mesh::Packet* packet, uint8_t hash_ void MyMesh::loadFloodGroupModeration() { memset(flood_group_moderation, 0, sizeof(flood_group_moderation)); if (_fs == NULL || !_fs->exists(FLOOD_GROUP_MODERATION_FILE)) return; - File file = openFloodChannelBlockRead(_fs, FLOOD_GROUP_MODERATION_FILE); + File file = openFloodSettingsRead(_fs, FLOOD_GROUP_MODERATION_FILE); if (!file) return; FloodGroupModerationEntry loaded[FLOOD_GROUP_MODERATION_SLOTS]; @@ -6341,7 +7765,7 @@ void MyMesh::loadFloodGroupModeration() { bool MyMesh::saveFloodGroupModeration() { if (_fs == NULL) return false; - File file = openFloodChannelBlockWrite(_fs, FLOOD_GROUP_MODERATION_FILE); + File file = openFloodSettingsWrite(_fs, FLOOD_GROUP_MODERATION_FILE); if (!file) return false; const uint8_t magic[4] = {'F', 'G', 'M', '1'}; uint8_t count = FLOOD_GROUP_MODERATION_SLOTS; @@ -6646,7 +8070,7 @@ void MyMesh::setFloodGroupModeration(const char* args, char* reply) { uint8_t secret[PUB_KEY_SIZE]; uint8_t key_len = 0; - uint8_t hash_prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]; + uint8_t hash_prefix[FLOOD_CHANNEL_KEY_PREFIX_LEN]; char channel_name[FLOOD_GROUP_MODERATION_NAME_LEN]; if (!parseFloodModerationChannel(channel_text, secret, key_len, hash_prefix, channel_name, sizeof(channel_name))) { @@ -6747,7 +8171,7 @@ void MyMesh::loadClockSyncPrefs() { clock_sync_required_samples = CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT; if (_fs != NULL && _fs->exists(CLOCK_SYNC_PREFS_FILE)) { - File file = openFloodChannelBlockRead(_fs, CLOCK_SYNC_PREFS_FILE); + File file = openFloodSettingsRead(_fs, CLOCK_SYNC_PREFS_FILE); if (file) { uint8_t magic[4]; uint8_t mesh_enabled = 0; @@ -6786,7 +8210,7 @@ void MyMesh::loadClockSyncPrefs() { bool MyMesh::saveClockSyncPrefs() { if (_fs == NULL) return false; - File file = openFloodChannelBlockWrite(_fs, CLOCK_SYNC_PREFS_FILE); + File file = openFloodSettingsWrite(_fs, CLOCK_SYNC_PREFS_FILE); if (!file) return false; const uint8_t magic[4] = {'C', 'T', 'S', '4'}; const uint8_t mesh_enabled = clock_sync_mesh_enabled ? 1 : 0; @@ -7375,235 +8799,6 @@ void MyMesh::formatClockSyncStatus(const char* args, char* reply, size_t reply_l } } -static void trimFloodChannelBlockSelector(const char* selector, char* dest, size_t dest_len) { - selector = skipLocalSpaces(selector); - StrHelper::strncpy(dest, selector == NULL ? "" : selector, dest_len); - size_t len = strlen(dest); - while (len > 0 && dest[len - 1] == ' ') { - dest[--len] = 0; - } -} - -static bool parseFloodChannelBlockPrefixSelector(const char* selector, - uint8_t prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]) { - char text[16]; - trimFloodChannelBlockSelector(selector, text, sizeof(text)); - if (strlen(text) != FLOOD_CHANNEL_BLOCK_PREFIX_LEN * 2) { - return false; - } - for (int i = 0; i < FLOOD_CHANNEL_BLOCK_PREFIX_LEN * 2; i++) { - if (!mesh::Utils::isHexChar(text[i])) { - return false; - } - } - return mesh::Utils::fromHex(prefix, FLOOD_CHANNEL_BLOCK_PREFIX_LEN, text); -} - -static void formatFloodChannelBlockHops(char* dest, uint8_t max_hops) { - if (max_hops == FLOOD_CHANNEL_BLOCK_HOPS_ALL) { - strcpy(dest, "h=all"); - } else if (max_hops == FLOOD_CHANNEL_BLOCK_HOPS_INHERIT) { - strcpy(dest, "h=def"); - } else { - sprintf(dest, "h>%u", (unsigned int)max_hops); - } -} - -int MyMesh::findFloodChannelBlockBySelector(const char* selector) const { - uint8_t prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]; - if (parseFloodChannelBlockPrefixSelector(selector, prefix)) { - for (int i = 0; i < FLOOD_CHANNEL_BLOCK_SLOTS; i++) { - const auto& entry = flood_channel_blocks[i]; - if (entry.active && memcmp(entry.hash_prefix, prefix, sizeof(entry.hash_prefix)) == 0) { - return i; - } - } - return -1; - } - - int index = 0; - if (parsePositiveSelector(selector, index)) { - return (index >= 1 && index <= FLOOD_CHANNEL_BLOCK_SLOTS) ? index - 1 : -1; - } - - char name[FLOOD_CHANNEL_BLOCK_NAME_LEN]; - trimFloodChannelBlockSelector(selector, name, sizeof(name)); - if (name[0] == 0) { - return -1; - } - for (int i = 0; i < FLOOD_CHANNEL_BLOCK_SLOTS; i++) { - const auto& entry = flood_channel_blocks[i]; - if (entry.active && strcmp(entry.name, name) == 0) { - return i; - } - } - return -1; -} - -int MyMesh::findFloodChannelBlockSlot(const uint8_t prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN], const char* name) const { - int free_slot = -1; - for (int i = 0; i < FLOOD_CHANNEL_BLOCK_SLOTS; i++) { - const auto& entry = flood_channel_blocks[i]; - if (entry.active) { - if (memcmp(entry.hash_prefix, prefix, FLOOD_CHANNEL_BLOCK_PREFIX_LEN) == 0 || strcmp(entry.name, name) == 0) { - return i; - } - } else if (free_slot < 0) { - free_slot = i; - } - } - return free_slot; -} - -void MyMesh::formatFloodChannelBlockDetail(char* reply, int idx) const { - if (idx < 0 || idx >= FLOOD_CHANNEL_BLOCK_SLOTS) { - strcpy(reply, "Err - not found"); - return; - } - - const auto& entry = flood_channel_blocks[idx]; - if (!entry.active) { - snprintf(reply, 150, "> %d empty", idx + 1); - return; - } - - char prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN * 2 + 1]; - char hops[8]; - mesh::Utils::toHex(prefix, entry.hash_prefix, FLOOD_CHANNEL_BLOCK_PREFIX_LEN); - formatFloodChannelBlockHops(hops, entry.max_hops); - snprintf(reply, 150, "> %d %s %u %s %s", idx + 1, prefix, (unsigned int)entry.key_len * 8, hops, entry.name); -} - -void MyMesh::setFloodChannelBlock(int index, const uint8_t* secret, uint8_t key_len, - const char* name, uint8_t max_hops, char* reply) { - if ((key_len != CIPHER_KEY_SIZE && key_len != PUB_KEY_SIZE) || secret == NULL || name == NULL || name[0] == 0) { - strcpy(reply, "Err - bad params"); - return; - } - if (index < 0 || index > FLOOD_CHANNEL_BLOCK_SLOTS) { - snprintf(reply, 160, "Err - index 1-%d", FLOOD_CHANNEL_BLOCK_SLOTS); - return; - } - if (max_hops != FLOOD_CHANNEL_BLOCK_HOPS_ALL - && max_hops != FLOOD_CHANNEL_BLOCK_HOPS_INHERIT - && (max_hops < 1 || max_hops > 7)) { - strcpy(reply, "Err - bad hops"); - return; - } - - uint8_t prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]; - deriveFloodChannelBlockPrefix(secret, key_len, prefix); - int slot = index > 0 ? index - 1 : findFloodChannelBlockSlot(prefix, name); - if (slot < 0 || slot >= FLOOD_CHANNEL_BLOCK_SLOTS) { - strcpy(reply, "Err - block list full"); - return; - } - - auto& entry = flood_channel_blocks[slot]; - clearFloodChannelBlockEntry(entry); - entry.active = true; - entry.key_len = key_len; - entry.max_hops = max_hops; - memcpy(entry.secret, secret, PUB_KEY_SIZE); - if (entry.key_len == CIPHER_KEY_SIZE) { - memset(&entry.secret[CIPHER_KEY_SIZE], 0, PUB_KEY_SIZE - CIPHER_KEY_SIZE); - } - deriveFloodChannelBlockPrefix(entry.secret, entry.key_len, entry.hash_prefix); - StrHelper::strncpy(entry.name, name, sizeof(entry.name)); - - if (!saveFloodChannelBlocks()) { - strcpy(reply, "Err - save failed"); - return; - } - formatFloodChannelBlockDetail(reply, slot); -} - -void MyMesh::formatFloodChannelBlocks(const char* selector, char* reply) { - if (!selectorIsEmpty(selector)) { - int idx = findFloodChannelBlockBySelector(selector); - if (idx < 0) { - strcpy(reply, "Err - not found"); - } else { - formatFloodChannelBlockDetail(reply, idx); - } - return; - } - - char* out = reply; - const size_t reply_limit = 150; - size_t remaining = reply_limit; - char hops[8]; - formatFloodChannelBlockHops(hops, _prefs.flood_channel_block_max_hops); - size_t full_len = 2 + strlen(hops); - for (int i = 0; i < FLOOD_CHANNEL_BLOCK_SLOTS; i++) { - const auto& entry = flood_channel_blocks[i]; - size_t display_len = entry.active ? strlen(entry.name) : 1; - full_len += 1 + (i + 1 >= 10 ? 2 : 1) + 1 + display_len; - if (entry.active && entry.max_hops != FLOOD_CHANNEL_BLOCK_HOPS_INHERIT) { - char row_hops[8]; - formatFloodChannelBlockHops(row_hops, entry.max_hops); - full_len += 1 + strlen(row_hops); - } - } - bool trim_names = full_len >= reply_limit; - - int written = snprintf(out, remaining, "> %s", hops); - if (written < 0 || (size_t)written >= remaining) { - reply[0] = 0; - return; - } - out += written; - remaining -= written; - - for (int i = 0; i < FLOOD_CHANNEL_BLOCK_SLOTS && remaining > 1; i++) { - const char* display = "-"; - char short_display[6]; - const auto& entry = flood_channel_blocks[i]; - if (entry.active) { - char row_hops[8]; - row_hops[0] = 0; - if (entry.max_hops != FLOOD_CHANNEL_BLOCK_HOPS_INHERIT) { - formatFloodChannelBlockHops(row_hops, entry.max_hops); - } - if (trim_names) { - StrHelper::strncpy(short_display, entry.name, sizeof(short_display)); - if (strlen(entry.name) >= sizeof(short_display)) { - short_display[sizeof(short_display) - 2] = '~'; - short_display[sizeof(short_display) - 1] = 0; - } - display = short_display; - } else { - display = entry.name; - } - written = snprintf(out, remaining, " %d:%s%s%s", i + 1, display, - row_hops[0] ? "/" : "", row_hops); - } else { - written = snprintf(out, remaining, " %d:%s", i + 1, display); - } - if (written < 0 || (size_t)written >= remaining) { - out[remaining - 1] = 0; - break; - } - out += written; - remaining -= written; - } -} - -void MyMesh::deleteFloodChannelBlock(const char* selector, char* reply) { - int idx = findFloodChannelBlockBySelector(selector); - if (idx < 0 || idx >= FLOOD_CHANNEL_BLOCK_SLOTS || !flood_channel_blocks[idx].active) { - strcpy(reply, "Err - not found"); - return; - } - - clearFloodChannelBlockEntry(flood_channel_blocks[idx]); - if (!saveFloodChannelBlocks()) { - strcpy(reply, "Err - save failed"); - return; - } - strcpy(reply, "OK"); -} - void MyMesh::formatStatsReply(char *reply) { StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr); } @@ -8134,17 +9329,21 @@ static bool isFilterMgrAllowed(const char* cmd) { || commandFamilyMatches(cmd, "get loop.detect") || commandFamilyMatches(cmd, "get flood.max") || commandFamilyMatches(cmd, "get flood.channel.data") - || commandFamilyMatches(cmd, "get flood.channel.block") || commandFamilyMatches(cmd, "get flood.filter") +#if MESH_ENABLE_FLOOD_RULE_ENGINE + || commandFamilyMatches(cmd, "get flood.rule") +#endif || commandFamilyMatches(cmd, "get flood.moderation")) return true; - // General payload/hop filters plus the existing keyed-channel and flood-hop gates. + // General payload/hop filters plus the remaining flood-hop gates. return commandFamilyMatches(cmd, "set flood.filter") || commandFamilyMatches(cmd, "del flood.filter") +#if MESH_ENABLE_FLOOD_RULE_ENGINE + || commandFamilyMatches(cmd, "set flood.rule") + || commandFamilyMatches(cmd, "del flood.rule") +#endif || commandFamilyMatches(cmd, "set flood.moderation") || commandFamilyMatches(cmd, "del flood.moderation") || commandFamilyMatches(cmd, "set flood.channel.data") - || commandFamilyMatches(cmd, "set flood.channel.block") - || commandFamilyMatches(cmd, "del flood.channel.block") || commandFamilyMatches(cmd, "set flood.max") || commandFamilyMatches(cmd, "set loop.detect") || commandFamilyMatches(cmd, "set repeat"); @@ -8367,9 +9566,17 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * } else if (commandFamilyMatches(command, "get flood.filter")) { formatFloodPacketFilters(command + strlen("get flood.filter"), reply); } else if (commandFamilyMatches(command, "set flood.filter")) { - setFloodPacketFilter(command + strlen("set flood.filter"), reply); + setFloodPacketFilter(command + strlen("set flood.filter"), reply, false); } else if (commandFamilyMatches(command, "del flood.filter")) { deleteFloodPacketFilter(command + strlen("del flood.filter"), reply); +#if MESH_ENABLE_FLOOD_RULE_ENGINE + } else if (commandFamilyMatches(command, "get flood.rule")) { + formatFloodPacketFilters(command + strlen("get flood.rule"), reply); + } else if (commandFamilyMatches(command, "set flood.rule")) { + setFloodPacketFilter(command + strlen("set flood.rule"), reply, true); + } else if (commandFamilyMatches(command, "del flood.rule")) { + deleteFloodPacketFilter(command + strlen("del flood.rule"), reply); +#endif } else if (commandFamilyMatches(command, "get flood.moderation")) { formatFloodGroupModeration(command + strlen("get flood.moderation"), reply); } else if (commandFamilyMatches(command, "set flood.moderation")) { diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 44f06655..5c897360 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -73,6 +73,7 @@ #include #include #include +#include #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) #include #endif @@ -138,8 +139,22 @@ struct NeighbourInfo { #define MAX_SCHEDULED_RADIO_SETTINGS (MAX_SCHEDULED_RADIO_SETTINGS_PER_TYPE * 2) +#ifndef MESH_ENABLE_FLOOD_RULE_ENGINE + // STM32WL repeater images have only 240 KB of application flash. They keep + // the established dynamic flood.filter table unless a larger target profile + // explicitly opts into the generalized rule parser and persistence format. + #if defined(STM32_PLATFORM) + #define MESH_ENABLE_FLOOD_RULE_ENGINE 0 + #else + #define MESH_ENABLE_FLOOD_RULE_ENGINE 1 + #endif +#endif #ifndef FLOOD_PACKET_FILTER_SLOTS - #define FLOOD_PACKET_FILTER_SLOTS 16 + #if MESH_ENABLE_FLOOD_RULE_ENGINE + #define FLOOD_PACKET_FILTER_SLOTS 31 + #else + #define FLOOD_PACKET_FILTER_SLOTS 16 + #endif #endif #define FLOOD_PACKET_FILTER_ANY_TYPE 0xFF #define FLOOD_PACKET_FILTER_MAX_HOPS 63 @@ -151,6 +166,9 @@ struct NeighbourInfo { #endif #define FLOOD_PACKET_FILTER_BLACKLIST_REPLACE_MAX 18 #define FLOOD_PACKET_FILTER_PATH_ID_SIZE 3 +#define FLOOD_PACKET_FILTER_PATH_PREFIX_HOPS_MAX 3 +#define FLOOD_PACKET_FILTER_PATH_PREFIX_BYTES_MAX \ + (FLOOD_PACKET_FILTER_PATH_PREFIX_HOPS_MAX * 3) #ifndef FLOOD_CHANNEL_SCOPE_SLOTS #if defined(ESP32) @@ -164,6 +182,18 @@ 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_DIRECT_SCOPE_SLOTS + // STM32WL repeater images and RAM are both exceptionally tight. Keep one + // reusable regionless target there; other platforms scale with the rule + // table up to the region-map capacity. + #if defined(STM32_PLATFORM) + #define FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS 1 + #elif FLOOD_CHANNEL_SCOPE_SLOTS < MAX_REGION_ENTRIES + #define FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS FLOOD_CHANNEL_SCOPE_SLOTS + #else + #define FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS MAX_REGION_ENTRIES + #endif +#endif #ifndef FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS #define FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS FLOOD_CHANNEL_SCOPE_SLOTS #endif @@ -234,6 +264,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks uint64_t uptime_millis; unsigned long next_local_advert, next_flood_advert; mesh::DeferredCliCommand deferred_cli_command; + mesh::RemoteCliReplyCache remote_cli_reply_cache; TransportKey deferred_cli_reply_scope; bool deferred_cli_reply_scoped; uint32_t pending_self_advert_delay; @@ -259,7 +290,7 @@ 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_regionless_scope_set; bool recv_pkt_channel_scope_bypass; bool recv_pkt_channel_scope_rejected; TransportKey default_scope; @@ -277,14 +308,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks uint8_t prefix_len; uint32_t last_heard_millis; }; - struct FloodChannelBlockEntry { - bool active; - uint8_t key_len; - uint8_t max_hops; - uint8_t hash_prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]; - uint8_t secret[PUB_KEY_SIZE]; - char name[FLOOD_CHANNEL_BLOCK_NAME_LEN]; - }; struct FloodPacketFilterEntry { bool active; uint8_t payload_type; @@ -293,12 +316,37 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks bool suspend_on_temp_radio; char scope_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN]; bool match_blacklisted_path; +#if !MESH_ENABLE_FLOOD_RULE_ENGINE bool scope_requires_region_match; +#endif bool scope_uses_slow_timing; +#if MESH_ENABLE_FLOOD_RULE_ENGINE + uint8_t incoming_scope_kind; + char incoming_scope_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN]; + uint8_t channel_key_len; + uint8_t channel_hash; + uint8_t channel_secret[PUB_KEY_SIZE]; + char channel_name[FLOOD_GROUP_MODERATION_NAME_LEN]; + uint8_t path_hash_size; + uint8_t path_hops; + uint8_t path[FLOOD_PACKET_FILTER_PATH_PREFIX_BYTES_MAX]; + char target_region_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN]; + bool drop_on_match; + bool rate_limit_enabled; + uint16_t rate_per_minute; + uint8_t priority; + bool stop_on_match; + uint32_t rate_window_started; + uint16_t rate_window_count; + bool rate_window_active; +#endif }; struct FloodChannelScopeEntry { - uint16_t region_id; // zero means unused - // Low bits select txt:*/login:*/other:* or an exact channel-key length. + // Zero means unused. Otherwise this is either a region ID or a one-based + // flood_channel_direct_scopes index, as selected by selector. + uint16_t target_id; + // Encodes txt:*/login:*/other:* or an exact key length, target kind, + // optional path selector, and fast/slow timing. uint8_t selector; uint8_t channel_hash; uint8_t secret[PUB_KEY_SIZE]; @@ -311,7 +359,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks struct FloodGroupModerationEntry { bool active; uint8_t key_len; - uint8_t hash_prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]; + uint8_t hash_prefix[FLOOD_CHANNEL_KEY_PREFIX_LEN]; uint8_t secret[PUB_KEY_SIZE]; char channel_name[FLOOD_GROUP_MODERATION_NAME_LEN]; char sender[FLOOD_GROUP_MODERATION_NAME_LEN]; @@ -334,15 +382,17 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks }; mutable FloodRetryBridgeState flood_retry_bridge_states[MAX_FLOOD_RETRY_SLOTS]; 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]; + char flood_channel_direct_scopes[FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS] + [FLOOD_PACKET_FILTER_SCOPE_NAME_LEN]; FloodChannelScopeRequireEntry flood_channel_scope_requirements[FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS]; FloodGroupModerationEntry flood_group_moderation[FLOOD_GROUP_MODERATION_SLOTS]; + uint32_t recv_pkt_filter_match_mask; ClockSyncSample clock_sync_samples[CLOCK_SYNC_SAMPLE_SLOTS]; bool clock_sync_mesh_enabled; bool clock_sync_mesh_edge_enabled; @@ -530,32 +580,40 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks void refreshScheduledRadioState(); void processScheduledRadioSettings(); bool isMillisTimerDue(unsigned long timestamp) const; - void loadFloodChannelBlocks(); - bool saveFloodChannelBlocks(); - void seedDefaultFloodChannelBlocks(); - void clearFloodChannelBlockEntry(FloodChannelBlockEntry& entry); - void deriveFloodChannelBlockPrefix(const uint8_t* secret, uint8_t key_len, - uint8_t prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]) const; - uint8_t resolveFloodChannelBlockHops(uint8_t max_hops) const; - bool floodChannelBlockHopApplies(const mesh::Packet* packet, uint8_t max_hops) const; bool floodChannelDataHopApplies(const mesh::Packet* packet) const; - bool floodChannelBlockMatches(const FloodChannelBlockEntry& entry, const mesh::Packet* packet) const; - bool shouldBlockFloodChannelForward(const mesh::Packet* packet) const; - void loadFloodPacketFilters(); + bool loadFloodPacketFilters(); bool saveFloodPacketFilters(); +#if MESH_ENABLE_FLOOD_RULE_ENGINE + void migrateLegacyFloodChannelBlocks(); +#endif 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 floodPacketFilterFieldsMatch(const FloodPacketFilterEntry& entry, + const mesh::Packet* packet, + bool incoming_is_scoped, + uint16_t incoming_transport_code, + bool incoming_region_allowed, + const RegionEntry* incoming_region) const; + bool authenticateFloodPacketFilterChannel( + const FloodPacketFilterEntry& entry, + const mesh::Packet* packet) const; + int nextFloodPacketFilterMatch(uint32_t match_mask, + uint32_t visited_mask) const; + uint32_t applyFloodPacketFilterStop(uint32_t match_mask) const; + uint32_t evaluateFloodPacketFilterMatches( + const mesh::Packet* packet, bool incoming_region_allowed, + const RegionEntry* incoming_region) const; + bool applyFloodPacketFilterScope(mesh::Packet* packet, uint32_t match_mask, bool& scope_set, bool& fast_track, bool log_change = true); bool shouldBlockFloodPacketForward(const mesh::Packet* packet) const; + void commitFloodPacketFilterRates(const mesh::Packet* packet); 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 setFloodPacketFilter(const char* args, char* reply, + bool require_explicit_action = false); void deleteFloodPacketFilter(const char* args, char* reply); void formatFloodPacketFilterBlacklist(const char* args, char* reply) const; void setFloodPacketFilterBlacklist(const char* args, char* reply); @@ -564,8 +622,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks bool saveFloodChannelScopes(bool empty_table = false); bool applyFloodChannelScopeTarget(mesh::Packet* packet, const FloodChannelScopeEntry& entry, bool& scope_changed, bool& fast_track, + bool& regionless_scope_set, bool log_change = true); bool applyFloodChannelScope(mesh::Packet* packet, bool& fast_track, + bool& regionless_scope_set, bool log_change = true); static uint8_t scoreFloodTransportScope(const mesh::Packet* packet, void* context); uint8_t getFloodTransportScopeDepth(const mesh::Packet* packet); @@ -609,9 +669,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks void formatClockSyncStatus(const char* args, char* reply, size_t reply_len) const; void formatClockSyncTable(char* reply, size_t reply_len) const; void formatClockSyncSampleDetail(int index, char* reply, size_t reply_len) const; - int findFloodChannelBlockBySelector(const char* selector) const; - int findFloodChannelBlockSlot(const uint8_t prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN], const char* name) const; - void formatFloodChannelBlockDetail(char* reply, int idx) const; bool hasScheduledRadioWorkDue() const; uint32_t limitSleepToMillisTimer(unsigned long timestamp, uint32_t sleep_secs) const; uint32_t limitSleepToScheduledRadioWork(uint32_t sleep_secs) const; @@ -791,11 +848,6 @@ public: void startRegionsLoad() override; bool saveRegions() override; void onDefaultRegionChanged(const RegionEntry* r) override; - void setFloodChannelBlock(int index, const uint8_t* secret, uint8_t key_len, - const char* name, uint8_t max_hops, char* reply) override; - void formatFloodChannelBlocks(const char* selector, char* reply) override; - void deleteFloodChannelBlock(const char* selector, char* reply) override; - mesh::LocalIdentity& getSelfId() override { return self_id; } void saveIdentity(const mesh::LocalIdentity& new_id) override; diff --git a/examples/simple_room_server/FloodRuleEngine.cpp b/examples/simple_room_server/FloodRuleEngine.cpp new file mode 100644 index 00000000..96beed2a --- /dev/null +++ b/examples/simple_room_server/FloodRuleEngine.cpp @@ -0,0 +1,1573 @@ +#include "FloodRuleEngine.h" + +#if defined(MESHCORE_ESP32_FULL_PROFILE) + +#include +#include + +#include +#include + +namespace { + +static const char RULE_FILE[] = "/flood_filter"; +static const char RULE_USAGE[] = + "Err - use: set flood.rule[.n] type= [hops=] [...]"; +static const char DUPLICATE_OPTION[] = "Err - duplicate filter option"; + +// Channel encryption uses a 128-bit key, while MACThenDecrypt takes a +// PUB_KEY_SIZE buffer. Keep the unused half zero-padded like GroupChannel. +static const uint8_t PUBLIC_CHANNEL_SECRET[PUB_KEY_SIZE] = { + 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, + 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72 +}; + +static const char* skipSpaces(const char* text) { + while (text != NULL && *text == ' ') text++; + return text == NULL ? "" : text; +} + +static bool asciiEqual(const char* left, const char* right) { + if (left == NULL || right == NULL) return false; + while (*left && *right) { + char a = *left++; + char b = *right++; + if (a >= 'A' && a <= 'Z') a = (char)(a - 'A' + 'a'); + if (b >= 'A' && b <= 'Z') b = (char)(b - 'A' + 'a'); + if (a != b) return false; + } + return *left == 0 && *right == 0; +} + +static bool asciiStartsWith(const char* text, const char* prefix) { + if (text == NULL || prefix == NULL) return false; + while (*prefix) { + char a = *text++; + char b = *prefix++; + if (a >= 'A' && a <= 'Z') a = (char)(a - 'A' + 'a'); + if (b >= 'A' && b <= 'Z') b = (char)(b - 'A' + 'a'); + if (a != b) return false; + } + return true; +} + +static void copyString(char* dest, const char* src, size_t dest_len) { + if (dest == NULL || dest_len == 0) return; + if (src == NULL) src = ""; + size_t len = strlen(src); + if (len >= dest_len) len = dest_len - 1; + memcpy(dest, src, len); + dest[len] = 0; +} + +static bool parseUnsigned(const char* text, uint32_t maximum, + uint32_t& value) { + if (text == NULL || *text == 0) return false; + uint32_t parsed = 0; + for (const char* p = text; *p; p++) { + if (*p < '0' || *p > '9') return false; + uint32_t digit = (uint32_t)(*p - '0'); + if (digit > maximum || parsed > (maximum - digit) / 10U) return false; + parsed = parsed * 10U + digit; + } + value = parsed; + return true; +} + +static const char* payloadTypeName(uint8_t type) { + switch (type) { + case PAYLOAD_TYPE_REQ: return "req"; + case PAYLOAD_TYPE_RESPONSE: return "response"; + case PAYLOAD_TYPE_TXT_MSG: return "txt_msg"; + case PAYLOAD_TYPE_ACK: return "ack"; + case PAYLOAD_TYPE_ADVERT: return "advert"; + case PAYLOAD_TYPE_GRP_TXT: return "grp_txt"; + case PAYLOAD_TYPE_GRP_DATA: return "grp_data"; + case PAYLOAD_TYPE_ANON_REQ: return "anon_req"; + case PAYLOAD_TYPE_PATH: return "path"; + case PAYLOAD_TYPE_TRACE: return "trace"; + case PAYLOAD_TYPE_MULTIPART: return "multipart"; + case PAYLOAD_TYPE_CONTROL: return "control"; + case PAYLOAD_TYPE_OTA: return "ota"; + case 0x0D: return "reserved13"; + case 0x0E: return "reserved14"; + case PAYLOAD_TYPE_RAW_CUSTOM: return "raw_custom"; + case FloodRuleEngine::ANY_TYPE: return "any"; + default: return "invalid"; + } +} + +static bool parsePayloadType(const char* text, uint8_t& type) { + uint32_t numeric = 0; + if (parseUnsigned(text, PH_TYPE_MASK, numeric)) { + type = (uint8_t)numeric; + return true; + } + if (text != NULL && text[0] == '0' + && (text[1] == 'x' || text[1] == 'X') && text[2] != 0) { + uint8_t parsed = 0; + for (const char* p = text + 2; *p; p++) { + uint8_t digit; + if (*p >= '0' && *p <= '9') digit = (uint8_t)(*p - '0'); + else if (*p >= 'a' && *p <= 'f') digit = (uint8_t)(*p - 'a' + 10); + else if (*p >= 'A' && *p <= 'F') digit = (uint8_t)(*p - 'A' + 10); + else return false; + if (parsed > (PH_TYPE_MASK - digit) / 16U) return false; + parsed = (uint8_t)(parsed * 16U + digit); + } + type = parsed; + return true; + } + if (asciiStartsWith(text, "payload_type_")) text += strlen("payload_type_"); + if (asciiEqual(text, "any")) type = FloodRuleEngine::ANY_TYPE; + else if (asciiEqual(text, "req")) type = PAYLOAD_TYPE_REQ; + else if (asciiEqual(text, "response") || asciiEqual(text, "resp")) type = PAYLOAD_TYPE_RESPONSE; + else if (asciiEqual(text, "txt_msg") || asciiEqual(text, "txt")) type = PAYLOAD_TYPE_TXT_MSG; + else if (asciiEqual(text, "ack")) type = PAYLOAD_TYPE_ACK; + else if (asciiEqual(text, "advert")) type = PAYLOAD_TYPE_ADVERT; + else if (asciiEqual(text, "grp_txt") || asciiEqual(text, "group_text")) type = PAYLOAD_TYPE_GRP_TXT; + else if (asciiEqual(text, "grp_data") || asciiEqual(text, "group_data")) type = PAYLOAD_TYPE_GRP_DATA; + else if (asciiEqual(text, "anon_req")) type = PAYLOAD_TYPE_ANON_REQ; + else if (asciiEqual(text, "path")) type = PAYLOAD_TYPE_PATH; + else if (asciiEqual(text, "trace")) type = PAYLOAD_TYPE_TRACE; + else if (asciiEqual(text, "multipart")) type = PAYLOAD_TYPE_MULTIPART; + else if (asciiEqual(text, "control")) type = PAYLOAD_TYPE_CONTROL; + else if (asciiEqual(text, "ota")) type = PAYLOAD_TYPE_OTA; + else if (asciiEqual(text, "raw") || asciiEqual(text, "raw_custom")) type = PAYLOAD_TYPE_RAW_CUSTOM; + else return false; + return true; +} + +static bool parseHopSpec(const char* text, uint8_t& min_hops, + uint8_t& max_hops) { + if (text == NULL || *text == 0) return false; + if (asciiEqual(text, "all")) { + min_hops = 0; + max_hops = FloodRuleEngine::MAX_HOPS; + return true; + } + char spec[12]; + if (strlen(text) >= sizeof(spec)) return false; + copyString(spec, text, sizeof(spec)); + size_t len = strlen(spec); + uint32_t parsed_min = 0; + uint32_t parsed_max = 0; + if (len > 1 && spec[len - 1] == '+') { + spec[len - 1] = 0; + if (!parseUnsigned(spec, FloodRuleEngine::MAX_HOPS, parsed_min)) return false; + min_hops = (uint8_t)parsed_min; + max_hops = FloodRuleEngine::MAX_HOPS; + return true; + } + char* dash = strchr(spec, '-'); + if (dash != NULL) { + *dash++ = 0; + if (!parseUnsigned(spec, FloodRuleEngine::MAX_HOPS, parsed_min) + || !parseUnsigned(dash, FloodRuleEngine::MAX_HOPS, parsed_max) + || parsed_min > parsed_max) { + return false; + } + min_hops = (uint8_t)parsed_min; + max_hops = (uint8_t)parsed_max; + return true; + } + if (!parseUnsigned(spec, FloodRuleEngine::MAX_HOPS, parsed_min)) return false; + min_hops = max_hops = (uint8_t)parsed_min; + return true; +} + +static void formatHopSpec(char* dest, size_t dest_len, uint8_t min_hops, + uint8_t max_hops) { + if (min_hops == 0 && max_hops == FloodRuleEngine::MAX_HOPS) { + snprintf(dest, dest_len, "all"); + } else if (max_hops == FloodRuleEngine::MAX_HOPS) { + snprintf(dest, dest_len, "%u+", (unsigned int)min_hops); + } else if (min_hops == max_hops) { + snprintf(dest, dest_len, "%u", (unsigned int)min_hops); + } else { + snprintf(dest, dest_len, "%u-%u", (unsigned int)min_hops, + (unsigned int)max_hops); + } +} + +static bool normalizeScopeName(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 len = 0; + while (text[len]) { + uint8_t c = (uint8_t)text[len]; + if (!RegionMap::is_name_char(c) || c == '#' || c == '$') return false; + len++; + } + if (len + 2 > dest_len) return false; + dest[0] = '#'; + memcpy(&dest[1], text, len + 1); + return true; +} + +static bool validStoredScopeName(const char* text) { + if (text == NULL || text[0] != '#' || text[1] == 0) return false; + for (size_t i = 1; i < FloodRuleEngine::NAME_LEN; i++) { + uint8_t c = (uint8_t)text[i]; + if (c == 0) return true; + if (!RegionMap::is_name_char(c) || c == '#' || c == '$') return false; + } + return false; +} + +static bool validStoredRegionName(const char* text) { + if (text == NULL || text[0] == 0 || strcmp(text, "*") == 0) return false; + for (size_t i = 0; i < FloodRuleEngine::NAME_LEN; i++) { + uint8_t c = (uint8_t)text[i]; + if (c == 0) return true; + if (!RegionMap::is_name_char(c)) return false; + } + return false; +} + +static void deriveScopeKey(const char* scope_name, TransportKey& scope) { + mesh::Utils::sha256(scope.key, sizeof(scope.key), + (const uint8_t*)scope_name, strlen(scope_name)); +} + +static bool parseChannel(const char* text, uint8_t secret[PUB_KEY_SIZE], + uint8_t& key_len, uint8_t& channel_hash, + char* name, size_t name_len) { + if (text == NULL || *text == 0) return false; + memset(secret, 0, PUB_KEY_SIZE); + if (asciiEqual(text, "public")) { + key_len = CIPHER_KEY_SIZE; + memcpy(secret, PUBLIC_CHANNEL_SECRET, sizeof(PUBLIC_CHANNEL_SECRET)); + copyString(name, "public", name_len); + } else if (text[0] == '#' && text[1] != 0) { + if (strlen(text) >= name_len) return false; + key_len = CIPHER_KEY_SIZE; + mesh::Utils::sha256(secret, key_len, (const uint8_t*)text, + strlen(text)); + copyString(name, text, name_len); + } else { + size_t hex_len = strlen(text); + if (hex_len != CIPHER_KEY_SIZE * 2 && hex_len != PUB_KEY_SIZE * 2) { + return false; + } + for (size_t i = 0; i < hex_len; i++) { + if (!mesh::Utils::isHexChar(text[i])) return false; + } + key_len = (uint8_t)(hex_len / 2); + if (!mesh::Utils::fromHex(secret, key_len, text)) return false; + uint8_t prefix[4]; + mesh::Utils::sha256(prefix, sizeof(prefix), secret, key_len); + char prefix_hex[9]; + mesh::Utils::toHex(prefix_hex, prefix, sizeof(prefix)); + snprintf(name, name_len, "key:%s", prefix_hex); + } + mesh::Utils::sha256(&channel_hash, sizeof(channel_hash), secret, key_len); + return true; +} + +static bool parsePathPrefix(const char* text, uint8_t& hash_size, + uint8_t& path_hops, + uint8_t path[FloodRuleEngine::PATH_PREFIX_BYTES_MAX]) { + memset(path, 0, FloodRuleEngine::PATH_PREFIX_BYTES_MAX); + if (text == NULL || *text == 0 || strcmp(text, "*") == 0) { + hash_size = 0; + path_hops = 0; + return text != NULL && *text != 0; + } + if (strlen(text) >= 32) return false; + char input[32]; + copyString(input, text, sizeof(input)); + char* token = input; + uint8_t parsed_size = 0; + uint8_t count = 0; + while (token != NULL) { + char* comma = strchr(token, ','); + if (comma != NULL) *comma = 0; + size_t hex_len = strlen(token); + if (hex_len != 2 && hex_len != 4 && hex_len != 6) return false; + uint8_t token_size = (uint8_t)(hex_len / 2); + if ((parsed_size != 0 && parsed_size != token_size) + || count >= FloodRuleEngine::PATH_PREFIX_HOPS_MAX) { + return false; + } + for (size_t i = 0; i < hex_len; i++) { + if (!mesh::Utils::isHexChar(token[i])) return false; + } + parsed_size = token_size; + if (!mesh::Utils::fromHex(&path[count * parsed_size], parsed_size, + token)) { + return false; + } + count++; + token = comma == NULL ? NULL : comma + 1; + } + if (count == 0) return false; + hash_size = parsed_size; + path_hops = count; + return true; +} + +static void formatPathPrefix( + char* dest, size_t dest_len, uint8_t hash_size, uint8_t path_hops, + const uint8_t path[FloodRuleEngine::PATH_PREFIX_BYTES_MAX]) { + if (hash_size == 0 || path_hops == 0) { + copyString(dest, "*", dest_len); + return; + } + size_t used = 0; + dest[0] = 0; + for (uint8_t i = 0; i < path_hops; i++) { + char hop[7]; + mesh::Utils::toHex(hop, &path[i * hash_size], hash_size); + int written = snprintf(&dest[used], dest_len - used, "%s%s", + i == 0 ? "" : ",", hop); + if (written < 0 || (size_t)written >= dest_len - used) { + dest[dest_len - 1] = 0; + return; + } + used += (size_t)written; + } +} + +static bool commandMatches(const char* command, const char* base) { + size_t len = strlen(base); + return strncmp(command, base, len) == 0 + && (command[len] == 0 || command[len] == '.' || command[len] == ' '); +} + +static File openRead(FILESYSTEM* fs, const char* path) { + return fs->open(path); +} + +static File openWrite(FILESYSTEM* fs, const char* path) { + return fs->open(path, "w", true); +} + +} // namespace + +FloodRuleEngine::FloodRuleEngine() : _fs(NULL), _regions(NULL) { + memset(_entries, 0, sizeof(_entries)); +} + +void FloodRuleEngine::begin(FILESYSTEM* fs, RegionMap* regions) { + _fs = fs; + _regions = regions; + load(); +} + +void FloodRuleEngine::seedDefaults() { + memset(_entries, 0, sizeof(_entries)); + Entry& entry = _entries[0]; + entry.active = true; + entry.payload_type = PAYLOAD_TYPE_OTA; + entry.min_hops = 0; + entry.max_hops = MAX_HOPS; + entry.suspend_on_temp_radio = true; + entry.drop_on_match = true; +} + +void FloodRuleEngine::load() { + memset(_entries, 0, sizeof(_entries)); + if (_fs == NULL) { + seedDefaults(); + return; + } + if (!_fs->exists(RULE_FILE)) { + seedDefaults(); + save(); + return; + } + + File file = openRead(_fs, RULE_FILE); + if (!file) return; + + Entry* loaded = _entries; + auto readExact = [&file](void* dest, size_t len) { + return file.read((uint8_t*)dest, len) == len; + }; + + uint8_t magic[4]; + uint8_t count = 0; + bool success = readExact(magic, sizeof(magic)); + bool version_6 = success && memcmp(magic, "FPF6", 4) == 0; + bool version_7 = success && memcmp(magic, "FPF7", 4) == 0; + success = (version_6 || version_7) + && readExact(&count, sizeof(count)) && count <= RULE_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; + uint8_t drop_on_match = 0; + uint8_t rate_limit_enabled = 0; + uint8_t stop_on_match = 0; + + success = readExact(&active, sizeof(active)) + && readExact(&loaded[i].payload_type, + sizeof(loaded[i].payload_type)) + && readExact(&loaded[i].min_hops, sizeof(loaded[i].min_hops)) + && readExact(&loaded[i].max_hops, sizeof(loaded[i].max_hops)) + && readExact(&suspend_on_temp_radio, + sizeof(suspend_on_temp_radio)) + && readExact(loaded[i].scope_name, sizeof(loaded[i].scope_name)) + && readExact(&match_blacklisted_path, + sizeof(match_blacklisted_path)) + && readExact(&scope_requires_region_match, + sizeof(scope_requires_region_match)) + && readExact(&scope_uses_slow_timing, + sizeof(scope_uses_slow_timing)); + if (success && version_7) { + success = readExact(&loaded[i].incoming_scope_kind, + sizeof(loaded[i].incoming_scope_kind)) + && readExact(loaded[i].incoming_scope_name, + sizeof(loaded[i].incoming_scope_name)) + && readExact(&loaded[i].channel_key_len, + sizeof(loaded[i].channel_key_len)) + && readExact(loaded[i].channel_secret, + sizeof(loaded[i].channel_secret)) + && readExact(loaded[i].channel_name, + sizeof(loaded[i].channel_name)) + && readExact(&loaded[i].path_hash_size, + sizeof(loaded[i].path_hash_size)) + && readExact(&loaded[i].path_hops, + sizeof(loaded[i].path_hops)) + && readExact(loaded[i].path, sizeof(loaded[i].path)) + && readExact(&drop_on_match, sizeof(drop_on_match)) + && readExact(&rate_limit_enabled, sizeof(rate_limit_enabled)) + && readExact(&loaded[i].rate_per_minute, + sizeof(loaded[i].rate_per_minute)) + && readExact(loaded[i].target_region_name, + sizeof(loaded[i].target_region_name)) + && readExact(&loaded[i].priority, sizeof(loaded[i].priority)) + && readExact(&stop_on_match, sizeof(stop_on_match)); + } else { + loaded[i].incoming_scope_kind = scope_requires_region_match + ? FloodFilterPolicy::RULE_IN_ALLOWED + : FloodFilterPolicy::RULE_IN_ANY; + drop_on_match = loaded[i].scope_name[0] == 0 ? 1 : 0; + } + + 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_uses_slow_timing = scope_uses_slow_timing != 0; + loaded[i].drop_on_match = drop_on_match != 0; + loaded[i].rate_limit_enabled = rate_limit_enabled != 0; + loaded[i].stop_on_match = stop_on_match != 0; + if (success && (active > 1 || suspend_on_temp_radio > 1 + || match_blacklisted_path > 1 || scope_requires_region_match > 1 + || scope_uses_slow_timing > 1 || drop_on_match > 1 + || rate_limit_enabled > 1 || stop_on_match > 1)) { + success = false; + } + if (!success) break; + if (!loaded[i].active) { + memset(&loaded[i], 0, sizeof(loaded[i])); + continue; + } + + bool scope_name_terminated = memchr( + loaded[i].scope_name, 0, sizeof(loaded[i].scope_name)) != NULL; + bool input_name_terminated = memchr( + loaded[i].incoming_scope_name, 0, + sizeof(loaded[i].incoming_scope_name)) != NULL; + bool channel_name_terminated = memchr( + loaded[i].channel_name, 0, + sizeof(loaded[i].channel_name)) != NULL; + bool target_name_terminated = memchr( + loaded[i].target_region_name, 0, + sizeof(loaded[i].target_region_name)) != NULL; + bool direct_target = scope_name_terminated + && loaded[i].scope_name[0] != 0; + bool region_target = target_name_terminated + && loaded[i].target_region_name[0] != 0; + + bool incoming_valid = loaded[i].incoming_scope_kind + <= FloodFilterPolicy::RULE_IN_REGION; + if (incoming_valid + && loaded[i].incoming_scope_kind == FloodFilterPolicy::RULE_IN_SCOPE) { + incoming_valid = input_name_terminated + && validStoredScopeName(loaded[i].incoming_scope_name); + } else if (incoming_valid + && loaded[i].incoming_scope_kind == FloodFilterPolicy::RULE_IN_REGION) { + incoming_valid = input_name_terminated + && validStoredRegionName(loaded[i].incoming_scope_name); + } else if (incoming_valid) { + incoming_valid = input_name_terminated + && loaded[i].incoming_scope_name[0] == 0; + } + + bool channel_valid = loaded[i].channel_key_len == 0 + || loaded[i].channel_key_len == CIPHER_KEY_SIZE + || loaded[i].channel_key_len == PUB_KEY_SIZE; + if (channel_valid && loaded[i].channel_key_len == 0) { + channel_valid = channel_name_terminated + && loaded[i].channel_name[0] == 0; + } else if (channel_valid) { + channel_valid = channel_name_terminated + && loaded[i].channel_name[0] != 0; + if (channel_valid) { + mesh::Utils::sha256(&loaded[i].channel_hash, + sizeof(loaded[i].channel_hash), + loaded[i].channel_secret, + loaded[i].channel_key_len); + } + } + if (channel_valid && loaded[i].channel_key_len != 0) { + channel_valid = loaded[i].payload_type == ANY_TYPE + || loaded[i].payload_type == PAYLOAD_TYPE_GRP_TXT + || loaded[i].payload_type == PAYLOAD_TYPE_GRP_DATA; + } + + bool path_valid = (loaded[i].path_hash_size == 0 + && loaded[i].path_hops == 0) + || (!loaded[i].match_blacklisted_path + && loaded[i].path_hash_size >= 1 + && loaded[i].path_hash_size <= 3 + && loaded[i].path_hops >= 1 + && loaded[i].path_hops <= PATH_PREFIX_HOPS_MAX); + bool action_valid = loaded[i].drop_on_match || direct_target + || region_target || loaded[i].rate_limit_enabled + || loaded[i].stop_on_match; + if (!((loaded[i].payload_type <= PH_TYPE_MASK + || loaded[i].payload_type == ANY_TYPE) + && loaded[i].min_hops <= loaded[i].max_hops + && loaded[i].max_hops <= MAX_HOPS + && scope_name_terminated + && (!direct_target + || validStoredScopeName(loaded[i].scope_name)) + && target_name_terminated + && (!region_target + || validStoredRegionName(loaded[i].target_region_name)) + && !(direct_target && region_target) + && !(loaded[i].drop_on_match + && (direct_target || region_target)) + && !(loaded[i].drop_on_match + && loaded[i].rate_limit_enabled) + && (!loaded[i].rate_limit_enabled + || loaded[i].rate_per_minute < RATE_UNLIMITED) + && (!loaded[i].scope_uses_slow_timing + || direct_target || region_target) + && incoming_valid && channel_valid && path_valid + && action_valid)) { + success = false; + } + } + file.close(); + + // Invalid or truncated persistence fails open. Corrupt bytes must never + // enable a forwarding block. + if (!success) memset(_entries, 0, sizeof(_entries)); +} + +bool FloodRuleEngine::save() { + if (_fs == NULL) return false; + File file = openWrite(_fs, RULE_FILE); + if (!file) return false; + auto writeExact = [&file](const void* src, size_t len) { + return file.write((const uint8_t*)src, len) == len; + }; + + const uint8_t magic[4] = {'F', 'P', 'F', '7'}; + uint8_t count = RULE_SLOTS; + bool success = writeExact(magic, sizeof(magic)) + && writeExact(&count, sizeof(count)); + for (int i = 0; success && i < RULE_SLOTS; i++) { + const Entry& entry = _entries[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.incoming_scope_kind == FloodFilterPolicy::RULE_IN_ALLOWED + ? 1 : 0; + uint8_t scope_uses_slow_timing = entry.scope_uses_slow_timing ? 1 : 0; + uint8_t drop_on_match = entry.drop_on_match ? 1 : 0; + uint8_t rate_limit_enabled = entry.rate_limit_enabled ? 1 : 0; + uint8_t stop_on_match = entry.stop_on_match ? 1 : 0; + success = writeExact(&active, sizeof(active)) + && writeExact(&entry.payload_type, sizeof(entry.payload_type)) + && writeExact(&entry.min_hops, sizeof(entry.min_hops)) + && writeExact(&entry.max_hops, sizeof(entry.max_hops)) + && writeExact(&suspend_on_temp_radio, + sizeof(suspend_on_temp_radio)) + && writeExact(entry.scope_name, sizeof(entry.scope_name)) + && writeExact(&match_blacklisted_path, + sizeof(match_blacklisted_path)) + && writeExact(&scope_requires_region_match, + sizeof(scope_requires_region_match)) + && writeExact(&scope_uses_slow_timing, + sizeof(scope_uses_slow_timing)) + && writeExact(&entry.incoming_scope_kind, + sizeof(entry.incoming_scope_kind)) + && writeExact(entry.incoming_scope_name, + sizeof(entry.incoming_scope_name)) + && writeExact(&entry.channel_key_len, sizeof(entry.channel_key_len)) + && writeExact(entry.channel_secret, sizeof(entry.channel_secret)) + && writeExact(entry.channel_name, sizeof(entry.channel_name)) + && writeExact(&entry.path_hash_size, sizeof(entry.path_hash_size)) + && writeExact(&entry.path_hops, sizeof(entry.path_hops)) + && writeExact(entry.path, sizeof(entry.path)) + && writeExact(&drop_on_match, sizeof(drop_on_match)) + && writeExact(&rate_limit_enabled, sizeof(rate_limit_enabled)) + && writeExact(&entry.rate_per_minute, + sizeof(entry.rate_per_minute)) + && writeExact(entry.target_region_name, + sizeof(entry.target_region_name)) + && writeExact(&entry.priority, sizeof(entry.priority)) + && writeExact(&stop_on_match, sizeof(stop_on_match)); + } + file.close(); + return success; +} + +bool FloodRuleEngine::fieldsMatch( + const Entry& entry, const mesh::Packet* packet, + bool temp_radio_active, bool incoming_is_scoped, + uint16_t incoming_transport_code, bool incoming_region_allowed, + const RegionEntry* incoming_region) const { + if (!entry.active || packet == NULL || !packet->isRouteFlood()) return false; + if (entry.suspend_on_temp_radio && temp_radio_active) return false; + // FULL room servers do not carry the repeater's separate passive blacklist. + // Legacy blacklist-qualified rows stay inert instead of widening their match. + if (entry.match_blacklisted_path) return false; + if (!FloodFilterPolicy::pathStartsWith( + packet, entry.path_hash_size, entry.path_hops, entry.path)) { + return false; + } + + uint8_t type = packet->getPayloadType(); + uint8_t hops = packet->getPathHashCount(); + if ((entry.payload_type != ANY_TYPE && entry.payload_type != type) + || hops < entry.min_hops || hops > entry.max_hops) { + return false; + } + + if (entry.incoming_scope_kind == FloodFilterPolicy::RULE_IN_REGION) { + if (incoming_region == NULL + || !RegionNameUtils::equivalent( + entry.incoming_scope_name, incoming_region->name)) { + return false; + } + } else { + uint16_t wanted_transport_code = 0; + if (entry.incoming_scope_kind == FloodFilterPolicy::RULE_IN_SCOPE) { + TransportKey incoming_scope; + deriveScopeKey(entry.incoming_scope_name, incoming_scope); + wanted_transport_code = incoming_scope.calcTransportCode(packet); + } + if (!FloodFilterPolicy::ruleIncomingScopeMatches( + entry.incoming_scope_kind, incoming_is_scoped, + incoming_transport_code, incoming_region_allowed, + wanted_transport_code)) { + return false; + } + } + + if (entry.channel_key_len != 0) { + if ((type != PAYLOAD_TYPE_GRP_TXT && type != PAYLOAD_TYPE_GRP_DATA) + || packet->payload_len + < PATH_HASH_SIZE + CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE + || ((packet->payload_len - PATH_HASH_SIZE - CIPHER_MAC_SIZE) + % CIPHER_BLOCK_SIZE) != 0 + || packet->payload[0] != entry.channel_hash) { + return false; + } + } + return true; +} + +bool FloodRuleEngine::authenticateChannel( + const Entry& entry, const mesh::Packet* packet) const { + if (entry.channel_key_len == 0) return true; + uint8_t data[MAX_PACKET_PAYLOAD]; + return mesh::Utils::MACThenDecrypt( + entry.channel_secret, data, &packet->payload[PATH_HASH_SIZE], + packet->payload_len - PATH_HASH_SIZE) > 0; +} + +int FloodRuleEngine::nextMatch(uint32_t match_mask, + uint32_t visited_mask) const { + uint8_t priorities[RULE_SLOTS]; + for (int i = 0; i < RULE_SLOTS; i++) { + priorities[i] = _entries[i].priority; + } + return FloodFilterPolicy::nextOrderedRule( + match_mask, visited_mask, priorities, RULE_SLOTS); +} + +uint32_t FloodRuleEngine::applyStop(uint32_t match_mask) const { + uint8_t priorities[RULE_SLOTS]; + uint8_t stop_flags[RULE_SLOTS]; + for (int i = 0; i < RULE_SLOTS; i++) { + priorities[i] = _entries[i].priority; + stop_flags[i] = _entries[i].stop_on_match ? 1 : 0; + } + return FloodFilterPolicy::truncateRulesAtStop( + match_mask, priorities, stop_flags, RULE_SLOTS); +} + +uint32_t FloodRuleEngine::evaluate( + const mesh::Packet* packet, bool temp_radio_active, + bool incoming_region_allowed, + const RegionEntry* incoming_region) const { + static_assert(RULE_SLOTS <= 32, + "flood rule match mask supports at most 32 slots"); + if (packet == NULL || !packet->isRouteFlood()) return 0; + bool incoming_is_scoped = + packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD; + uint16_t incoming_transport_code = incoming_is_scoped + ? packet->transport_codes[0] : 0; + bool channel_auth_checked[RULE_SLOTS] = { false }; + bool channel_auth_valid[RULE_SLOTS] = { false }; + uint32_t result = 0; + for (int i = 0; i < RULE_SLOTS; i++) { + const Entry& entry = _entries[i]; + if (!fieldsMatch(entry, packet, temp_radio_active, incoming_is_scoped, + incoming_transport_code, incoming_region_allowed, + incoming_region)) { + continue; + } + bool authenticated = true; + if (entry.channel_key_len != 0) { + int cached = -1; + for (int j = 0; j < i; j++) { + if (channel_auth_checked[j] + && FloodFilterPolicy::sameChannelKey( + _entries[j].channel_key_len, _entries[j].channel_secret, + entry.channel_key_len, entry.channel_secret)) { + cached = j; + break; + } + } + authenticated = cached >= 0 + ? channel_auth_valid[cached] + : authenticateChannel(entry, packet); + channel_auth_checked[i] = true; + channel_auth_valid[i] = authenticated; + } + if (authenticated) result |= (uint32_t)1U << i; + } + return applyStop(result); +} + +bool FloodRuleEngine::applyScope(mesh::Packet* packet, uint32_t match_mask, + bool& scope_set, bool& fast_track, + bool log_change) { + scope_set = false; + fast_track = false; + if (packet == NULL || !packet->isRouteFlood()) return false; + + uint32_t visited = 0; + while (true) { + int i = nextMatch(match_mask, visited); + if (i < 0) break; + visited |= (uint32_t)1U << i; + const Entry& entry = _entries[i]; + if (entry.scope_name[0] == 0 + && entry.target_region_name[0] == 0) { + continue; + } + + TransportKey scope; + const char* target_name = entry.scope_name; + if (entry.scope_name[0] != 0) { + deriveScopeKey(entry.scope_name, scope); + } else { + RegionEntry* region = _regions == NULL ? NULL + : _regions->findByName(entry.target_region_name); + if (region == NULL || region->isWildcard() + || (region->flags & REGION_DENY_FLOOD) != 0 + || _regions->getTransportKeysFor(*region, &scope, 1) <= 0 + || scope.isNull()) { + continue; + } + target_name = region->name; + } + + bool changed = FloodFilterPolicy::setTransportScope( + packet, scope.calcTransportCode(packet)); + scope_set = true; + fast_track = FloodFilterPolicy::fastTrackScopeChange( + changed, entry.scope_uses_slow_timing); + if (changed && log_change) { + MESH_DEBUG_PRINTLN( + "flood.rule set scope slot=%d type=%d hops=%d scope=%s tx=%s", + i + 1, packet->getPayloadType(), packet->getPathHashCount(), + target_name, entry.scope_uses_slow_timing ? "slow" : "fast"); + } + return changed; + } + return false; +} + +bool FloodRuleEngine::shouldBlock(const mesh::Packet* packet, + uint32_t match_mask, + uint32_t now_millis) const { + if (packet == NULL || !packet->isRouteFlood()) return false; + uint8_t type = packet->getPayloadType(); + uint8_t hops = packet->getPathHashCount(); + + uint32_t visited = 0; + while (true) { + int i = nextMatch(match_mask, visited); + if (i < 0) break; + visited |= (uint32_t)1U << i; + const Entry& entry = _entries[i]; + bool blocked = entry.drop_on_match; + if (entry.rate_limit_enabled + && FloodFilterPolicy::rateLimitReached( + entry.rate_window_active, now_millis, + entry.rate_window_started, entry.rate_window_count, + entry.rate_per_minute)) { + blocked = true; + } + if (blocked) { + MESH_DEBUG_PRINTLN( + "allowPacketForward: flood.rule matched slot=%d type=%d hops=%d", + i + 1, type, hops); + return true; + } + } + return false; +} + +void FloodRuleEngine::commitRates(const mesh::Packet* packet, + uint32_t match_mask, + uint32_t now_millis) { + if (packet == NULL || !packet->isRouteFlood()) return; + for (int i = 0; i < RULE_SLOTS; i++) { + Entry& entry = _entries[i]; + if ((match_mask & ((uint32_t)1U << i)) == 0 + || !entry.rate_limit_enabled) { + continue; + } + if (!entry.rate_window_active + || now_millis - entry.rate_window_started >= 60000UL) { + entry.rate_window_active = true; + entry.rate_window_started = now_millis; + entry.rate_window_count = 0; + } + if (entry.rate_window_count < 0xFFFF) entry.rate_window_count++; + } +} + +void FloodRuleEngine::formatDetail(int index, char* reply, + size_t reply_len) const { + if (index < 0 || index >= RULE_SLOTS || !_entries[index].active) { + snprintf(reply, reply_len, "Err - empty filter slot"); + return; + } + const Entry& entry = _entries[index]; + char hops[12]; + char prefix[32]; + char incoming[48]; + char action[72]; + char rate[24]; + formatHopSpec(hops, sizeof(hops), entry.min_hops, entry.max_hops); + if (entry.match_blacklisted_path) copyString(prefix, "blacklist", sizeof(prefix)); + else formatPathPrefix(prefix, sizeof(prefix), entry.path_hash_size, + entry.path_hops, entry.path); + + switch (entry.incoming_scope_kind) { + case FloodFilterPolicy::RULE_IN_NONE: + copyString(incoming, "none", sizeof(incoming)); + break; + case FloodFilterPolicy::RULE_IN_SCOPED: + copyString(incoming, "scoped", sizeof(incoming)); + break; + case FloodFilterPolicy::RULE_IN_ALLOWED: + copyString(incoming, "allowed", sizeof(incoming)); + break; + case FloodFilterPolicy::RULE_IN_UNKNOWN: + copyString(incoming, "unknown", sizeof(incoming)); + break; + case FloodFilterPolicy::RULE_IN_SCOPE: + snprintf(incoming, sizeof(incoming), "scope:%s", + entry.incoming_scope_name); + break; + case FloodFilterPolicy::RULE_IN_REGION: + snprintf(incoming, sizeof(incoming), "region:%s%s", + entry.incoming_scope_name, + _regions != NULL + && _regions->findByName(entry.incoming_scope_name) != NULL + ? "" : "?"); + break; + default: + copyString(incoming, "any", sizeof(incoming)); + break; + } + + action[0] = 0; + if (entry.drop_on_match) { + copyString(action, "drop", sizeof(action)); + } else if (entry.scope_name[0] != 0) { + snprintf(action, sizeof(action), "scope=%s", entry.scope_name); + } else if (entry.target_region_name[0] != 0) { + RegionEntry* current = _regions == NULL ? NULL + : _regions->findByName(entry.target_region_name); + snprintf(action, sizeof(action), "region=%s%s", + entry.target_region_name, current == NULL ? "?" : ""); + } + rate[0] = 0; + if (entry.rate_limit_enabled) { + snprintf(rate, sizeof(rate), "%srate=%u/min", + action[0] == 0 ? "" : " ", + (unsigned int)entry.rate_per_minute); + } + + int written = snprintf( + reply, reply_len, + "> %d type=%s hops=%s channel=%s prefix=%s in=%s %s%s priority=%u%s%s%s", + index + 1, payloadTypeName(entry.payload_type), hops, + entry.channel_key_len == 0 ? "*" : entry.channel_name, + prefix, incoming, action, rate, + (unsigned int)entry.priority, + entry.stop_on_match ? " stop" : "", + entry.scope_uses_slow_timing ? " tx=slow" : "", + entry.suspend_on_temp_radio ? " suspend=tempradio" : ""); + if (written >= 0 && (size_t)written < reply_len) return; + + char compact_type[4]; + if (entry.payload_type == ANY_TYPE) copyString(compact_type, "any", sizeof(compact_type)); + else snprintf(compact_type, sizeof(compact_type), "%u", + (unsigned int)entry.payload_type); + char compact_incoming[44]; + switch (entry.incoming_scope_kind) { + case FloodFilterPolicy::RULE_IN_NONE: + copyString(compact_incoming, "n", sizeof(compact_incoming)); + break; + case FloodFilterPolicy::RULE_IN_SCOPED: + copyString(compact_incoming, "s", sizeof(compact_incoming)); + break; + case FloodFilterPolicy::RULE_IN_ALLOWED: + copyString(compact_incoming, "a", sizeof(compact_incoming)); + break; + case FloodFilterPolicy::RULE_IN_UNKNOWN: + copyString(compact_incoming, "u", sizeof(compact_incoming)); + break; + case FloodFilterPolicy::RULE_IN_SCOPE: + snprintf(compact_incoming, sizeof(compact_incoming), "s:%s", + entry.incoming_scope_name); + break; + case FloodFilterPolicy::RULE_IN_REGION: + snprintf(compact_incoming, sizeof(compact_incoming), "r:%s", + entry.incoming_scope_name); + break; + default: + copyString(compact_incoming, "*", sizeof(compact_incoming)); + break; + } + char compact_action[44]; + if (entry.drop_on_match) { + copyString(compact_action, " drop", sizeof(compact_action)); + } else if (entry.scope_name[0] != 0) { + snprintf(compact_action, sizeof(compact_action), " scope=%s", + entry.scope_name); + } else if (entry.target_region_name[0] != 0) { + snprintf(compact_action, sizeof(compact_action), " region=%s", + entry.target_region_name); + } else { + compact_action[0] = 0; + } + char compact_rate[12]; + compact_rate[0] = 0; + if (entry.rate_limit_enabled) { + snprintf(compact_rate, sizeof(compact_rate), " q=%u", + (unsigned int)entry.rate_per_minute); + } + char compact_flags[8]; + compact_flags[0] = 0; + if (entry.scope_uses_slow_timing || entry.suspend_on_temp_radio) { + snprintf(compact_flags, sizeof(compact_flags), " f=%s%s", + entry.scope_uses_slow_timing ? "s" : "", + entry.suspend_on_temp_radio ? "t" : ""); + } + snprintf(reply, reply_len, + ">%d %s %s c=%s p=%s i=%s%s%s pri=%u%s%s", + index + 1, compact_type, hops, + entry.channel_key_len == 0 ? "*" : entry.channel_name, + prefix, compact_incoming, compact_action, compact_rate, + (unsigned int)entry.priority, + entry.stop_on_match ? " stop" : "", + compact_flags); +} + +void FloodRuleEngine::format(const char* args, char* reply) const { + const char* selector = skipSpaces(args); + if (*selector == '.') selector = skipSpaces(selector + 1); + if (*selector != 0) { + uint32_t slot = 0; + if (!parseUnsigned(selector, RULE_SLOTS, slot) || slot == 0) { + snprintf(reply, 160, "Err - filter slot must be 1-%u", + (unsigned int)RULE_SLOTS); + return; + } + formatDetail((int)slot - 1, reply, 160); + return; + } + + size_t used = (size_t)snprintf(reply, 160, ">"); + int active_count = 0; + bool truncated = false; + for (int i = 0; i < RULE_SLOTS; i++) { + const Entry& entry = _entries[i]; + if (!entry.active) continue; + active_count++; + char hops[12]; + char item[120]; + char target[48]; + char priority[8]; + formatHopSpec(hops, sizeof(hops), entry.min_hops, entry.max_hops); + if (entry.drop_on_match) { + copyString(target, "!drop", sizeof(target)); + } else if (entry.scope_name[0] != 0) { + snprintf(target, sizeof(target), ">%s", entry.scope_name); + } else if (entry.target_region_name[0] != 0) { + snprintf(target, sizeof(target), ">r:%s", + entry.target_region_name); + } else { + target[0] = 0; + } + priority[0] = 0; + if (entry.priority != 0) { + snprintf(priority, sizeof(priority), "^%u", + (unsigned int)entry.priority); + } + snprintf(item, sizeof(item), " %d=%s@%s%s%s%s%s%s%s%s", + i + 1, payloadTypeName(entry.payload_type), hops, + entry.match_blacklisted_path ? "?blacklist" : "", target, + priority, entry.stop_on_match ? "~stop" : "", + entry.rate_limit_enabled ? "~rate" : "", + 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; + break; + } + memcpy(&reply[used], item, item_len + 1); + used += item_len; + } + if (active_count == 0) copyString(reply, "> off", 160); + else if (truncated) copyString(&reply[used], " ...", 160 - used); +} + +void FloodRuleEngine::set(const char* args, char* reply, + bool require_explicit_action) { + const char* cursor = skipSpaces(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 - filter slot must be 1-%u", + (unsigned int)RULE_SLOTS); + return; + } + memcpy(slot_text, slot_start, slot_len); + slot_text[slot_len] = 0; + uint32_t slot = 0; + if (!parseUnsigned(slot_text, RULE_SLOTS, slot) || slot == 0) { + snprintf(reply, 160, "Err - filter slot must be 1-%u", + (unsigned int)RULE_SLOTS); + return; + } + requested_slot = (int)slot - 1; + if (*cursor != ' ') { + copyString(reply, RULE_USAGE, 160); + return; + } + } + cursor = skipSpaces(cursor); + if (strlen(cursor) >= 192) { + copyString(reply, "Err - rule parameters too long", 160); + return; + } + + char params[192]; + copyString(params, cursor, sizeof(params)); + char* tokens[18]; + int token_count = 0; + char* token = params; + while (*token != 0) { + if (token_count >= 18) { + copyString(reply, "Err - too many rule parameters", 160); + return; + } + tokens[token_count++] = token; + char* separator = strchr(token, ' '); + if (separator == NULL) break; + *separator++ = 0; + while (*separator == ' ') separator++; + token = separator; + } + if (token_count == 0) { + copyString(reply, RULE_USAGE, 160); + return; + } + + uint8_t payload_type = 0; + const char* type_text = asciiStartsWith(tokens[0], "type=") + ? tokens[0] + strlen("type=") : tokens[0]; + if (!parsePayloadType(type_text, payload_type)) { + copyString(reply, + "Err - packet type must be name, any, 0-15, or 0x00-0x0F", + 160); + return; + } + + uint8_t min_hops = 0; + uint8_t max_hops = MAX_HOPS; + bool hops_set = false; + bool suspend_on_temp_radio = false; + bool path_set = false; + uint8_t path_hash_size = 0; + uint8_t path_hops = 0; + uint8_t path[PATH_PREFIX_BYTES_MAX]; + memset(path, 0, sizeof(path)); + uint8_t incoming_scope_kind = FloodFilterPolicy::RULE_IN_ANY; + char incoming_scope_name[NAME_LEN]; + memset(incoming_scope_name, 0, sizeof(incoming_scope_name)); + bool incoming_set = false; + uint8_t channel_key_len = 0; + uint8_t channel_hash = 0; + uint8_t channel_secret[PUB_KEY_SIZE]; + memset(channel_secret, 0, sizeof(channel_secret)); + char channel_name[NAME_LEN]; + memset(channel_name, 0, sizeof(channel_name)); + bool channel_set = false; + bool scope_timing_set = false; + bool scope_uses_slow_timing = false; + char scope_name[NAME_LEN]; + memset(scope_name, 0, sizeof(scope_name)); + char target_region_name[NAME_LEN]; + memset(target_region_name, 0, sizeof(target_region_name)); + bool target_set = false; + bool drop_on_match = false; + bool drop_set = false; + bool rate_limit_enabled = false; + uint16_t rate_per_minute = 0; + uint8_t priority = 0; + bool priority_set = false; + bool stop_on_match = false; + + for (int i = 1; i < token_count; i++) { + if (asciiEqual(tokens[i], "suspend=tempradio")) { + if (suspend_on_temp_radio) { + copyString(reply, DUPLICATE_OPTION, 160); + return; + } + suspend_on_temp_radio = true; + } else if (asciiStartsWith(tokens[i], "f=")) { + const char* flags = tokens[i] + 2; + if (*flags == 0) { + copyString(reply, "Err - compact flags are s and/or t", 160); + return; + } + while (*flags != 0) { + if (*flags == 's' || *flags == 'S') { + if (scope_timing_set) { + copyString(reply, DUPLICATE_OPTION, 160); + return; + } + scope_timing_set = true; + scope_uses_slow_timing = true; + } else if (*flags == 't' || *flags == 'T') { + if (suspend_on_temp_radio) { + copyString(reply, DUPLICATE_OPTION, 160); + return; + } + suspend_on_temp_radio = true; + } else { + copyString(reply, "Err - compact flags are s and/or t", 160); + return; + } + flags++; + } + } else if (asciiEqual(tokens[i], "path=blacklist")) { + copyString(reply, + "Err - path=blacklist is repeater-only; use prefix= on rooms", + 160); + return; + } else if (asciiEqual(tokens[i], "require=region")) { + if (incoming_set) { + copyString(reply, DUPLICATE_OPTION, 160); + return; + } + incoming_set = true; + incoming_scope_kind = FloodFilterPolicy::RULE_IN_ALLOWED; + } else if (asciiStartsWith(tokens[i], "in=") + || asciiStartsWith(tokens[i], "i=")) { + if (incoming_set) { + copyString(reply, DUPLICATE_OPTION, 160); + return; + } + incoming_set = true; + bool compact = asciiStartsWith(tokens[i], "i="); + const char* value = tokens[i] + (compact ? 2 : strlen("in=")); + if (asciiEqual(value, "any") || (compact && asciiEqual(value, "*"))) { + incoming_scope_kind = FloodFilterPolicy::RULE_IN_ANY; + } else if (asciiEqual(value, "none") || asciiEqual(value, "unscoped") + || (compact && asciiEqual(value, "n"))) { + incoming_scope_kind = FloodFilterPolicy::RULE_IN_NONE; + } else if (asciiEqual(value, "scoped") + || (compact && asciiEqual(value, "s"))) { + incoming_scope_kind = FloodFilterPolicy::RULE_IN_SCOPED; + } else if (asciiEqual(value, "allowed") || asciiEqual(value, "known") + || (compact && asciiEqual(value, "a"))) { + incoming_scope_kind = FloodFilterPolicy::RULE_IN_ALLOWED; + } else if (asciiEqual(value, "unknown") + || (compact && asciiEqual(value, "u"))) { + incoming_scope_kind = FloodFilterPolicy::RULE_IN_UNKNOWN; + } else if (asciiStartsWith(value, "scope:") + || (compact && asciiStartsWith(value, "s:"))) { + incoming_scope_kind = FloodFilterPolicy::RULE_IN_SCOPE; + const char* name = value + (asciiStartsWith(value, "scope:") + ? strlen("scope:") : 2); + if (!normalizeScopeName(name, incoming_scope_name, + sizeof(incoming_scope_name))) { + copyString(reply, "Err - bad incoming scope name", 160); + return; + } + } else if (asciiStartsWith(value, "region:") + || (compact && asciiStartsWith(value, "r:"))) { + const char* name = value + (asciiStartsWith(value, "region:") + ? strlen("region:") : 2); + RegionEntry* region = _regions == NULL ? NULL + : _regions->findByNamePrefix(name); + if (region == NULL || region->isWildcard()) { + copyString(reply, "Err - bad incoming region", 160); + return; + } + incoming_scope_kind = FloodFilterPolicy::RULE_IN_REGION; + copyString(incoming_scope_name, region->name, + sizeof(incoming_scope_name)); + } else { + copyString(reply, + "Err - in=any|none|scoped|allowed|unknown|scope:name|region:name", + 160); + return; + } + } else if (asciiStartsWith(tokens[i], "priority=") + || asciiStartsWith(tokens[i], "pri=")) { + if (priority_set) { + copyString(reply, DUPLICATE_OPTION, 160); + return; + } + const char* value = strchr(tokens[i], '=') + 1; + uint32_t parsed = 0; + if (!parseUnsigned(value, 255, parsed)) { + copyString(reply, "Err - priority must be 0-255", 160); + return; + } + priority = (uint8_t)parsed; + priority_set = true; + } else if (asciiEqual(tokens[i], "stop") + || asciiEqual(tokens[i], "action=stop")) { + if (stop_on_match) { + copyString(reply, DUPLICATE_OPTION, 160); + return; + } + stop_on_match = true; + } else if (asciiStartsWith(tokens[i], "tx=")) { + if (scope_timing_set) { + copyString(reply, DUPLICATE_OPTION, 160); + return; + } + if (asciiEqual(tokens[i], "tx=slow")) { + scope_uses_slow_timing = true; + } else if (!asciiEqual(tokens[i], "tx=fast")) { + copyString(reply, "Err - tx timing must be fast or slow", 160); + return; + } + scope_timing_set = true; + } else if (asciiStartsWith(tokens[i], "scope=")) { + if (target_set || drop_set) { + copyString(reply, DUPLICATE_OPTION, 160); + return; + } + if (!normalizeScopeName(tokens[i] + strlen("scope="), scope_name, + sizeof(scope_name))) { + copyString(reply, + "Err - scope must be a public name of at most 30 characters", + 160); + return; + } + target_set = true; + } else if (asciiStartsWith(tokens[i], "region=")) { + if (target_set || drop_set) { + copyString(reply, DUPLICATE_OPTION, 160); + return; + } + RegionEntry* region = _regions == NULL ? NULL + : _regions->findByNamePrefix(tokens[i] + strlen("region=")); + TransportKey target_scope; + if (region == NULL || region->isWildcard() + || (region->flags & REGION_DENY_FLOOD) != 0 + || _regions->getTransportKeysFor(*region, &target_scope, 1) <= 0 + || target_scope.isNull()) { + copyString(reply, "Err - bad target region", 160); + return; + } + copyString(target_region_name, region->name, + sizeof(target_region_name)); + target_set = true; + } else if (asciiEqual(tokens[i], "drop") + || asciiEqual(tokens[i], "action=drop")) { + if (drop_set || target_set) { + copyString(reply, DUPLICATE_OPTION, 160); + return; + } + drop_on_match = true; + drop_set = true; + } else if (asciiStartsWith(tokens[i], "rate=") + || asciiStartsWith(tokens[i], "q=")) { + if (rate_limit_enabled) { + copyString(reply, DUPLICATE_OPTION, 160); + return; + } + bool compact = asciiStartsWith(tokens[i], "q="); + char rate_text[24]; + copyString(rate_text, + tokens[i] + (compact ? 2 : strlen("rate=")), + sizeof(rate_text)); + char* slash = strchr(rate_text, '/'); + if ((!compact && slash == NULL) + || (slash != NULL && !(strcmp(slash, "/min") == 0 + || strcmp(slash, "/m") == 0))) { + copyString(reply, "Err - rate format is X/min", 160); + return; + } + if (slash != NULL) *slash = 0; + uint32_t parsed = 0; + if (!parseUnsigned(rate_text, RATE_UNLIMITED - 1, parsed)) { + copyString(reply, "Err - rate must be 0-65534/min", 160); + return; + } + rate_per_minute = (uint16_t)parsed; + rate_limit_enabled = true; + } else if (asciiStartsWith(tokens[i], "channel=") + || asciiStartsWith(tokens[i], "c=")) { + if (channel_set) { + copyString(reply, DUPLICATE_OPTION, 160); + return; + } + channel_set = true; + const char* value = tokens[i] + (asciiStartsWith(tokens[i], "c=") + ? 2 : strlen("channel=")); + if (!asciiEqual(value, "*")) { + if (!parseChannel(value, channel_secret, channel_key_len, + channel_hash, channel_name, + sizeof(channel_name))) { + copyString(reply, + "Err - channel must be *, public, #channel, or a key", + 160); + return; + } + } + } else if (asciiStartsWith(tokens[i], "prefix=") + || asciiStartsWith(tokens[i], "p=") + || asciiStartsWith(tokens[i], "path=")) { + if (path_set) { + copyString(reply, DUPLICATE_OPTION, 160); + return; + } + const char* value = strchr(tokens[i], '=') + 1; + if (!parsePathPrefix(value, path_hash_size, path_hops, path)) { + copyString(reply, + "Err - prefix is * or 1-3 comma-separated 1/2/3-byte IDs", + 160); + return; + } + path_set = true; + } else if (asciiStartsWith(tokens[i], "hops=")) { + if (hops_set || !parseHopSpec(tokens[i] + strlen("hops="), + min_hops, max_hops)) { + copyString(reply, + "Err - hops must be all, N, N+, or N-M (0-63)", 160); + return; + } + hops_set = true; + } else if (!hops_set + && parseHopSpec(tokens[i], min_hops, max_hops)) { + hops_set = true; + } else { + copyString(reply, RULE_USAGE, 160); + return; + } + } + + if (scope_timing_set && !target_set) { + copyString(reply, "Err - tx timing requires scope= or region=", 160); + return; + } + if (drop_on_match && rate_limit_enabled) { + copyString(reply, "Err - drop cannot be combined with rate", 160); + return; + } + if (channel_key_len != 0 && payload_type != ANY_TYPE + && payload_type != PAYLOAD_TYPE_GRP_TXT + && payload_type != PAYLOAD_TYPE_GRP_DATA) { + copyString(reply, + "Err - channel matcher requires type=any|grp_txt|grp_data", + 160); + return; + } + bool action_set = drop_set || target_set || rate_limit_enabled + || stop_on_match; + if (require_explicit_action && !action_set) { + copyString(reply, + "Err - flood.rule requires drop, scope=, region=, rate=, or stop", + 160); + return; + } + if (!action_set) drop_on_match = true; + + Entry candidate; + memset(&candidate, 0, sizeof(candidate)); + candidate.active = true; + candidate.payload_type = payload_type; + candidate.min_hops = min_hops; + candidate.max_hops = max_hops; + candidate.suspend_on_temp_radio = suspend_on_temp_radio; + candidate.scope_uses_slow_timing = scope_uses_slow_timing; + candidate.incoming_scope_kind = incoming_scope_kind; + copyString(candidate.incoming_scope_name, incoming_scope_name, + sizeof(candidate.incoming_scope_name)); + candidate.channel_key_len = channel_key_len; + candidate.channel_hash = channel_hash; + memcpy(candidate.channel_secret, channel_secret, + sizeof(candidate.channel_secret)); + copyString(candidate.channel_name, channel_name, + sizeof(candidate.channel_name)); + candidate.path_hash_size = path_hash_size; + candidate.path_hops = path_hops; + memcpy(candidate.path, path, sizeof(candidate.path)); + copyString(candidate.scope_name, scope_name, sizeof(candidate.scope_name)); + copyString(candidate.target_region_name, target_region_name, + sizeof(candidate.target_region_name)); + candidate.drop_on_match = drop_on_match; + candidate.rate_limit_enabled = rate_limit_enabled; + candidate.rate_per_minute = rate_per_minute; + candidate.priority = priority; + candidate.stop_on_match = stop_on_match; + + int slot = requested_slot; + if (slot < 0) { + for (int i = 0; i < RULE_SLOTS; i++) { + if (_entries[i].active + && memcmp(&_entries[i], &candidate, + offsetof(Entry, rate_window_started)) == 0) { + slot = i; + break; + } + } + } + if (slot < 0) { + for (int i = 0; i < RULE_SLOTS; i++) { + if (!_entries[i].active) { + slot = i; + break; + } + } + } + if (slot < 0) { + copyString(reply, "Err - filter table full", 160); + return; + } + + Entry previous = _entries[slot]; + _entries[slot] = candidate; + if (!save()) { + _entries[slot] = previous; + copyString(reply, "Err - unable to save flood filter", 160); + return; + } + char detail[160]; + formatDetail(slot, detail, sizeof(detail)); + const char* text = detail[0] == '>' ? skipSpaces(detail + 1) : detail; + snprintf(reply, 160, "OK - %s", text); +} + +void FloodRuleEngine::remove(const char* args, char* reply) { + const char* selector = skipSpaces(args); + if (*selector == '.') selector = skipSpaces(selector + 1); + if (asciiEqual(selector, "all")) { + Entry previous[RULE_SLOTS]; + memcpy(previous, _entries, sizeof(previous)); + memset(_entries, 0, sizeof(_entries)); + if (!save()) { + memcpy(_entries, previous, sizeof(_entries)); + copyString(reply, "Err - unable to save flood filter", 160); + } else { + copyString(reply, "OK - all flood filters removed", 160); + } + return; + } + + uint32_t slot = 0; + if (!parseUnsigned(selector, RULE_SLOTS, slot) || slot == 0) { + snprintf(reply, 160, "Err - use: del flood.rule.<1-%u>|all", + (unsigned int)RULE_SLOTS); + return; + } + int index = (int)slot - 1; + if (!_entries[index].active) { + copyString(reply, "Err - empty filter slot", 160); + return; + } + Entry previous = _entries[index]; + memset(&_entries[index], 0, sizeof(_entries[index])); + if (!save()) { + _entries[index] = previous; + copyString(reply, "Err - unable to save flood filter", 160); + } else { + copyString(reply, "OK", 160); + } +} + +bool FloodRuleEngine::handleCommand(const char* command, char* reply) { + if (command == NULL || reply == NULL) return false; + if (commandMatches(command, "get flood.filter.blacklist") + || commandMatches(command, "set flood.filter.blacklist") + || commandMatches(command, "del flood.filter.blacklist")) { + copyString(reply, + "Err - flood.filter.blacklist is repeater-only; use prefix=", + 160); + return true; + } + if (commandMatches(command, "get flood.rule")) { + format(command + strlen("get flood.rule"), reply); + return true; + } + if (commandMatches(command, "set flood.rule")) { + set(command + strlen("set flood.rule"), reply, true); + return true; + } + if (commandMatches(command, "del flood.rule")) { + remove(command + strlen("del flood.rule"), reply); + return true; + } + if (commandMatches(command, "get flood.filter")) { + format(command + strlen("get flood.filter"), reply); + return true; + } + if (commandMatches(command, "set flood.filter")) { + set(command + strlen("set flood.filter"), reply, false); + return true; + } + if (commandMatches(command, "del flood.filter")) { + remove(command + strlen("del flood.filter"), reply); + return true; + } + return false; +} + +#endif diff --git a/examples/simple_room_server/FloodRuleEngine.h b/examples/simple_room_server/FloodRuleEngine.h new file mode 100644 index 00000000..1e901234 --- /dev/null +++ b/examples/simple_room_server/FloodRuleEngine.h @@ -0,0 +1,91 @@ +#pragma once + +#if defined(MESHCORE_ESP32_FULL_PROFILE) + +#include +#include + +#include +#include + +class FloodRuleEngine { +public: + static constexpr uint8_t RULE_SLOTS = 31; + static constexpr uint8_t ANY_TYPE = 0xFF; + static constexpr uint8_t MAX_HOPS = 63; + static constexpr uint8_t NAME_LEN = 32; + static constexpr uint8_t PATH_PREFIX_HOPS_MAX = 3; + static constexpr uint8_t PATH_PREFIX_BYTES_MAX = 9; + static constexpr uint16_t RATE_UNLIMITED = 0xFFFF; + + FloodRuleEngine(); + + void begin(FILESYSTEM* fs, RegionMap* regions); + + uint32_t evaluate(const mesh::Packet* packet, bool temp_radio_active, + bool incoming_region_allowed, + const RegionEntry* incoming_region) const; + bool applyScope(mesh::Packet* packet, uint32_t match_mask, + bool& scope_set, bool& fast_track, + bool log_change = true); + bool shouldBlock(const mesh::Packet* packet, uint32_t match_mask, + uint32_t now_millis) const; + void commitRates(const mesh::Packet* packet, uint32_t match_mask, + uint32_t now_millis); + + bool handleCommand(const char* command, char* reply); + +private: + struct Entry { + bool active; + uint8_t payload_type; + uint8_t min_hops; + uint8_t max_hops; + bool suspend_on_temp_radio; + char scope_name[NAME_LEN]; + bool match_blacklisted_path; + bool scope_uses_slow_timing; + uint8_t incoming_scope_kind; + char incoming_scope_name[NAME_LEN]; + uint8_t channel_key_len; + uint8_t channel_hash; + uint8_t channel_secret[PUB_KEY_SIZE]; + char channel_name[NAME_LEN]; + uint8_t path_hash_size; + uint8_t path_hops; + uint8_t path[PATH_PREFIX_BYTES_MAX]; + char target_region_name[NAME_LEN]; + bool drop_on_match; + bool rate_limit_enabled; + uint16_t rate_per_minute; + uint8_t priority; + bool stop_on_match; + uint32_t rate_window_started; + uint16_t rate_window_count; + bool rate_window_active; + }; + + FILESYSTEM* _fs; + RegionMap* _regions; + Entry _entries[RULE_SLOTS]; + + void seedDefaults(); + void load(); + bool save(); + bool fieldsMatch(const Entry& entry, const mesh::Packet* packet, + bool temp_radio_active, bool incoming_is_scoped, + uint16_t incoming_transport_code, + bool incoming_region_allowed, + const RegionEntry* incoming_region) const; + bool authenticateChannel(const Entry& entry, + const mesh::Packet* packet) const; + int nextMatch(uint32_t match_mask, uint32_t visited_mask) const; + uint32_t applyStop(uint32_t match_mask) const; + void format(const char* args, char* reply) const; + void formatDetail(int index, char* reply, size_t reply_len) const; + void set(const char* args, char* reply, + bool require_explicit_action = false); + void remove(const char* args, char* reply); +}; + +#endif diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 779b93f9..3e3cee64 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1,5 +1,8 @@ #include "MyMesh.h" #include +#if defined(MESHCORE_ESP32_FULL_PROFILE) +#include +#endif #include #include #include @@ -317,6 +320,69 @@ int MyMesh::calcRxDelay(float score, uint32_t air_time) const { return (int)((powf(_prefs.rx_delay_base, 0.85f - score) - 1.0f) * air_time); } +#if defined(MESHCORE_ESP32_FULL_PROFILE) +bool MyMesh::evaluateFloodRuleTiming(const mesh::Packet* packet, + bool& fast_track) { + fast_track = false; + if (packet == NULL || !packet->isRouteFlood()) return false; + + bool incoming_region_allowed = false; + RegionEntry* incoming_region = NULL; + if (packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { + incoming_region = region_map.findMatch(packet, REGION_DENY_FLOOD); + incoming_region_allowed = incoming_region != NULL; + } else if (packet->getRouteType() == ROUTE_TYPE_FLOOD) { + incoming_region_allowed = + (region_map.getWildcard().flags & REGION_DENY_FLOOD) == 0; + } + + mesh::Packet candidate = *packet; + uint32_t match_mask = flood_rules.evaluate( + packet, isTempRadioActive(), incoming_region_allowed, incoming_region); + bool scope_set = false; + return flood_rules.applyScope(&candidate, match_mask, scope_set, + fast_track, false); +} + +static const uint8_t ROOM_MAX_LOOP_MINIMAL[] = { 0, 4, 2, 1 }; +static const uint8_t ROOM_MAX_LOOP_MODERATE[] = { 0, 2, 1, 1 }; +static const uint8_t ROOM_MAX_LOOP_STRICT[] = { 0, 1, 1, 1 }; + +bool MyMesh::isLooped(const mesh::Packet* packet, + const uint8_t max_counters[]) const { + uint8_t hash_size = packet->getPathHashSize(); + uint8_t hash_count = packet->getPathHashCount(); + uint8_t occurrences = 0; + const uint8_t* path = packet->path; + while (hash_count > 0) { + if (self_id.isHashMatch(path, hash_size)) occurrences++; + hash_count--; + path += hash_size; + } + return occurrences >= max_counters[hash_size]; +} + +int MyMesh::calcRxDelayForPacket(const mesh::Packet* packet, float score, + uint32_t air_time) { + bool fast_track = false; + if (!evaluateFloodRuleTiming(packet, fast_track)) { + return calcRxDelay(score, air_time); + } + if (fast_track) return 0; + float slow_base = + FloodFilterPolicy::slowScopeRxDelayBase(_prefs.rx_delay_base); + return (int)((powf(slow_base, 0.85f - score) - 1.0f) * air_time); +} + +uint32_t MyMesh::getSlowFloodRuleRetransmitDelay( + 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); +} +#endif + const char *MyMesh::getLogDateTime() { static char tmp[32]; uint32_t now = getRTCClock()->getCurrentTime(); @@ -409,11 +475,62 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { if (packet->getPathHashCount() >= _prefs.flood_max) return false; if (packet->getRouteType() == ROUTE_TYPE_FLOOD && packet->getPathHashCount() >= _prefs.flood_max_unscoped) return false; if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && packet->getPathHashCount() >= _prefs.flood_max_advert) return false; +#if defined(MESHCORE_ESP32_FULL_PROFILE) + if (flood_rules.shouldBlock(packet, recv_pkt_rule_match_mask, + _ms->getMillis())) { + return false; + } +#endif } +#if defined(MESHCORE_ESP32_FULL_PROFILE) + if (packet->isRouteFlood()) { + if (recv_pkt_region == NULL && !recv_pkt_regionless_scope_set) { + MESH_DEBUG_PRINTLN( + "allowPacketForward: unknown transport code, or wildcard not allowed for FLOOD packet"); + return false; + } + if (_prefs.loop_detect != LOOP_DETECT_OFF) { + const uint8_t* maximums = _prefs.loop_detect == LOOP_DETECT_MINIMAL + ? ROOM_MAX_LOOP_MINIMAL + : (_prefs.loop_detect == LOOP_DETECT_MODERATE + ? ROOM_MAX_LOOP_MODERATE : ROOM_MAX_LOOP_STRICT); + if (isLooped(packet, maximums)) { + MESH_DEBUG_PRINTLN( + "allowPacketForward: FLOOD packet loop detected!"); + return false; + } + } + flood_rules.commitRates(packet, recv_pkt_rule_match_mask, + _ms->getMillis()); + } +#endif return true; } mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { +#if defined(MESHCORE_ESP32_FULL_PROFILE) + bool scope_changed = false; + bool fast_track_scope_change = false; + recv_pkt_regionless_scope_set = false; + recv_pkt_rule_match_mask = 0; + if (pkt->isRouteFlood()) { + bool incoming_region_allowed = false; + RegionEntry* incoming_region = NULL; + if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { + incoming_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); + incoming_region_allowed = incoming_region != NULL; + } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { + incoming_region_allowed = + (region_map.getWildcard().flags & REGION_DENY_FLOOD) == 0; + } + recv_pkt_rule_match_mask = flood_rules.evaluate( + pkt, isTempRadioActive(), incoming_region_allowed, incoming_region); + bool scope_set = false; + scope_changed = flood_rules.applyScope( + pkt, recv_pkt_rule_match_mask, scope_set, fast_track_scope_change); + recv_pkt_regionless_scope_set = scope_set; + } +#endif if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { @@ -425,7 +542,20 @@ mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { } else { recv_pkt_region = NULL; } - return Mesh::onRecvPacket(pkt); + mesh::DispatcherAction action = Mesh::onRecvPacket(pkt); +#if defined(MESHCORE_ESP32_FULL_PROFILE) + if (scope_changed && action != ACTION_RELEASE + && action != ACTION_MANUAL_HOLD) { + if (fast_track_scope_change) { + action = ACTION_RETRANSMIT(0); + } else { + uint8_t priority = (action >> 24) - 1; + action = ACTION_RETRANSMIT_DELAYED( + priority, getSlowFloodRuleRetransmitDelay(pkt)); + } + } +#endif + return action; } void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const mesh::Identity &sender, @@ -602,96 +732,118 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, if (!(flags == TXT_TYPE_PLAIN || flags == TXT_TYPE_CLI_DATA)) { MESH_DEBUG_PRINTLN("onPeerDataRecv: unsupported command flags received: flags=%02x", (uint32_t)flags); - } else if (sender_timestamp >= client->last_timestamp) { // prevent replay attacks, but send Acks for retries - bool is_retry = (sender_timestamp == client->last_timestamp); - client->last_timestamp = sender_timestamp; + return; + } - uint32_t now = getRTCClock()->getCurrentTimeUnique(); - client->last_activity = now; - client->extra.room.push_failures = 0; // reset so push can resume (if prev failed) + // len can be > original length, but 'text' will be padded with zeroes + data[len] = 0; // need to make a C string again, with null terminator + const char* text = (const char*)&data[5]; + const size_t text_len = strlen(text); - // len can be > original length, but 'text' will be padded with zeroes - data[len] = 0; // need to make a C string again, with null terminator + uint8_t temp[166]; + temp[5] = 0; + bool send_ack = false; - uint32_t ack_hash; // calc truncated hash of the message timestamp + text + sender pub_key, to prove to - // sender that we got it - mesh::Utils::sha256((uint8_t *)&ack_hash, 4, data, 5 + strlen((char *)&data[5]), client->id.pub_key, - PUB_KEY_SIZE); - - uint8_t temp[166]; - bool send_ack; - if (flags == TXT_TYPE_CLI_DATA) { - if (client->isAdmin()) { - if (is_retry) { - temp[5] = 0; // no reply - } else { - handleCommand(sender_timestamp, (char *)&data[5], (char *)&temp[5], - i, packet->getPathHashSize()); - temp[4] = (TXT_TYPE_CLI_DATA << 2); // attempt and flags, (NOTE: legacy was: TXT_TYPE_PLAIN) - } - send_ack = false; - } else { - temp[5] = 0; // no reply - send_ack = false; // and no ACK... user shoudn't be sending these - } - } else { // TXT_TYPE_PLAIN - if ((client->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) { - temp[5] = 0; // no reply - send_ack = false; // no ACK - } else { - if (!is_retry) { - addPost(client, (const char *)&data[5]); - } - temp[5] = 0; // no reply (ACK is enough) - send_ack = true; - } - } - - uint32_t delay_millis; - if (send_ack) { - if (client->out_path_len == OUT_PATH_UNKNOWN) { - mesh::Packet *ack = createAck(ack_hash); - if (ack) sendFloodReply(ack, TXT_ACK_DELAY, packet->getPathHashSize()); - delay_millis = TXT_ACK_DELAY + REPLY_DELAY_MILLIS; - } else { - uint32_t d = TXT_ACK_DELAY; - if (getExtraAckTransmitCount() > 0) { - mesh::Packet *a1 = createMultiAck(ack_hash, 1); - if (a1) sendDirect(a1, client->out_path, client->out_path_len, d); - d += 300; - } - - mesh::Packet *a2 = createAck(ack_hash); - if (a2) sendDirect(a2, client->out_path, client->out_path_len, d); - delay_millis = d + REPLY_DELAY_MILLIS; + if (flags == TXT_TYPE_PLAIN) { + const bool is_guest = + (client->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST; + if (is_guest) { + if (sender_timestamp < client->extra.room.last_post_timestamp) { + MESH_DEBUG_PRINTLN("onPeerDataRecv: stale room post detected"); + return; } + client->extra.room.last_post_timestamp = sender_timestamp; } else { - delay_millis = 0; + uint8_t message_fingerprint[MAX_HASH_SIZE]; + mesh::Utils::sha256(message_fingerprint, sizeof(message_fingerprint), + client->id.pub_key, PUB_KEY_SIZE, + (const uint8_t*)text, text_len); + const auto replay_decision = recent_room_posts.classifyAndRemember( + message_fingerprint, sender_timestamp, + client->extra.room.last_post_timestamp); + + using ReplayDecision = + mesh::LogicalMessageCache::ReplayDecision; + if (replay_decision == ReplayDecision::StaleOrMismatched) { + MESH_DEBUG_PRINTLN("onPeerDataRecv: stale or mismatched room post detected"); + return; + } + if (replay_decision == ReplayDecision::NewMessage) { + addPost(client, text); + } + // Exact retries are ACKed again regardless of whether newer posts have + // advanced this client's room-post replay timestamp. + send_ack = true; + } + } else { // TXT_TYPE_CLI_DATA + if (sender_timestamp < client->last_timestamp) { + MESH_DEBUG_PRINTLN("onPeerDataRecv: possible replay attack detected"); + return; } - int text_len = strlen((char *)&temp[5]); - if (text_len > 0) { - if (now == sender_timestamp) { - // WORKAROUND: the two timestamps need to be different, in the CLI view - now++; - } - memcpy(temp, &now, 4); // mostly an extra blob to help make packet_hash unique + const bool is_retry = sender_timestamp == client->last_timestamp; + client->last_timestamp = sender_timestamp; + if (client->isAdmin() && !is_retry) { + handleCommand(sender_timestamp, (char*)text, (char*)&temp[5], + i, packet->getPathHashSize()); + temp[4] = (TXT_TYPE_CLI_DATA << 2); // attempt and flags, (NOTE: legacy was: TXT_TYPE_PLAIN) + } + // CLI_DATA replies are the result signal; no separate ACK is expected. + } - // calc expected ACK reply - // mesh::Utils::sha256((uint8_t *)&expected_ack_crc, 4, temp, 5 + text_len, self_id.pub_key, - // PUB_KEY_SIZE); + uint32_t now = getRTCClock()->getCurrentTimeUnique(); + client->last_activity = now; + client->extra.room.push_failures = 0; // reset so push can resume (if prev failed) - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len); - if (reply) { - if (client->out_path_len == OUT_PATH_UNKNOWN) { - sendFloodReply(reply, delay_millis + SERVER_RESPONSE_DELAY, packet->getPathHashSize()); - } else { - sendDirect(reply, client->out_path, client->out_path_len, delay_millis + SERVER_RESPONSE_DELAY); - } + uint32_t delay_millis; + if (send_ack) { + uint32_t ack_hash; // prove receipt of this timestamp, attempt, text, and sender + mesh::Utils::sha256((uint8_t*)&ack_hash, 4, data, 5 + text_len, + client->id.pub_key, PUB_KEY_SIZE); + + if (client->out_path_len == OUT_PATH_UNKNOWN) { + mesh::Packet *ack = createAck(ack_hash); + if (ack) sendFloodReply(ack, TXT_ACK_DELAY, packet->getPathHashSize()); + delay_millis = TXT_ACK_DELAY + REPLY_DELAY_MILLIS; + } else { + uint32_t d = TXT_ACK_DELAY; + if (getExtraAckTransmitCount() > 0) { + mesh::Packet *a1 = createMultiAck(ack_hash, 1); + if (a1) sendDirect(a1, client->out_path, client->out_path_len, d); + d += 300; } + + mesh::Packet *a2 = createAck(ack_hash); + if (a2) sendDirect(a2, client->out_path, client->out_path_len, d); + delay_millis = d + REPLY_DELAY_MILLIS; } } else { - MESH_DEBUG_PRINTLN("onPeerDataRecv: possible replay attack detected"); + delay_millis = 0; + } + + int reply_text_len = strlen((char*)&temp[5]); + if (reply_text_len > 0) { + if (now == sender_timestamp) { + // WORKAROUND: the two timestamps need to be different, in the CLI view + now++; + } + memcpy(temp, &now, 4); // mostly an extra blob to help make packet_hash unique + + // calc expected ACK reply + // mesh::Utils::sha256((uint8_t *)&expected_ack_crc, 4, temp, 5 + reply_text_len, self_id.pub_key, + // PUB_KEY_SIZE); + + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, + temp, 5 + reply_text_len); + if (reply) { + if (client->out_path_len == OUT_PATH_UNKNOWN) { + sendFloodReply(reply, delay_millis + SERVER_RESPONSE_DELAY, + packet->getPathHashSize()); + } else { + sendDirect(reply, client->out_path, client->out_path_len, + delay_millis + SERVER_RESPONSE_DELAY); + } + } } } else if (type == PAYLOAD_TYPE_REQ && len >= 5) { uint32_t sender_timestamp; @@ -909,6 +1061,10 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc radio_apply_retry_at = 0; radio_apply_failures = 0; recv_pkt_region = NULL; +#if defined(MESHCORE_ESP32_FULL_PROFILE) + recv_pkt_rule_match_mask = 0; + recv_pkt_regionless_scope_set = false; +#endif // defaults memset(&_prefs, 0, sizeof(_prefs)); @@ -998,6 +1154,9 @@ void MyMesh::begin(FILESYSTEM *fs) { acl.load(_fs, self_id); region_map.load(_fs); +#if defined(MESHCORE_ESP32_FULL_PROFILE) + flood_rules.begin(_fs, ®ion_map); +#endif // establish default-scope { @@ -1818,7 +1977,13 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply addSystemPost(msg); snprintf(reply, MAX_POST_TEXT_LEN, "OK"); } - } else{ + } +#if defined(MESHCORE_ESP32_FULL_PROFILE) + else if (flood_rules.handleCommand(command, reply)) { + // handled by the FULL-profile persistent flood rule engine + } +#endif + else { _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands } } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index a02d5a17..7959b034 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -24,7 +24,9 @@ #endif #include #include +#include #include +#include "FloodRuleEngine.h" #include #include @@ -87,6 +89,10 @@ #define MAX_UNSYNCED_POSTS 32 #endif +#ifndef ROOM_MESSAGE_CACHE_SIZE + #define ROOM_MESSAGE_CACHE_SIZE 32 +#endif + #ifndef SERVER_RESPONSE_DELAY #define SERVER_RESPONSE_DELAY 300 #endif @@ -128,7 +134,13 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks NodePrefs _prefs; TransportKeyStore key_store; RegionMap region_map, temp_map; +#if defined(MESHCORE_ESP32_FULL_PROFILE) + FloodRuleEngine flood_rules; + uint32_t recv_pkt_rule_match_mask; + bool recv_pkt_regionless_scope_set; +#endif ClientACL acl; + mesh::LogicalMessageCache recent_room_posts; CommonCLI _cli; #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) UserGpioReplyTracker _gpio_reply_tracker; @@ -218,6 +230,14 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks mesh::Packet* createSelfAdvert(); File openAppend(const char* fname); int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); +#if defined(MESHCORE_ESP32_FULL_PROFILE) + bool evaluateFloodRuleTiming(const mesh::Packet* packet, + bool& fast_track); + bool isLooped(const mesh::Packet* packet, + const uint8_t max_counters[]) const; + uint32_t getSlowFloodRuleRetransmitDelay( + const mesh::Packet* packet); +#endif protected: bool isTempRadioActive() const override { @@ -233,6 +253,10 @@ protected: void logTxFail(mesh::Packet* pkt, int len) override; int calcRxDelay(float score, uint32_t air_time) const override; +#if defined(MESHCORE_ESP32_FULL_PROFILE) + int calcRxDelayForPacket(const mesh::Packet* packet, float score, + uint32_t air_time) override; +#endif const char* getLogDateTime() override; uint32_t getRetransmitDelay(const mesh::Packet* packet) override; uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index 5fda9928..34f214f8 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -28,6 +28,7 @@ struct ClientInfo { union { struct { uint32_t sync_since; // sync messages SINCE this timestamp (by OUR clock) + uint32_t last_post_timestamp; // sender timestamp for room posts only (transient) uint32_t pending_ack; uint32_t push_post_timestamp; unsigned long ack_timeout; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index a46906c6..806eb9da 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -497,92 +497,20 @@ static void formatFloodRetryPathGate(char* dest, uint8_t path_gate) { } } -static bool parseFloodChannelBlockHops(const char* value, uint8_t& max_hops) { +static bool parseFloodChannelHops(const char* value, uint8_t& max_hops) { if (value == NULL) { return false; } value = skipSpacesConst(value); if (strcmp(value, "all") == 0) { - max_hops = FLOOD_CHANNEL_BLOCK_HOPS_ALL; + max_hops = FLOOD_CHANNEL_HOPS_ALL; return true; } return parseUint8Strict(value, 1, 7, max_hops); } -static bool parseFloodChannelBlockRowHops(const char* value, uint8_t& max_hops) { - if (value == NULL) { - return false; - } - value = skipSpacesConst(value); - if (strcmp(value, "default") == 0 || strcmp(value, "def") == 0 || strcmp(value, "inherit") == 0) { - max_hops = FLOOD_CHANNEL_BLOCK_HOPS_INHERIT; - return true; - } - return parseFloodChannelBlockHops(value, max_hops); -} - -static bool parseFloodChannelBlockHopAssignment(const char* text, bool allow_bare, uint8_t& max_hops) { - char token[16]; - text = skipSpacesConst(text); - if (text == NULL || *text == 0) { - return false; - } - - size_t len = 0; - while (text[len] && text[len] != ' ' && len + 1 < sizeof(token)) { - token[len] = text[len]; - len++; - } - token[len] = 0; - - const char* value = NULL; - if (strncmp(token, "h=", 2) == 0) { - value = token + 2; - } else if (strncmp(token, "hops=", 5) == 0) { - value = token + 5; - } else if (allow_bare) { - value = token; - } else { - return false; - } - return parseFloodChannelBlockRowHops(value, max_hops); -} - -static bool looksFloodChannelBlockHopAssignment(const char* text) { - text = skipSpacesConst(text); - if (text == NULL || *text == 0) { - return false; - } - return (*text >= '0' && *text <= '9') - || strncmp(text, "h=", 2) == 0 - || strncmp(text, "hops=", 5) == 0 - || strncmp(text, "all", 3) == 0 - || strncmp(text, "def", 3) == 0 - || strncmp(text, "default", 7) == 0 - || strncmp(text, "inherit", 7) == 0; -} - -static bool trimFloodChannelBlockHopSuffix(char* name, uint8_t& max_hops) { - size_t len = strlen(name); - while (len > 0 && name[len - 1] == ' ') { - name[--len] = 0; - } - char* token = strrchr(name, ' '); - if (token == NULL) { - return true; - } - if (strncmp(token + 1, "h=", 2) != 0 && strncmp(token + 1, "hops=", 5) != 0) { - return true; - } - if (!parseFloodChannelBlockHopAssignment(token + 1, false, max_hops)) { - return false; - } - *token = 0; - return strlen(name) > 0; -} - -static void formatFloodChannelBlockHops(char* dest, uint8_t max_hops) { - if (max_hops == FLOOD_CHANNEL_BLOCK_HOPS_ALL) { +static void formatFloodChannelHops(char* dest, uint8_t max_hops) { + if (max_hops == FLOOD_CHANNEL_HOPS_ALL) { strcpy(dest, "h=all"); } else { sprintf(dest, "h>%u", (unsigned int)max_hops); @@ -699,69 +627,6 @@ static bool formatFloodRetryBucketCollisionWarning(char* reply, const NodePrefs* return true; } -static bool parseFloodChannelBlockKey(const char* text, uint8_t secret[PUB_KEY_SIZE], uint8_t& key_len) { - if (text == NULL || text[0] == 0) { - return false; - } - - memset(secret, 0, PUB_KEY_SIZE); - if (text[0] == '#') { - if (!isValidName(text)) { - return false; - } - mesh::Utils::sha256(secret, CIPHER_KEY_SIZE, (const uint8_t*)text, strlen(text)); - key_len = CIPHER_KEY_SIZE; - return true; - } - - size_t hex_len = strlen(text); - if (!(hex_len == CIPHER_KEY_SIZE * 2 || hex_len == PUB_KEY_SIZE * 2)) { - return false; - } - for (size_t i = 0; i < hex_len; i++) { - if (!mesh::Utils::isHexChar(text[i])) { - return false; - } - } - - key_len = (uint8_t)(hex_len / 2); - return mesh::Utils::fromHex(secret, key_len, text); -} - -static bool parseFloodChannelBlockDotIndex(const char*& cursor, int& index) { - if (*cursor != '.') { - index = 0; - return true; - } - - cursor++; - if (*cursor < '0' || *cursor > '9') { - return false; - } - int value = 0; - while (*cursor >= '0' && *cursor <= '9') { - value = (value * 10) + (*cursor - '0'); - if (value > FLOOD_CHANNEL_BLOCK_SLOTS) { - return false; - } - cursor++; - } - if (value < 1) { - return false; - } - index = value; - return true; -} - -static void copyTrimmedFloodChannelBlockName(char* dest, size_t dest_len, const char* src) { - src = skipSpacesConst(src); - StrHelper::strncpy(dest, src, dest_len); - size_t len = strlen(dest); - while (len > 0 && dest[len - 1] == ' ') { - dest[--len] = 0; - } -} - static void applyDirectRetryPreset(NodePrefs* prefs, uint8_t preset) { prefs->retry_preset = preset; if (preset == RETRY_PRESET_INFRA) { @@ -1208,8 +1073,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { memset(_prefs->battery_alert_region, 0, sizeof(_prefs->battery_alert_region)); _prefs->direct_retry_recent_enabled = DIRECT_RETRY_RECENT_DEFAULT; _prefs->flood_channel_data_enabled = 1; - _prefs->flood_channel_block_max_hops = FLOOD_CHANNEL_BLOCK_HOPS_ALL; - _prefs->flood_channel_data_max_hops = FLOOD_CHANNEL_BLOCK_HOPS_ALL; + _prefs->legacy_flood_channel_block_max_hops = FLOOD_CHANNEL_HOPS_ALL; + _prefs->flood_channel_data_max_hops = FLOOD_CHANNEL_HOPS_ALL; has_flood_retry_prefs = file.available() >= 2; if (has_flood_retry_prefs) { file.read((uint8_t *)&_prefs->flood_retry_attempts, sizeof(_prefs->flood_retry_attempts)); // 311 @@ -1244,8 +1109,9 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { if (file.available() >= (int)sizeof(_prefs->flood_channel_data_enabled)) { file.read((uint8_t *)&_prefs->flood_channel_data_enabled, sizeof(_prefs->flood_channel_data_enabled)); } - if (file.available() >= (int)sizeof(_prefs->flood_channel_block_max_hops)) { - file.read((uint8_t *)&_prefs->flood_channel_block_max_hops, sizeof(_prefs->flood_channel_block_max_hops)); + if (file.available() >= (int)sizeof(_prefs->legacy_flood_channel_block_max_hops)) { + file.read((uint8_t *)&_prefs->legacy_flood_channel_block_max_hops, + sizeof(_prefs->legacy_flood_channel_block_max_hops)); } if (file.available() >= (int)sizeof(_prefs->flood_channel_data_max_hops)) { file.read((uint8_t *)&_prefs->flood_channel_data_max_hops, sizeof(_prefs->flood_channel_data_max_hops)); @@ -1431,13 +1297,14 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->direct_retry_recent_enabled = constrain(_prefs->direct_retry_recent_enabled, 0, 1); _prefs->flood_channel_data_enabled = constrain(_prefs->flood_channel_data_enabled, 0, 1); _prefs->telemetry_access = constrain(_prefs->telemetry_access, 0, 1); - if (_prefs->flood_channel_block_max_hops != FLOOD_CHANNEL_BLOCK_HOPS_ALL - && (_prefs->flood_channel_block_max_hops < 1 || _prefs->flood_channel_block_max_hops > 7)) { - _prefs->flood_channel_block_max_hops = FLOOD_CHANNEL_BLOCK_HOPS_ALL; + if (_prefs->legacy_flood_channel_block_max_hops != FLOOD_CHANNEL_HOPS_ALL + && (_prefs->legacy_flood_channel_block_max_hops < 1 + || _prefs->legacy_flood_channel_block_max_hops > 7)) { + _prefs->legacy_flood_channel_block_max_hops = FLOOD_CHANNEL_HOPS_ALL; } - if (_prefs->flood_channel_data_max_hops != FLOOD_CHANNEL_BLOCK_HOPS_ALL + if (_prefs->flood_channel_data_max_hops != FLOOD_CHANNEL_HOPS_ALL && (_prefs->flood_channel_data_max_hops < 1 || _prefs->flood_channel_data_max_hops > 7)) { - _prefs->flood_channel_data_max_hops = FLOOD_CHANNEL_BLOCK_HOPS_ALL; + _prefs->flood_channel_data_max_hops = FLOOD_CHANNEL_HOPS_ALL; } if (_prefs->battery_alert_low_percent < 1 || _prefs->battery_alert_low_percent > 100 @@ -1564,7 +1431,7 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) { WRITE_COMMON_PREFS(&prefs->battery_alert_critical_percent); WRITE_COMMON_PREFS(&prefs->direct_retry_recent_enabled); WRITE_COMMON_PREFS(&prefs->flood_channel_data_enabled); - WRITE_COMMON_PREFS(&prefs->flood_channel_block_max_hops); + WRITE_COMMON_PREFS(&prefs->legacy_flood_channel_block_max_hops); WRITE_COMMON_PREFS(&prefs->flood_channel_data_max_hops); WRITE_COMMON_PREFS(&prefs->telemetry_access); // 674 #if defined(ENABLE_OTA) @@ -1708,7 +1575,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) { file.write((uint8_t *)&_prefs->battery_alert_critical_percent, sizeof(_prefs->battery_alert_critical_percent)); file.write((uint8_t *)&_prefs->direct_retry_recent_enabled, sizeof(_prefs->direct_retry_recent_enabled)); file.write((uint8_t *)&_prefs->flood_channel_data_enabled, sizeof(_prefs->flood_channel_data_enabled)); - file.write((uint8_t *)&_prefs->flood_channel_block_max_hops, sizeof(_prefs->flood_channel_block_max_hops)); + file.write((uint8_t *)&_prefs->legacy_flood_channel_block_max_hops, + sizeof(_prefs->legacy_flood_channel_block_max_hops)); file.write((uint8_t *)&_prefs->flood_channel_data_max_hops, sizeof(_prefs->flood_channel_data_max_hops)); file.write((uint8_t *)&_prefs->telemetry_access, sizeof(_prefs->telemetry_access)); // 674 #if defined(ENABLE_OTA) @@ -3945,7 +3813,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } } else if (memcmp(config, "flood.channel.data.hops ", 24) == 0) { uint8_t max_hops; - if (parseFloodChannelBlockHops(&config[24], max_hops)) { + if (parseFloodChannelHops(&config[24], max_hops)) { _prefs->flood_channel_data_max_hops = max_hops; savePrefs(); strcpy(reply, "OK"); @@ -3964,62 +3832,6 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else { strcpy(reply, "Error, must be on or off"); } - } else if (memcmp(config, "flood.channel.block.hops ", 25) == 0) { - uint8_t max_hops; - if (parseFloodChannelBlockHops(&config[25], max_hops)) { - _prefs->flood_channel_block_max_hops = max_hops; - savePrefs(); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error, must be all or 1-7"); - } - } else if (memcmp(config, "flood.channel.block", 19) == 0 - && (config[19] == ' ' || config[19] == '.')) { - const char* cursor = &config[19]; - int index = 0; - if (!parseFloodChannelBlockDotIndex(cursor, index)) { - sprintf(reply, "Error, index 1-%d", FLOOD_CHANNEL_BLOCK_SLOTS); - return; - } - cursor = skipSpacesConst(cursor); - - const char* key_start = cursor; - while (*cursor && *cursor != ' ') cursor++; - size_t key_len_text = cursor - key_start; - char key_text[PUB_KEY_SIZE * 2 + 1]; - if (key_len_text == 0 || key_len_text >= sizeof(key_text)) { - strcpy(reply, "Error, use: set flood.channel.block[.n] |#channel"); - return; - } - memcpy(key_text, key_start, key_len_text); - key_text[key_len_text] = 0; - - char name[FLOOD_CHANNEL_BLOCK_NAME_LEN]; - uint8_t block_hops = FLOOD_CHANNEL_BLOCK_HOPS_INHERIT; - if (key_text[0] == '#') { - StrHelper::strncpy(name, key_text, sizeof(name)); - const char* extra = skipSpacesConst(cursor); - if (*extra && looksFloodChannelBlockHopAssignment(extra) - && !parseFloodChannelBlockHopAssignment(extra, true, block_hops)) { - strcpy(reply, "Error, hops must be all, default, or 1-7"); - return; - } - } else { - copyTrimmedFloodChannelBlockName(name, sizeof(name), cursor); - if (!trimFloodChannelBlockHopSuffix(name, block_hops)) { - strcpy(reply, "Error, bad name or hops"); - return; - } - } - uint8_t secret[PUB_KEY_SIZE]; - uint8_t decoded_key_len = 0; - if (!parseFloodChannelBlockKey(key_text, secret, decoded_key_len)) { - strcpy(reply, "Error, key must be 128/256-bit hex or #channel"); - } else if (name[0] == 0 || !isValidName(name)) { - strcpy(reply, "Error, bad name"); - } else { - _callbacks->setFloodChannelBlock(index, secret, decoded_key_len, name, block_hops, reply); - } } else if (memcmp(config, "flood.retry.count ", 18) == 0) { int attempts = looksUnsignedInteger(&config[18]) ? _atoi(&config[18]) : -1; if (attempts >= 0 && attempts <= 15) { @@ -4570,36 +4382,12 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %s", StrHelper::ftoa(_prefs->tx_delay_factor)); } else if (strcmp(config, "flood.channel.data.hops") == 0) { char hops[8]; - formatFloodChannelBlockHops(hops, _prefs->flood_channel_data_max_hops); + formatFloodChannelHops(hops, _prefs->flood_channel_data_max_hops); sprintf(reply, "> %s", hops); } else if (strcmp(config, "flood.channel.data") == 0) { char hops[8]; - formatFloodChannelBlockHops(hops, _prefs->flood_channel_data_max_hops); + formatFloodChannelHops(hops, _prefs->flood_channel_data_max_hops); sprintf(reply, "> %s %s", _prefs->flood_channel_data_enabled ? "on" : "off", hops); - } else if (memcmp(config, "flood.channel.block.hops", 24) == 0) { - char hops[8]; - formatFloodChannelBlockHops(hops, _prefs->flood_channel_block_max_hops); - sprintf(reply, "> %s", hops); - } else if (memcmp(config, "flood.channel.block", 19) == 0 - && (config[19] == 0 || config[19] == ' ' || config[19] == '.')) { - const char* cursor = &config[19]; - int index = 0; - if (!parseFloodChannelBlockDotIndex(cursor, index)) { - sprintf(reply, "Error, index 1-%d", FLOOD_CHANNEL_BLOCK_SLOTS); - return; - } - cursor = skipSpacesConst(cursor); - char selector[8]; - if (index > 0 && *cursor != 0) { - strcpy(reply, "Error, use index or selector"); - return; - } - if (index > 0) { - snprintf(selector, sizeof(selector), "%d", index); - _callbacks->formatFloodChannelBlocks(selector, reply); - } else { - _callbacks->formatFloodChannelBlocks(cursor, reply); - } } else if (memcmp(config, "flood.max.advert", 16) == 0) { sprintf(reply, "> %d", (uint32_t)_prefs->flood_max_advert); } else if (memcmp(config, "flood.max.unscoped", 18) == 0) { @@ -4799,28 +4587,6 @@ void CommonCLI::handleDelCmd(char* command, char* reply) { _callbacks->deleteScheduledRadioParams(true, skipSpacesConst(&config[11]), reply); } else if (memcmp(config, "radioat", 7) == 0 && (config[7] == 0 || config[7] == ' ')) { _callbacks->deleteScheduledRadioParams(false, skipSpacesConst(&config[7]), reply); - } else if (memcmp(config, "flood.channel.block", 19) == 0 - && (config[19] == ' ' || config[19] == '.')) { - const char* cursor = &config[19]; - int index = 0; - if (!parseFloodChannelBlockDotIndex(cursor, index)) { - sprintf(reply, "Error, index 1-%d", FLOOD_CHANNEL_BLOCK_SLOTS); - return; - } - cursor = skipSpacesConst(cursor); - char selector[8]; - if (index > 0 && *cursor != 0) { - strcpy(reply, "Error, use index or selector"); - return; - } - if (index > 0) { - snprintf(selector, sizeof(selector), "%d", index); - _callbacks->deleteFloodChannelBlock(selector, reply); - } else if (*cursor != 0) { - _callbacks->deleteFloodChannelBlock(cursor, reply); - } else { - strcpy(reply, "Error, use: del flood.channel.block "); - } } else { strcpy(reply, "unknown del: "); StrHelper::strncpy(&reply[13], config, 160 - 14); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 7326cbf0..a24e6632 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -90,17 +90,10 @@ #define COMMON_CLI_TMP_LEN ((FLOOD_RETRY_LIST_TEXT_MAX > (PRV_KEY_SIZE * 2 + 4)) ? FLOOD_RETRY_LIST_TEXT_MAX : (PRV_KEY_SIZE * 2 + 4)) #endif -#ifndef FLOOD_CHANNEL_BLOCK_SLOTS - #define FLOOD_CHANNEL_BLOCK_SLOTS 15 +#ifndef FLOOD_CHANNEL_KEY_PREFIX_LEN + #define FLOOD_CHANNEL_KEY_PREFIX_LEN 4 #endif -#ifndef FLOOD_CHANNEL_BLOCK_NAME_LEN - #define FLOOD_CHANNEL_BLOCK_NAME_LEN 32 -#endif -#ifndef FLOOD_CHANNEL_BLOCK_PREFIX_LEN - #define FLOOD_CHANNEL_BLOCK_PREFIX_LEN 4 -#endif -#define FLOOD_CHANNEL_BLOCK_HOPS_ALL 0xFF -#define FLOOD_CHANNEL_BLOCK_HOPS_INHERIT 0xFE +#define FLOOD_CHANNEL_HOPS_ALL 0xFF #define DIRECT_RETRY_CR4_MIN_SNR_X4_DEFAULT 40 #define DIRECT_RETRY_CR5_MIN_SNR_X4_DEFAULT 30 @@ -191,7 +184,9 @@ struct NodePrefs { // persisted to file uint8_t battery_alert_critical_percent; uint8_t direct_retry_recent_enabled; uint8_t flood_channel_data_enabled; - uint8_t flood_channel_block_max_hops; + // Retains the removed flood.channel.block byte at its established file + // offset so every following preference remains upgrade-compatible. + uint8_t legacy_flood_channel_block_max_hops; uint8_t flood_channel_data_max_hops; uint8_t telemetry_access; @@ -334,24 +329,6 @@ public: (void)selector; strcpy(reply, "Error: unsupported"); } - virtual void setFloodChannelBlock(int index, const uint8_t* secret, uint8_t key_len, - const char* name, uint8_t max_hops, char* reply) { - (void)index; - (void)secret; - (void)key_len; - (void)name; - (void)max_hops; - strcpy(reply, "Error: unsupported"); - } - virtual void formatFloodChannelBlocks(const char* selector, char* reply) { - (void)selector; - strcpy(reply, "Error: unsupported"); - } - virtual void deleteFloodChannelBlock(const char* selector, char* reply) { - (void)selector; - strcpy(reply, "Error: unsupported"); - } - virtual void startRegionsLoad() { // no op by default } diff --git a/src/helpers/CompanionFrameQueue.h b/src/helpers/CompanionFrameQueue.h new file mode 100644 index 00000000..f17dfc14 --- /dev/null +++ b/src/helpers/CompanionFrameQueue.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include + +namespace mesh { + +inline bool isCompanionPushFrame(const uint8_t* frame, size_t len) { + return frame != NULL && len > 0 && (frame[0] & 0x80) != 0; +} + +/** + * Add a frame to a companion transport's contiguous outbound queue. + * + * Protocol responses use codes below 0x80 and complete an app command that is + * waiting for them. Push frames use codes at or above 0x80 and are + * asynchronous. Keep one slot available for a response, and if an older queue + * is already full of mixed traffic, let a response replace the newest push. + * Responses are inserted before pushes so packet-log traffic cannot delay a + * command indefinitely. + */ +template +bool enqueueCompanionFrame(Frame queue[], QueueLength& queue_len, size_t capacity, + const uint8_t* src, size_t len) { + if (queue == NULL || src == NULL || len == 0 || capacity == 0) return false; + + size_t count = static_cast(queue_len); + if (count > capacity) return false; + + const bool push = isCompanionPushFrame(src, len); + if (push && count >= capacity - 1) { + return false; // preserve one slot for the reply to an app command + } + + if (count == capacity) { + // A response may displace best-effort asynchronous traffic, but never an + // earlier response that another command is already waiting for. + size_t evict = count; + while (evict > 0) { + --evict; + if (isCompanionPushFrame(queue[evict].buf, queue[evict].len)) break; + } + if (!isCompanionPushFrame(queue[evict].buf, queue[evict].len)) return false; + + for (size_t i = evict; i + 1 < count; ++i) { + queue[i] = queue[i + 1]; + } + --count; + } + + size_t insert_at = count; + if (!push) { + // Keep responses FIFO with respect to one another, ahead of asynchronous + // pushes such as raw-packet logs and message-waiting notifications. + // The stable partition also repairs a queue populated by older admission + // behavior before this policy gets a chance to add the next response. + for (size_t i = 1; i < count; ++i) { + if (isCompanionPushFrame(queue[i].buf, queue[i].len)) continue; + + Frame response = queue[i]; + size_t j = i; + while (j > 0 + && isCompanionPushFrame(queue[j - 1].buf, queue[j - 1].len)) { + queue[j] = queue[j - 1]; + --j; + } + queue[j] = response; + } + for (size_t i = 0; i < count; ++i) { + if (isCompanionPushFrame(queue[i].buf, queue[i].len)) { + insert_at = i; + break; + } + } + for (size_t i = count; i > insert_at; --i) { + queue[i] = queue[i - 1]; + } + } + + queue[insert_at].len = len; + memcpy(queue[insert_at].buf, src, len); + queue_len = static_cast(count + 1); + return true; +} + +} // namespace mesh diff --git a/src/helpers/FloodFilterPolicy.h b/src/helpers/FloodFilterPolicy.h index 5a954999..d2e0eb75 100644 --- a/src/helpers/FloodFilterPolicy.h +++ b/src/helpers/FloodFilterPolicy.h @@ -23,6 +23,16 @@ static constexpr uint8_t SCOPE_PATH_BRIDGE_BUCKET_BASE = 2; static constexpr uint8_t SCOPE_PATH_BRIDGE_BUCKET_COUNT = 6; static constexpr uint8_t SCOPE_PATH_INVALID_BUCKET = 0xFF; +enum RuleIncomingScope : uint8_t { + RULE_IN_ANY = 0, + RULE_IN_NONE = 1, + RULE_IN_SCOPED = 2, + RULE_IN_ALLOWED = 3, + RULE_IN_UNKNOWN = 4, + RULE_IN_SCOPE = 5, + RULE_IN_REGION = 6, +}; + enum ChannelScopeGate { CHANNEL_SCOPE_USE_GLOBAL, CHANNEL_SCOPE_BYPASS, @@ -66,6 +76,49 @@ inline uint8_t scopeSelectorValue(uint8_t stored_selector) { & (uint8_t)~(SLOW_SCOPE_FLAG | SCOPE_PATH_MASK); } +// Regionless channel-scope rows use alternate base selector values so the +// existing 16-bit target field can hold a one-based direct-scope table index. +// Region-backed rows retain their original on-disk selector values. +static constexpr uint8_t CHANNEL_SCOPE_DIRECT_KEY_128 = 17; +static constexpr uint8_t CHANNEL_SCOPE_DIRECT_KEY_256 = 18; +static constexpr uint8_t CHANNEL_SCOPE_DIRECT_TXT_ANY = 33; +static constexpr uint8_t CHANNEL_SCOPE_DIRECT_LOGIN_ANY = 34; +static constexpr uint8_t CHANNEL_SCOPE_DIRECT_OTHER_ANY = 35; +static constexpr uint8_t CHANNEL_SCOPE_INVALID_SELECTOR = 0xFF; + +inline uint8_t encodeChannelScopeTargetSelector(uint8_t match_selector, + bool direct_target) { + if (!direct_target) return match_selector; + if (match_selector <= 2) { + return (uint8_t)(CHANNEL_SCOPE_DIRECT_TXT_ANY + match_selector); + } + if (match_selector == 16 || match_selector == 32) { + return (uint8_t)(16 + match_selector / 16); + } + return CHANNEL_SCOPE_INVALID_SELECTOR; +} + +inline bool channelScopeUsesDirectTarget(uint8_t stored_selector) { + uint8_t selector = scopeSelectorValue(stored_selector); + return (selector >= CHANNEL_SCOPE_DIRECT_KEY_128 + && selector <= CHANNEL_SCOPE_DIRECT_KEY_256) + || (selector >= CHANNEL_SCOPE_DIRECT_TXT_ANY + && selector <= CHANNEL_SCOPE_DIRECT_OTHER_ANY); +} + +inline uint8_t channelScopeMatchSelectorValue(uint8_t stored_selector) { + uint8_t selector = scopeSelectorValue(stored_selector); + if (selector >= CHANNEL_SCOPE_DIRECT_KEY_128 + && selector <= CHANNEL_SCOPE_DIRECT_KEY_256) { + return (uint8_t)((selector - 16) * 16); + } + if (selector >= CHANNEL_SCOPE_DIRECT_TXT_ANY + && selector <= CHANNEL_SCOPE_DIRECT_OTHER_ANY) { + return (uint8_t)(selector - CHANNEL_SCOPE_DIRECT_TXT_ANY); + } + return selector; +} + inline bool scopeUsesSlowTiming(uint8_t stored_selector) { return (stored_selector & SLOW_SCOPE_FLAG) != 0; } @@ -176,6 +229,92 @@ inline bool pathMatchesConfiguredIds(const mesh::Packet* packet, packet, ids, configuredIdCount(ids, maximum_count)); } +inline bool pathStartsWith(const mesh::Packet* packet, + uint8_t hash_size, + uint8_t prefix_hops, + const uint8_t* prefix) { + if (prefix_hops == 0) return true; + if (packet == NULL || prefix == NULL || hash_size < 1 || hash_size > 3 + || packet->getPathHashSize() != hash_size + || packet->getPathHashCount() < prefix_hops) { + return false; + } + return memcmp(packet->path, prefix, hash_size * prefix_hops) == 0; +} + +inline bool ruleIncomingScopeMatches(uint8_t kind, + bool incoming_is_scoped, + uint16_t incoming_transport_code, + bool incoming_region_allowed, + uint16_t wanted_transport_code) { + switch (kind) { + case RULE_IN_ANY: + return true; + case RULE_IN_NONE: + return !incoming_is_scoped; + case RULE_IN_SCOPED: + return incoming_is_scoped; + case RULE_IN_ALLOWED: + return incoming_region_allowed; + case RULE_IN_UNKNOWN: + return incoming_is_scoped && !incoming_region_allowed; + case RULE_IN_SCOPE: + return incoming_is_scoped + && incoming_transport_code == wanted_transport_code; + case RULE_IN_REGION: + // Region rules are name-bound and must be evaluated by the caller with + // RegionNameUtils. Numeric region IDs are deliberately not rule identity. + return false; + default: + return false; + } +} + +inline bool rateLimitReached(bool window_active, uint32_t now, + uint32_t window_started, uint16_t window_count, + uint16_t rate_per_minute) { + uint16_t effective_count = (!window_active + || now - window_started >= 60000UL) + ? 0 : window_count; + return effective_count >= rate_per_minute; +} + +inline bool sameChannelKey(uint8_t left_len, const uint8_t left[], + uint8_t right_len, const uint8_t right[]) { + return left != NULL && right != NULL && left_len != 0 + && left_len == right_len && memcmp(left, right, left_len) == 0; +} + +inline int nextOrderedRule(uint32_t match_mask, uint32_t visited_mask, + const uint8_t priorities[], uint8_t count) { + if (priorities == NULL || count > 32) return -1; + int best = -1; + for (uint8_t i = 0; i < count; i++) { + uint32_t bit = (uint32_t)1U << i; + if ((match_mask & bit) == 0 || (visited_mask & bit) != 0) continue; + if (best < 0 || priorities[i] > priorities[best]) best = i; + } + return best; +} + +inline uint32_t truncateRulesAtStop(uint32_t match_mask, + const uint8_t priorities[], + const uint8_t stop_flags[], + uint8_t count) { + if (stop_flags == NULL) return match_mask; + uint32_t effective = 0; + uint32_t visited = 0; + while (true) { + int index = nextOrderedRule(match_mask, visited, priorities, count); + if (index < 0) break; + uint32_t bit = (uint32_t)1U << index; + visited |= bit; + effective |= bit; + if (stop_flags[index] != 0) break; + } + return effective; +} + inline bool scopeRuleAllowed(bool requires_region_match, bool incoming_region_allowed) { return !requires_region_match || incoming_region_allowed; diff --git a/src/helpers/LogicalMessageCache.h b/src/helpers/LogicalMessageCache.h new file mode 100644 index 00000000..acf03068 --- /dev/null +++ b/src/helpers/LogicalMessageCache.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include + +namespace mesh { + +// Remembers a bounded set of logical messages independently of their on-air +// attempt byte. The fingerprint supplied by the caller must already identify +// the peer and message text. +template +class LogicalMessageCache { +public: + enum class ReplayDecision : uint8_t { + NewMessage, + ExactRetry, + StaleOrMismatched + }; + + LogicalMessageCache() { clear(); } + + bool find(const uint8_t fingerprint[MAX_HASH_SIZE], uint32_t source_timestamp, + uint32_t* mapped_timestamp = NULL) const { + if (fingerprint == NULL) return false; + + for (size_t i = 0; i < ENTRY_COUNT; i++) { + const Entry& entry = entries_[i]; + if (entry.valid && entry.source_timestamp == source_timestamp + && memcmp(entry.fingerprint, fingerprint, MAX_HASH_SIZE) == 0) { + if (mapped_timestamp != NULL) { + *mapped_timestamp = entry.mapped_timestamp; + } + return true; + } + } + return false; + } + + bool remember(const uint8_t fingerprint[MAX_HASH_SIZE], uint32_t source_timestamp, + uint32_t mapped_timestamp = 0) { + if (fingerprint == NULL) return false; + + for (size_t i = 0; i < ENTRY_COUNT; i++) { + Entry& entry = entries_[i]; + if (entry.valid && entry.source_timestamp == source_timestamp + && memcmp(entry.fingerprint, fingerprint, MAX_HASH_SIZE) == 0) { + entry.mapped_timestamp = mapped_timestamp; + return true; + } + } + + Entry& entry = entries_[next_entry_]; + memcpy(entry.fingerprint, fingerprint, MAX_HASH_SIZE); + entry.source_timestamp = source_timestamp; + entry.mapped_timestamp = mapped_timestamp; + entry.valid = true; + next_entry_ = (next_entry_ + 1) % ENTRY_COUNT; + return true; + } + + ReplayDecision classifyAndRemember( + const uint8_t fingerprint[MAX_HASH_SIZE], uint32_t source_timestamp, + uint32_t& latest_source_timestamp) { + if (fingerprint == NULL) return ReplayDecision::StaleOrMismatched; + if (find(fingerprint, source_timestamp)) { + return ReplayDecision::ExactRetry; + } + if (source_timestamp <= latest_source_timestamp) { + return ReplayDecision::StaleOrMismatched; + } + + latest_source_timestamp = source_timestamp; + remember(fingerprint, source_timestamp); + return ReplayDecision::NewMessage; + } + + void clear() { + memset(entries_, 0, sizeof(entries_)); + next_entry_ = 0; + } + +private: + static_assert(ENTRY_COUNT > 0, "LogicalMessageCache needs at least one entry"); + + struct Entry { + uint8_t fingerprint[MAX_HASH_SIZE]; + uint32_t source_timestamp; + uint32_t mapped_timestamp; + bool valid; + }; + + Entry entries_[ENTRY_COUNT]; + size_t next_entry_; +}; + +} // namespace mesh diff --git a/src/helpers/RemoteCliReplyCache.h b/src/helpers/RemoteCliReplyCache.h new file mode 100644 index 00000000..54501868 --- /dev/null +++ b/src/helpers/RemoteCliReplyCache.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include +#include + +namespace mesh { + +// Keeps the most recently completed remote CLI reply so an exact request +// retry can recover a lost response without executing the command twice. +// This is deliberately a single bounded entry: remote CLI commands are +// serialized, and repeater builds on small MCUs cannot afford one reply-sized +// buffer for every ACL client. +class RemoteCliReplyCache { +public: + static constexpr size_t MAX_REPLY_TEXT = + MAX_PACKET_PAYLOAD - CIPHER_MAC_SIZE - (CIPHER_BLOCK_SIZE - 1) - 5; + + RemoteCliReplyCache() { clear(); } + + static uint32_t fingerprint(const char* command, size_t command_len) { + if (command == NULL) return 0; + + // FNV-1a is only a compact request-mismatch guard. Authentication and the + // sender/timestamp tuple remain the replay-protection boundary. + uint32_t value = 2166136261UL; + for (size_t i = 0; i < command_len; i++) { + value ^= (uint8_t)command[i]; + value *= 16777619UL; + } + return value; + } + + bool remember(const uint8_t* sender_pub_key, uint32_t request_timestamp, + uint32_t command_fingerprint, const char* response) { + if (sender_pub_key == NULL || response == NULL) return false; + + memcpy(sender_pub_key_, sender_pub_key, sizeof(sender_pub_key_)); + request_timestamp_ = request_timestamp; + command_fingerprint_ = command_fingerprint; + + size_t response_len = 0; + while (response_len < MAX_REPLY_TEXT && response[response_len] != 0) { + response_len++; + } + memcpy(response_, response, response_len); + response_[response_len] = 0; + valid_ = true; + return true; + } + + bool matches(const uint8_t* sender_pub_key, uint32_t request_timestamp, + uint32_t command_fingerprint) const { + return valid_ && sender_pub_key != NULL + && request_timestamp_ == request_timestamp + && command_fingerprint_ == command_fingerprint + && memcmp(sender_pub_key_, sender_pub_key, sizeof(sender_pub_key_)) == 0; + } + + const char* response() const { return response_; } + bool hasResponse() const { return valid_ && response_[0] != 0; } + bool isValid() const { return valid_; } + + void clear() { + valid_ = false; + memset(sender_pub_key_, 0, sizeof(sender_pub_key_)); + request_timestamp_ = 0; + command_fingerprint_ = 0; + memset(response_, 0, sizeof(response_)); + } + +private: + bool valid_; + uint8_t sender_pub_key_[PUB_KEY_SIZE]; + uint32_t request_timestamp_; + uint32_t command_fingerprint_; + char response_[MAX_REPLY_TEXT + 1]; +}; + +} // namespace mesh diff --git a/src/helpers/esp32/SerialBLEInterface.cpp b/src/helpers/esp32/SerialBLEInterface.cpp index d714b3cd..56f1c834 100644 --- a/src/helpers/esp32/SerialBLEInterface.cpp +++ b/src/helpers/esp32/SerialBLEInterface.cpp @@ -1,4 +1,5 @@ #include "SerialBLEInterface.h" +#include "../CompanionFrameQueue.h" #include "esp_mac.h" #include "esp_gap_ble_api.h" @@ -211,15 +212,11 @@ size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) { } if (deviceConnected && len > 0) { - if (send_queue_len >= FRAME_QUEUE_SIZE) { + if (!mesh::enqueueCompanionFrame(send_queue, send_queue_len, FRAME_QUEUE_SIZE, + src, len)) { BLE_DEBUG_PRINTLN("writeFrame(), send_queue is full!"); return 0; } - - send_queue[send_queue_len].len = len; // add to send queue - memcpy(send_queue[send_queue_len].buf, src, len); - send_queue_len++; - return len; } return 0; diff --git a/src/helpers/esp32/SerialWifiInterface.cpp b/src/helpers/esp32/SerialWifiInterface.cpp index bdecb1a9..0f974d17 100644 --- a/src/helpers/esp32/SerialWifiInterface.cpp +++ b/src/helpers/esp32/SerialWifiInterface.cpp @@ -1,4 +1,5 @@ #include "SerialWifiInterface.h" +#include "../CompanionFrameQueue.h" #include void SerialWifiInterface::begin(int port) { @@ -25,15 +26,11 @@ size_t SerialWifiInterface::writeFrame(const uint8_t src[], size_t len) { } if (deviceConnected && len > 0) { - if (send_queue_len >= FRAME_QUEUE_SIZE) { + if (!mesh::enqueueCompanionFrame(send_queue, send_queue_len, FRAME_QUEUE_SIZE, + src, len)) { WIFI_DEBUG_PRINTLN("writeFrame(), send_queue is full!"); return 0; } - - send_queue[send_queue_len].len = len; // add to send queue - memcpy(send_queue[send_queue_len].buf, src, len); - send_queue_len++; - return len; } return 0; @@ -173,4 +170,4 @@ size_t SerialWifiInterface::checkRecvFrame(uint8_t dest[]) { bool SerialWifiInterface::isConnected() const { return deviceConnected; //pServer != NULL && pServer->getConnectedCount() > 0; -} \ No newline at end of file +} diff --git a/src/helpers/ethernet/SerialEthernetInterface.cpp b/src/helpers/ethernet/SerialEthernetInterface.cpp index 6b3dfc24..02f3e7a7 100644 --- a/src/helpers/ethernet/SerialEthernetInterface.cpp +++ b/src/helpers/ethernet/SerialEthernetInterface.cpp @@ -1,4 +1,5 @@ #include "SerialEthernetInterface.h" +#include "../CompanionFrameQueue.h" #define RECV_STATE_IDLE 0 #define RECV_STATE_HDR_FOUND 1 @@ -27,15 +28,11 @@ size_t SerialEthernetInterface::writeFrame(const uint8_t src[], size_t len) { } if (isConnected() && len > 0) { - if (send_queue_len >= FRAME_QUEUE_SIZE) { + if (!mesh::enqueueCompanionFrame(send_queue, send_queue_len, FRAME_QUEUE_SIZE, + src, len)) { ETHERNET_DEBUG_PRINTLN("writeFrame(), send_queue is full!"); return 0; } - - send_queue[send_queue_len].len = len; // add to send queue - memcpy(send_queue[send_queue_len].buf, src, len); - send_queue_len++; - return len; } return 0; diff --git a/src/helpers/nrf52/SerialBLEInterface.cpp b/src/helpers/nrf52/SerialBLEInterface.cpp index 6bb89154..62b99ca1 100644 --- a/src/helpers/nrf52/SerialBLEInterface.cpp +++ b/src/helpers/nrf52/SerialBLEInterface.cpp @@ -1,4 +1,5 @@ #include "SerialBLEInterface.h" +#include "../CompanionFrameQueue.h" #include #include #include "ble_gap.h" @@ -353,15 +354,11 @@ size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) { bool connected = isConnected(); if (connected && len > 0) { - if (send_queue_len >= FRAME_QUEUE_SIZE) { + if (!mesh::enqueueCompanionFrame(send_queue, send_queue_len, FRAME_QUEUE_SIZE, + src, len)) { BLE_DEBUG_PRINTLN("writeFrame(), send_queue is full!"); return 0; } - - send_queue[send_queue_len].len = len; - memcpy(send_queue[send_queue_len].buf, src, len); - send_queue_len++; - return len; } return 0; diff --git a/test/README.md b/test/README.md index a250db26..4b364c9f 100644 --- a/test/README.md +++ b/test/README.md @@ -39,7 +39,10 @@ does not reflect the GoogleTest count -- run the built binary directly | `test_prefs_save_routing` | `src/helpers/PrefsSaveRouting.h` | runtime common/observer setters write only their owning preference image; mixed-owner setters and migrations can deliberately write both | | `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_telemetry_history` | `src/helpers/TelemetryHistory.h` | 30-minute rings; seven-day temperature/voltage and dynamically sized GPS retention; exact 1 C temperature/status encoding; separate Base64 series payloads; 14-bit GPS differentials; resize preservation, heap budgets, and 1-based paging bounds | -| `test_flood_filter_policy` | `src/helpers/FloodFilterPolicy.h` | unordered 3-byte and 2-byte-prefix path matching; match thresholds; repeated path-entry semantics; blacklist and bridge-bucket channel-scope selection; `require=region` and per-channel scope-gate truth tables; fast/slow scope timing; adding, replacing, and preserving packet scope | +| `test_flood_filter_policy` | `src/helpers/FloodFilterPolicy.h` | unordered blacklist matching; ordered 1/2/3-byte pbyte rule prefixes; original incoming scope classes and canonical region-name identity; channel-authentication cache key comparison; priority ordering and terminal stop masks; bridge-bucket and regionless channel-target selector encoding; `require=region` and per-channel scope-gate truth tables; fast/slow timing; adding, replacing, and preserving packet scope | +| `test_logical_message_cache` | `src/helpers/LogicalMessageCache.h` | bounded logical-message mapping; stable retry timestamps; exact older retries after newer messages; stale and same-timestamp mismatch rejection | +| `test_remote_cli_reply_cache` | `src/helpers/RemoteCliReplyCache.h` | authenticated sender/timestamp/command matching; owned response storage; empty-response completion; on-air truncation; replacement and clearing | +| `test_companion_frame_queue` | `src/helpers/CompanionFrameQueue.h` | reserved reply capacity; response priority; push eviction under saturation; response preservation | | `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) | ## Conventions (and how to add a suite) diff --git a/test/test_companion_frame_queue/test_companion_frame_queue.cpp b/test/test_companion_frame_queue/test_companion_frame_queue.cpp new file mode 100644 index 00000000..90602dfe --- /dev/null +++ b/test/test_companion_frame_queue/test_companion_frame_queue.cpp @@ -0,0 +1,78 @@ +#include + +#include + +namespace { + +struct TestFrame { + size_t len; + uint8_t buf[8]; +}; + +static bool enqueue(TestFrame queue[], size_t& count, size_t capacity, + uint8_t code) { + return mesh::enqueueCompanionFrame(queue, count, capacity, &code, 1); +} + +TEST(CompanionFrameQueue, PushTrafficLeavesOneResponseSlot) { + TestFrame queue[4] = {}; + size_t count = 0; + + EXPECT_TRUE(enqueue(queue, count, 4, 0x88)); + EXPECT_TRUE(enqueue(queue, count, 4, 0x84)); + EXPECT_TRUE(enqueue(queue, count, 4, 0x81)); + EXPECT_FALSE(enqueue(queue, count, 4, 0x8A)); + ASSERT_EQ(3U, count); + + EXPECT_TRUE(enqueue(queue, count, 4, 0x00)); + ASSERT_EQ(4U, count); + EXPECT_EQ(0x00, queue[0].buf[0]); +} + +TEST(CompanionFrameQueue, ResponsesRunBeforeQueuedPushesAndRemainFifo) { + TestFrame queue[5] = {}; + size_t count = 0; + + ASSERT_TRUE(enqueue(queue, count, 5, 0x88)); + ASSERT_TRUE(enqueue(queue, count, 5, 0x06)); + ASSERT_TRUE(enqueue(queue, count, 5, 0x84)); + ASSERT_TRUE(enqueue(queue, count, 5, 0x00)); + + ASSERT_EQ(4U, count); + EXPECT_EQ(0x06, queue[0].buf[0]); + EXPECT_EQ(0x00, queue[1].buf[0]); + EXPECT_EQ(0x88, queue[2].buf[0]); + EXPECT_EQ(0x84, queue[3].buf[0]); +} + +TEST(CompanionFrameQueue, ResponseEvictsNewestPushFromAnAlreadyFullQueue) { + TestFrame queue[4] = { + {1, {0x06}}, {1, {0x88}}, {1, {0x00}}, {1, {0x84}}}; + size_t count = 4; + + ASSERT_TRUE(enqueue(queue, count, 4, 0x01)); + ASSERT_EQ(4U, count); + EXPECT_EQ(0x06, queue[0].buf[0]); + EXPECT_EQ(0x00, queue[1].buf[0]); + EXPECT_EQ(0x01, queue[2].buf[0]); + EXPECT_EQ(0x88, queue[3].buf[0]); +} + +TEST(CompanionFrameQueue, ResponseDoesNotEvictAnotherResponse) { + TestFrame queue[3] = { + {1, {0x00}}, {1, {0x01}}, {1, {0x06}}}; + size_t count = 3; + + EXPECT_FALSE(enqueue(queue, count, 3, 0x02)); + EXPECT_EQ(3U, count); + EXPECT_EQ(0x00, queue[0].buf[0]); + EXPECT_EQ(0x01, queue[1].buf[0]); + EXPECT_EQ(0x06, queue[2].buf[0]); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_flood_filter_policy/test_flood_filter_policy.cpp b/test/test_flood_filter_policy/test_flood_filter_policy.cpp index 53f85c51..aeb3c3a8 100644 --- a/test/test_flood_filter_policy/test_flood_filter_policy.cpp +++ b/test/test_flood_filter_policy/test_flood_filter_policy.cpp @@ -1,6 +1,7 @@ #include #include +#include static mesh::Packet makeFloodPacket(uint8_t hash_size, const uint8_t* path, @@ -127,6 +128,161 @@ TEST(FloodFilterBlacklist, ConfiguredBucketStopsAtEmptyTrailingIds) { &packet, &bucket[0][0], 17)); } +TEST(FloodRulePrefix, SupportsOneTwoAndThreeBytePathPrefixes) { + const uint8_t one_byte_path[] = {0x11, 0x22, 0x33}; + const uint8_t two_byte_path[] = {0x11, 0x22, 0x33, 0x44}; + const uint8_t three_byte_path[] = { + 0x11, 0x22, 0x33, + 0x44, 0x55, 0x66, + }; + mesh::Packet one = makeFloodPacket(1, one_byte_path, 3); + mesh::Packet two = makeFloodPacket(2, two_byte_path, 2); + mesh::Packet three = makeFloodPacket(3, three_byte_path, 2); + + EXPECT_TRUE(FloodFilterPolicy::pathStartsWith( + &one, 1, 2, one_byte_path)); + EXPECT_TRUE(FloodFilterPolicy::pathStartsWith( + &two, 2, 2, two_byte_path)); + EXPECT_TRUE(FloodFilterPolicy::pathStartsWith( + &three, 3, 2, three_byte_path)); +} + +TEST(FloodRulePrefix, RequiresTheExactPathWidthAndStartingSequence) { + const uint8_t path[] = { + 0x11, 0x22, + 0x33, 0x44, + }; + const uint8_t wrong_start[] = {0x33, 0x44}; + mesh::Packet packet = makeFloodPacket(2, path, 2); + + EXPECT_FALSE(FloodFilterPolicy::pathStartsWith( + &packet, 1, 1, path)); + EXPECT_FALSE(FloodFilterPolicy::pathStartsWith( + &packet, 2, 1, wrong_start)); + EXPECT_FALSE(FloodFilterPolicy::pathStartsWith( + &packet, 2, 3, path)); +} + +TEST(FloodRuleIncomingScope, MatchesOriginalScopeClasses) { + using namespace FloodFilterPolicy; + EXPECT_TRUE(ruleIncomingScopeMatches( + RULE_IN_ANY, false, 0, false, 0)); + EXPECT_TRUE(ruleIncomingScopeMatches( + RULE_IN_NONE, false, 0, true, 0)); + EXPECT_FALSE(ruleIncomingScopeMatches( + RULE_IN_NONE, true, 0x1234, true, 0)); + EXPECT_TRUE(ruleIncomingScopeMatches( + RULE_IN_SCOPED, true, 0x1234, false, 0)); + EXPECT_TRUE(ruleIncomingScopeMatches( + RULE_IN_ALLOWED, true, 0x1234, true, 0)); + EXPECT_TRUE(ruleIncomingScopeMatches( + RULE_IN_UNKNOWN, true, 0x1234, false, 0)); +} + +TEST(FloodRuleIncomingScope, ExactScopeDoesNotCrossMatch) { + using namespace FloodFilterPolicy; + EXPECT_TRUE(ruleIncomingScopeMatches( + RULE_IN_SCOPE, true, 0x1234, true, 0x1234)); + EXPECT_FALSE(ruleIncomingScopeMatches( + RULE_IN_SCOPE, true, 0x1235, true, 0x1234)); + EXPECT_FALSE(ruleIncomingScopeMatches( + RULE_IN_REGION, true, 0x1234, true, 0)); +} + +TEST(FloodRuleIncomingScope, RegionIdentityUsesCanonicalNames) { + EXPECT_TRUE(RegionNameUtils::equivalent("usa", "#usa")); + EXPECT_TRUE(RegionNameUtils::equivalent("BlackHole86", "BlackHole86")); + EXPECT_FALSE(RegionNameUtils::equivalent("usa", "BlackHole86")); +} + +TEST(FloodRuleRate, BlocksAtTheConfiguredPerMinuteLimit) { + EXPECT_FALSE(FloodFilterPolicy::rateLimitReached( + false, 1000, 0, 500, 2)); + EXPECT_FALSE(FloodFilterPolicy::rateLimitReached( + true, 2000, 1000, 1, 2)); + EXPECT_TRUE(FloodFilterPolicy::rateLimitReached( + true, 2000, 1000, 2, 2)); + EXPECT_TRUE(FloodFilterPolicy::rateLimitReached( + true, 2000, 1000, 0, 0)); +} + +TEST(FloodRuleRate, StartsANewWindowAfterSixtySeconds) { + EXPECT_FALSE(FloodFilterPolicy::rateLimitReached( + true, 61000, 1000, 2, 2)); + EXPECT_TRUE(FloodFilterPolicy::rateLimitReached( + true, 999, 0xFFFFFF00U, 2, 2)); + EXPECT_FALSE(FloodFilterPolicy::rateLimitReached( + true, 60000, 0xFFFFFF00U, 2, 2)); +} + +TEST(FloodRuleAuthentication, CacheKeysCompareOnlyTheirActiveBytes) { + uint8_t first[32] = {0}; + uint8_t same[32] = {0}; + uint8_t different[32] = {0}; + first[0] = same[0] = different[0] = 0xA5; + first[15] = same[15] = different[15] = 0x5A; + first[31] = 0x11; + same[31] = 0x22; + different[15] = 0x5B; + + EXPECT_TRUE(FloodFilterPolicy::sameChannelKey(16, first, 16, same)); + EXPECT_FALSE(FloodFilterPolicy::sameChannelKey( + 16, first, 16, different)); + EXPECT_FALSE(FloodFilterPolicy::sameChannelKey(16, first, 32, first)); + EXPECT_FALSE(FloodFilterPolicy::sameChannelKey(0, first, 0, same)); +} + +TEST(FloodRuleOrder, HighestPriorityWinsAndSlotBreaksTies) { + const uint8_t priorities[] = {10, 30, 30, 5}; + uint32_t matches = 0x0F; + uint32_t visited = 0; + + int first = FloodFilterPolicy::nextOrderedRule( + matches, visited, priorities, 4); + ASSERT_EQ(1, first); + visited |= (uint32_t)1U << first; + EXPECT_EQ(2, FloodFilterPolicy::nextOrderedRule( + matches, visited, priorities, 4)); +} + +TEST(FloodRuleOrder, StopRemovesOnlyLowerOrderedMatches) { + const uint8_t priorities[] = {10, 30, 20, 5}; + const uint8_t stop_flags[] = {0, 0, 1, 0}; + + EXPECT_EQ(0x06U, FloodFilterPolicy::truncateRulesAtStop( + 0x0F, priorities, stop_flags, 4)); +} + +TEST(FloodRuleOrder, StopAtEqualPriorityUsesLowerSlotFirst) { + const uint8_t priorities[] = {40, 40, 50}; + const uint8_t stop_flags[] = {1, 0, 0}; + + EXPECT_EQ(0x05U, FloodFilterPolicy::truncateRulesAtStop( + 0x07, priorities, stop_flags, 3)); +} + +TEST(FloodRuleOrder, NoStopPreservesEveryMatch) { + const uint8_t priorities[] = {1, 200, 3, 99}; + const uint8_t stop_flags[] = {0, 0, 0, 0}; + + EXPECT_EQ(0x0BU, FloodFilterPolicy::truncateRulesAtStop( + 0x0B, priorities, stop_flags, 4)); +} + +TEST(FloodRuleOrder, ThirtyOneSlotTableIncludesTheLastSlot) { + uint8_t priorities[31] = {0}; + uint8_t stop_flags[31] = {0}; + priorities[30] = 200; + stop_flags[30] = 1; + const uint32_t matches = ((uint32_t)1U << 30) | 1U; + + EXPECT_EQ(30, FloodFilterPolicy::nextOrderedRule( + matches, 0, priorities, 31)); + EXPECT_EQ((uint32_t)1U << 30, + FloodFilterPolicy::truncateRulesAtStop( + matches, priorities, stop_flags, 31)); +} + TEST(FloodFilterScope, RegionRequirementHasTheExpectedTruthTable) { EXPECT_TRUE(FloodFilterPolicy::scopeRuleAllowed(false, false)); EXPECT_TRUE(FloodFilterPolicy::scopeRuleAllowed(false, true)); @@ -193,6 +349,42 @@ TEST(FloodFilterScope, EveryBridgeBucketRoundTripsWithoutChangingSelector) { } } +TEST(FloodFilterScope, DirectChannelTargetsRoundTripEveryMatcherClass) { + const uint8_t selectors[] = {0, 1, 2, 16, 32}; + for (uint8_t selector : selectors) { + uint8_t direct = FloodFilterPolicy::encodeChannelScopeTargetSelector( + selector, true); + uint8_t stored = FloodFilterPolicy::encodeScopeSelector( + direct, true, FloodFilterPolicy::SCOPE_PATH_BRIDGE_BUCKET_BASE + 5); + + EXPECT_NE(FloodFilterPolicy::CHANNEL_SCOPE_INVALID_SELECTOR, direct); + EXPECT_TRUE(FloodFilterPolicy::channelScopeUsesDirectTarget(stored)); + EXPECT_EQ(selector, + FloodFilterPolicy::channelScopeMatchSelectorValue(stored)); + EXPECT_TRUE(FloodFilterPolicy::scopeUsesSlowTiming(stored)); + EXPECT_EQ(FloodFilterPolicy::SCOPE_PATH_BRIDGE_BUCKET_BASE + 5, + FloodFilterPolicy::scopePathSelectorValue(stored)); + } +} + +TEST(FloodFilterScope, RegionChannelTargetsKeepLegacySelectorValues) { + const uint8_t selectors[] = {0, 1, 2, 16, 32}; + for (uint8_t selector : selectors) { + uint8_t region = FloodFilterPolicy::encodeChannelScopeTargetSelector( + selector, false); + + EXPECT_EQ(selector, region); + EXPECT_FALSE(FloodFilterPolicy::channelScopeUsesDirectTarget(region)); + EXPECT_EQ(selector, + FloodFilterPolicy::channelScopeMatchSelectorValue(region)); + } +} + +TEST(FloodFilterScope, DirectChannelTargetRejectsUnknownMatcher) { + EXPECT_EQ(FloodFilterPolicy::CHANNEL_SCOPE_INVALID_SELECTOR, + FloodFilterPolicy::encodeChannelScopeTargetSelector(7, true)); +} + TEST(FloodFilterScope, OnlyChangedFastRulesReceiveFastTrackTreatment) { EXPECT_TRUE(FloodFilterPolicy::fastTrackScopeChange(true, false)); EXPECT_FALSE(FloodFilterPolicy::fastTrackScopeChange(true, true)); diff --git a/test/test_logical_message_cache/test_logical_message_cache.cpp b/test/test_logical_message_cache/test_logical_message_cache.cpp new file mode 100644 index 00000000..cd7ac186 --- /dev/null +++ b/test/test_logical_message_cache/test_logical_message_cache.cpp @@ -0,0 +1,111 @@ +#include + +#include + +static void makeFingerprint(uint8_t fingerprint[MAX_HASH_SIZE], uint8_t value) { + memset(fingerprint, value, MAX_HASH_SIZE); +} + +TEST(LogicalMessageCache, MatchesFingerprintAndSourceTimestamp) { + mesh::LogicalMessageCache<4> cache; + uint8_t first[MAX_HASH_SIZE]; + uint8_t second[MAX_HASH_SIZE]; + makeFingerprint(first, 0x11); + makeFingerprint(second, 0x22); + + ASSERT_TRUE(cache.remember(first, 100U, 500U)); + + uint32_t mapped = 0; + EXPECT_TRUE(cache.find(first, 100U, &mapped)); + EXPECT_EQ(500U, mapped); + EXPECT_FALSE(cache.find(first, 101U)); + EXPECT_FALSE(cache.find(second, 100U)); +} + +TEST(LogicalMessageCache, RepeatedRememberUpdatesMappedTimestamp) { + mesh::LogicalMessageCache<2> cache; + uint8_t fingerprint[MAX_HASH_SIZE]; + makeFingerprint(fingerprint, 0x33); + + ASSERT_TRUE(cache.remember(fingerprint, 7U, 70U)); + ASSERT_TRUE(cache.remember(fingerprint, 7U, 71U)); + + uint32_t mapped = 0; + EXPECT_TRUE(cache.find(fingerprint, 7U, &mapped)); + EXPECT_EQ(71U, mapped); +} + +TEST(LogicalMessageCache, RoundRobinReplacementIsBounded) { + mesh::LogicalMessageCache<2> cache; + uint8_t first[MAX_HASH_SIZE]; + uint8_t second[MAX_HASH_SIZE]; + uint8_t third[MAX_HASH_SIZE]; + makeFingerprint(first, 0x41); + makeFingerprint(second, 0x42); + makeFingerprint(third, 0x43); + + ASSERT_TRUE(cache.remember(first, 1U)); + ASSERT_TRUE(cache.remember(second, 2U)); + ASSERT_TRUE(cache.remember(third, 3U)); + + EXPECT_FALSE(cache.find(first, 1U)); + EXPECT_TRUE(cache.find(second, 2U)); + EXPECT_TRUE(cache.find(third, 3U)); +} + +TEST(LogicalMessageCache, ClearForgetsAllEntries) { + mesh::LogicalMessageCache<2> cache; + uint8_t fingerprint[MAX_HASH_SIZE]; + makeFingerprint(fingerprint, 0x55); + ASSERT_TRUE(cache.remember(fingerprint, 9U, 90U)); + + cache.clear(); + + EXPECT_FALSE(cache.find(fingerprint, 9U)); +} + +TEST(LogicalMessageCache, ExactOlderRetrySurvivesNewerMessages) { + mesh::LogicalMessageCache<4> cache; + uint8_t first[MAX_HASH_SIZE]; + uint8_t second[MAX_HASH_SIZE]; + makeFingerprint(first, 0x61); + makeFingerprint(second, 0x62); + uint32_t latest = 0; + + EXPECT_EQ(mesh::LogicalMessageCache<4>::ReplayDecision::NewMessage, + cache.classifyAndRemember(first, 100U, latest)); + EXPECT_EQ(mesh::LogicalMessageCache<4>::ReplayDecision::NewMessage, + cache.classifyAndRemember(second, 101U, latest)); + EXPECT_EQ(101U, latest); + + EXPECT_EQ(mesh::LogicalMessageCache<4>::ReplayDecision::ExactRetry, + cache.classifyAndRemember(first, 100U, latest)); + EXPECT_EQ(101U, latest); +} + +TEST(LogicalMessageCache, RejectsOldUnseenAndSameTimestampMismatches) { + mesh::LogicalMessageCache<4> cache; + uint8_t accepted[MAX_HASH_SIZE]; + uint8_t mismatched[MAX_HASH_SIZE]; + makeFingerprint(accepted, 0x71); + makeFingerprint(mismatched, 0x72); + uint32_t latest = 200U; + + EXPECT_EQ(mesh::LogicalMessageCache<4>::ReplayDecision::StaleOrMismatched, + cache.classifyAndRemember(mismatched, 199U, latest)); + EXPECT_EQ(mesh::LogicalMessageCache<4>::ReplayDecision::StaleOrMismatched, + cache.classifyAndRemember(mismatched, 200U, latest)); + EXPECT_EQ(mesh::LogicalMessageCache<4>::ReplayDecision::NewMessage, + cache.classifyAndRemember(accepted, 201U, latest)); +} + +TEST(LogicalMessageCache, RejectsNullFingerprint) { + mesh::LogicalMessageCache<2> cache; + EXPECT_FALSE(cache.remember(NULL, 1U, 2U)); + EXPECT_FALSE(cache.find(NULL, 1U)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_remote_cli_reply_cache/test_remote_cli_reply_cache.cpp b/test/test_remote_cli_reply_cache/test_remote_cli_reply_cache.cpp new file mode 100644 index 00000000..ca561789 --- /dev/null +++ b/test/test_remote_cli_reply_cache/test_remote_cli_reply_cache.cpp @@ -0,0 +1,85 @@ +#include + +#include + +TEST(RemoteCliReplyCache, ReplaysOnlyTheSameAuthenticatedRequest) { + mesh::RemoteCliReplyCache cache; + uint8_t alice[PUB_KEY_SIZE] = {}; + uint8_t bob[PUB_KEY_SIZE] = {}; + alice[0] = 0xA1; + bob[0] = 0xB2; + const char command[] = "set repeat off"; + const uint32_t fingerprint = + mesh::RemoteCliReplyCache::fingerprint(command, strlen(command)); + + EXPECT_FALSE(cache.matches(alice, 123U, fingerprint)); + ASSERT_TRUE(cache.remember(alice, 123U, fingerprint, "OK")); + EXPECT_TRUE(cache.matches(alice, 123U, fingerprint)); + EXPECT_STREQ("OK", cache.response()); + EXPECT_TRUE(cache.hasResponse()); + + EXPECT_FALSE(cache.matches(bob, 123U, fingerprint)); + EXPECT_FALSE(cache.matches(alice, 124U, fingerprint)); + EXPECT_FALSE(cache.matches( + alice, 123U, + mesh::RemoteCliReplyCache::fingerprint("set repeat on", 13))); +} + +TEST(RemoteCliReplyCache, OwnsAndReplacesTheRememberedResponse) { + mesh::RemoteCliReplyCache cache; + uint8_t first_sender[PUB_KEY_SIZE] = {}; + uint8_t sender[PUB_KEY_SIZE] = {}; + first_sender[0] = 0x11; + memcpy(sender, first_sender, sizeof(sender)); + char response[] = "first"; + + ASSERT_TRUE(cache.remember(sender, 1U, 10U, response)); + response[0] = 'X'; + sender[0] = 0x22; + EXPECT_STREQ("first", cache.response()); + + ASSERT_TRUE(cache.remember(sender, 2U, 20U, "second")); + EXPECT_FALSE(cache.matches(first_sender, 1U, 10U)); + EXPECT_TRUE(cache.matches(sender, 2U, 20U)); + EXPECT_STREQ("second", cache.response()); +} + +TEST(RemoteCliReplyCache, EmptyResponseStillMarksRequestComplete) { + mesh::RemoteCliReplyCache cache; + uint8_t sender[PUB_KEY_SIZE] = {}; + + ASSERT_TRUE(cache.remember(sender, 9U, 99U, "")); + EXPECT_TRUE(cache.matches(sender, 9U, 99U)); + EXPECT_TRUE(cache.isValid()); + EXPECT_FALSE(cache.hasResponse()); + EXPECT_STREQ("", cache.response()); +} + +TEST(RemoteCliReplyCache, TruncatesToTheOnAirReplyLimit) { + mesh::RemoteCliReplyCache cache; + uint8_t sender[PUB_KEY_SIZE] = {}; + char response[mesh::RemoteCliReplyCache::MAX_REPLY_TEXT + 20]; + memset(response, 'x', sizeof(response)); + response[sizeof(response) - 1] = 0; + + ASSERT_TRUE(cache.remember(sender, 7U, 77U, response)); + EXPECT_EQ(mesh::RemoteCliReplyCache::MAX_REPLY_TEXT, + strlen(cache.response())); +} + +TEST(RemoteCliReplyCache, ClearForgetsTheRequestAndResponse) { + mesh::RemoteCliReplyCache cache; + uint8_t sender[PUB_KEY_SIZE] = {}; + ASSERT_TRUE(cache.remember(sender, 1U, 2U, "OK")); + + cache.clear(); + + EXPECT_FALSE(cache.isValid()); + EXPECT_FALSE(cache.matches(sender, 1U, 2U)); + EXPECT_STREQ("", cache.response()); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/rak3x72/platformio.ini b/variants/rak3x72/platformio.ini index 113405c7..9e66b70d 100644 --- a/variants/rak3x72/platformio.ini +++ b/variants/rak3x72/platformio.ini @@ -17,10 +17,13 @@ build_src_filter = ${stm32_base.build_src_filter} [env:RAK_3x72_repeater] extends = rak3x72 board_upload.maximum_size = 245760 ; 240 KiB app, 16 KiB LittleFS +build_unflags = -Os build_flags = ${rak3x72.build_flags} + -Oz ; strongest size profile preserves the deployed 16 KiB filesystem boundary -flto -fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary -fipa-pta + -fmerge-all-constants -fno-semantic-interposition -fno-unwind-tables -fno-asynchronous-unwind-tables diff --git a/variants/tiny_relay/platformio.ini b/variants/tiny_relay/platformio.ini index 44781ea0..443c744d 100644 --- a/variants/tiny_relay/platformio.ini +++ b/variants/tiny_relay/platformio.ini @@ -18,10 +18,13 @@ build_src_filter = ${stm32_base.build_src_filter} [env:Tiny_Relay_repeater] extends = Tiny_Relay board_upload.maximum_size = 245760 ; 240 KiB app, 16 KiB LittleFS +build_unflags = -Os build_flags = ${Tiny_Relay.build_flags} + -Oz ; strongest size profile preserves the deployed 16 KiB filesystem boundary -flto ; retain headroom for current repeater safety features -fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary -fipa-pta + -fmerge-all-constants -fno-semantic-interposition -fno-unwind-tables -fno-asynchronous-unwind-tables diff --git a/variants/wio-e5-dev/platformio.ini b/variants/wio-e5-dev/platformio.ini index a469fb95..f71d4411 100644 --- a/variants/wio-e5-dev/platformio.ini +++ b/variants/wio-e5-dev/platformio.ini @@ -18,10 +18,13 @@ build_src_filter = ${stm32_base.build_src_filter} [env:wio-e5_repeater] extends = lora_e5 board_upload.maximum_size = 245760 ; 240 KiB app, 16 KiB LittleFS +build_unflags = -Os build_flags = ${lora_e5.build_flags} + -Oz ; strongest size profile preserves the deployed 16 KiB filesystem boundary -flto -fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary -fipa-pta + -fmerge-all-constants -fno-semantic-interposition -fno-unwind-tables -fno-asynchronous-unwind-tables diff --git a/variants/wio-e5-mini/platformio.ini b/variants/wio-e5-mini/platformio.ini index 186f837a..2417b393 100644 --- a/variants/wio-e5-mini/platformio.ini +++ b/variants/wio-e5-mini/platformio.ini @@ -26,6 +26,7 @@ build_flags = ${lora_e5_mini.build_flags} -flto -fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary -fipa-pta + -fmerge-all-constants -fno-semantic-interposition -fno-unwind-tables -fno-asynchronous-unwind-tables