From 953ad6defb12e2e830adae6c8fbd19ede2cba652 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 21 Oct 2026 01:10:11 -0700 Subject: [PATCH] Extend forwarding filters and save moderation rules atomically --- .github/workflows/run-unit-tests.yml | 5 + docs/cli_commands.md | 129 ++++- docs/flood_filtering.md | 172 +++++- examples/simple_repeater/MyMesh.cpp | 511 ++++++++++++------ examples/simple_repeater/MyMesh.h | 14 +- .../simple_room_server/FloodRuleEngine.cpp | 214 +++++--- examples/simple_room_server/FloodRuleEngine.h | 9 +- examples/simple_room_server/MyMesh.cpp | 24 +- scripts/check_firmware_ram.py | 6 +- src/helpers/FleetCommand.cpp | 84 ++- src/helpers/FloodAdvertCLI.h | 13 +- src/helpers/FloodCommandAliases.h | 38 ++ src/helpers/FloodFilterPolicy.h | 16 +- src/helpers/FloodGroupPlainText.h | 70 +++ src/helpers/FloodRuleCLI.h | 31 +- src/helpers/FloodRuleExtensions.h | 417 ++++++++++++++ src/helpers/FloodTextPattern.h | 319 +++++++++++ test/test_client_acl_cli.py | 3 +- test/test_firmware_ram.py | 12 +- .../test_fleet_command/test_fleet_command.cpp | 5 + test/test_fleet_command_crypto.py | 45 ++ test/test_fleet_role_wiring.py | 1 + test/test_flood_moderation.py | 469 ++++++++++++++++ test/test_flood_policy_capacity.py | 72 ++- test/test_flood_rule_cli.py | 201 +++++++ test/test_flood_rule_extensions.py | 307 +++++++++++ test/test_flood_rule_layout.py | 24 +- test/test_flood_text_pattern.py | 221 ++++++++ test/test_morse_auth.py | 1 + test/test_room_filter_extensions.py | 266 +++++++++ test/test_transport_filter_wiring.py | 96 +++- 31 files changed, 3453 insertions(+), 342 deletions(-) create mode 100644 src/helpers/FloodCommandAliases.h create mode 100644 src/helpers/FloodGroupPlainText.h create mode 100644 src/helpers/FloodRuleExtensions.h create mode 100644 src/helpers/FloodTextPattern.h create mode 100644 test/test_flood_moderation.py create mode 100644 test/test_flood_rule_cli.py create mode 100644 test/test_flood_rule_extensions.py create mode 100644 test/test_flood_text_pattern.py create mode 100644 test/test_room_filter_extensions.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index f1041500a..13c8ba620 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -360,7 +360,12 @@ jobs: - name: Verify bridge filter wiring and shorthand permissions run: | + python3 -B test/test_flood_text_pattern.py -v + python3 -B test/test_flood_rule_extensions.py -v + python3 -B test/test_flood_rule_cli.py -v + python3 -B test/test_flood_moderation.py -v python3 -B test/test_transport_filter_wiring.py -v + python3 -B test/test_room_filter_extensions.py -v python3 -B test/test_rs232_runtime.py -v python3 -B test/test_rs232_mqtt_runtime.py -v python3 -B test/test_rs232_driver_lifecycle.py -v diff --git a/docs/cli_commands.md b/docs/cli_commands.md index e9b5995e9..310eb776c 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -3333,7 +3333,7 @@ setting. | Mode | Where the rule applies | | --- | --- | -| `radio` | Ordinary LoRa flood forwarding. This is the default when `mode=` is omitted. | +| `radio` | Ordinary LoRa forwarding; flood-only unless `route=direct` or `route=any` is selected. This is the default mode. | | `bridge` | Packets entering or leaving an RS232 or ESP-NOW bridge, and MeshCore RX/TX packet publication through MQTT. | | `cross` | Transmissions copied to the other radio profile, including a copy's retries. The same-profile transmission remains eligible. | | `bridge,cross` | Both transport paths using one rule. `cross,bridge` is equivalent. | @@ -3398,6 +3398,10 @@ removed. The fixed-size STM32WL FPF6 build cannot match authenticated channels. - `del flood.channel.scope.` - `del flood.channel.scope all` +`fs` is the short family name for all these commands. Short options are +`p=bl` (`path=blacklist`), `p=b1` through `p=b6` (`path=bucket:1` through +`path=bucket:6`), and `tx=s`/`tx=f` (`tx=slow`/`tx=fast`). + **Parameters:** - `n`: Slot number within the table compiled for the target. Roomy ESP32 builds provide `1-255`; classic ESP32 repeaters and nRF52/other @@ -3606,6 +3610,9 @@ available for tables shared with `flood.filter path=blacklist` rules. - `del flood.channel.scope.require.` - `del flood.channel.scope.require all` +`fsr` is the short family name, with the same arguments and region/scope-manager +permissions. + **Default:** Empty; normal global region enforcement remains active. Once this table contains a row, received flood `GRP_TXT` and `GRP_DATA` @@ -3671,6 +3678,10 @@ worked moderation examples, see [Flood Filtering and Moderation](flood_filtering - `del flood.filter.` - `del flood.filter all` +Generalized rows also accept `sender=`, `text=`, `len=`, `route=`, `prob=`, +`enabled=`, and anchored `path=` chains. These use the same table and ordering; +see the parameters and short forms below. + The extended table is available on repeaters with the rule engine enabled and on FULL-profile ESP32 room servers. A FULL room server exposes `fr`, `flood.rule`, and `flood.filter`, has 31 slots, and requires an administrator @@ -3721,7 +3732,35 @@ compile this table. relayed login/admin traffic, but not direct packets or local delivery. - `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. + 1-, 2-, or 3-byte pbyte width. `p=` is the short form. +- `path=[^]ID>ID...[$]` or `pa=...`: Ordered chain of up to four IDs, matched + anywhere in a flood path. `^` anchors the earliest relay and `$` the most + recent; both require exactly the chain. Commas may replace `>`. + Each ID must use the packet's complete hash width. Direct paths and TRACE + SNR bytes never match. `path=blacklist` remains the separate unordered table. +- `sender=` (`n=`), `text=` (`x=`): Case-sensitive patterns + on the decrypted sender name and message body. Require an explicit + authenticated channel (`public`, `#name`, or key); `channel=*` and raw hashes + are insufficient. These match only plain group text. Patterns search + anywhere unless anchored with `^`/`$`, and support `.`, classes such as + `[A-Z]` or `[^0-9]`, `*`, `+`, `?`, groups, `|`, and escaped literals. + Use `[0-9]` for digits and quote values containing spaces. The maximum is + 63 bytes per pattern. Matching uses complete fields, removes AES zero + padding, and shares a 131,072-operation work budget across one rule + evaluation. Exhaustion rejects the packet; it cannot grant an allow, + rewrite, or retry action. + A message without `:` has no sender field but can match a text rule. +- `len=N|N+|N-M` (`l=`): Inclusive range of payload bytes, excluding route/path + metadata. Ciphertext padding counts toward payload length. +- `route=flood|direct|any` (`rt=f|d|a`): Select route classes. Omitted radio + rules retain flood-only behavior. Direct/any rows support drop, rate, and + stop/allow; scope/region, retry, and TX timing require flood-only radio rules. +- `prob=1-100` (`pr=`): Apply the row to approximately this percentage of + otherwise-matching packets. Omitted means `100`; a failed roll leaves later + rows eligible. It is a sampling rule, not a forwarding quota. +- `enabled=on|off` (`e=1|0`): Keep a row while enabling or disabling it. To + change only this state, use `set fr. e=off` or `set fr. e=on`; its + conditions and actions stay saved. - `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 @@ -3758,6 +3797,8 @@ compile this table. wildcard, or has no usable transport key, both the rewrite and its `stop` are inert so lower-order safety rows still run. A direct `scope=` target does not depend on region configuration. + `allow`, `forward`, and `a` are aliases for `stop`; they do not override + earlier drops, normal forwarding gates, or separate moderation rules. - `suspend=tempradio`: Optional. Skip this row while either `tempradio` or `tempradio2` is active, including scheduled temporary sessions. - `require=region`: Legacy alias for `in=allowed`. Apply the row only if the @@ -3799,11 +3840,11 @@ The payload names follow the [MeshCore packet-format allocation](https://docs.me | `0x0E` | `14` | reserved | | `0x0F` | `raw_custom` | `PAYLOAD_TYPE_RAW_CUSTOM` | -**Route scope:** `mode=radio` rules are evaluated only for the two flood route values: +**Route scope:** `mode=radio` defaults to the two flood route values: `ROUTE_TYPE_TRANSPORT_FLOOD` (`0x00`, flood plus transport codes) and `ROUTE_TYPE_FLOOD` (`0x01`, unscoped flood). Direct routes `0x02` and `0x03` -are unaffected by radio rules. Bridge/cross rules also cover direct routes at -their respective transport boundaries. +require an explicit `route=direct` or `route=any` on a radio row. Bridge/cross +rules cover both route classes by default at their transport boundaries. **Behavior:** Match fields within one row are ANDed. Radio rows are matched against the same immutable receive-time packet, before any rule changes its @@ -3853,7 +3894,10 @@ 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 +New sender/text, length, route, probability, enabled-state, or extended-path +fields use FPF8 instead. Old firmware without FPF8 support cannot load that +table; remove the new fields before downgrading and keep a copy of the rules. +FPF1-FPF5 files are rejected and filtering fails open. FPF7/FPF8 store 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 @@ -3864,11 +3908,10 @@ mode; normal payload types can also use the temporary channel. Other rows remain in force, and suspended rows resume after both temporary sessions end. A malformed persisted table fails open (no general rules are applied). -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. -Raw `hash:XX` rows skip this authentication and compare only the one visible -byte. +Within one receive evaluation, rows using the same channel key share the +initial authentication verdict; content patterns also decrypt into temporary +scratch for matching. The cache and plaintext are discarded after the packet. +Raw `hash:XX` rows skip authentication and compare only the visible byte. **Per-channel retry examples:** @@ -3962,24 +4005,56 @@ short setter; `get fr` lists the table. Unnumbered `set fr ...` reuses an identical row or adds one to the first empty slot. `del fr.N` and `del fr all` have the same behavior as their long forms. +Content, exceptions, and state changes use that same setter: + +```text +set fr grp_txt c=#local x="^BEACON" d +set fr grp_txt c=#local n="^Alice$" pri=200 a +set fr grp_txt c=#local d +set fr.4 e=off +set fr.4 e=on +``` + +The Alice rule stops lower-order general drops; separate moderation and hard +forwarding gates still apply. Sender names remain unverified display text. +Saved older prefix rules keep their start-anchored semantics; when retyping an +old `path=A1,B2`, use `prefix=A1,B2` or `pa=^A1>B2`. + | Full token | Short token | | --- | --- | | `type=...` | Positional type or `t=...`; `*` means `any` | | `hops=...` | Positional range or `h=...`; `*` means `all` | | `mode=radio/bridge/cross/bridge,cross` | `m=r/b/c/bc` (choose one value) | | `channel=...`, `prefix=...` | `c=...`, `p=...` | +| `path=[^]ID>ID...[$]` | `pa=...` | +| `sender=...`, `text=...` | `n=...`, `x=...` | +| `len=N/N+/N-M` | `l=...` | +| `route=flood/direct/any` | `rt=f/d/a` | +| `prob=N`, `enabled=on/off` | `pr=N`, `e=1/0` | | `hashbytes=any/1/2/2+/3` | `pb=*/1/2/2+/3` (choose one value) | | `path=blacklist` | `p=bl` (repeater only) | | `in=any/none/scoped/allowed/unknown` | `i=*/n/s/a/u` (choose one value) | | `in=scope:name`, `in=region:name` | `i=s:name`, `i=r:name` | | `drop`, `stop`, `retry` | `d`, `s`, `r` | +| `allow`, `forward` | `a` (same action as `stop`) | | `scope=name`, `region=name` | `s=name`, `r=name` | | `rate=N/min`, `priority=N` | `q=N`, `pri=N` | | `tx=slow`, `suspend=tempradio`, `retry` | `f=s`, `f=t`, `f=r`; combine as `f=str` | All existing validation and permissions apply. `m=cb`, `m=b,c`, and `m=c,b` -also mean bridge and crossover. Short aliases for `flood.channel.scope`, -`flood.moderation`, and blacklist management are not added. +also mean bridge and crossover. + +| Full family | Short family | Commands | +| --- | --- | --- | +| `flood.moderation` | `fm` | `get`, `set`, `del` | +| `flood.channel.scope` | `fs` | `get`, `set`, `del` | +| `flood.channel.scope.require` | `fsr` | `get`, `set`, `del` | +| `flood.filter.blacklist` | `fb` | `get`, `set`, `del` | +| `flood.advert` | `fa` | `get`, `clear` | + +Slot arguments, output, validation, and permissions are shared with the full +family names. For example, `get fa key 1` inspects the full advert key and +`clear fa all` clears advert history. The numbered long getters use this complete short setter when descriptive fields would exceed a reply. Defaults are omitted and specific payload types @@ -3993,7 +4068,7 @@ matching rule. Unknown or ambiguous references are rejected. See the On generalized repeaters, filter rows, scope-rewrite rows, the shared blacklist, and `flood.channel.data` compatibility state are committed in one -atomic FPF7 image. Compact FPF6 profiles retain separate files. Replacing or deleting +atomic FPF7/FPF8 image. Compact FPF6 profiles retain separate files. Replacing or deleting the blacklist does not delete rows containing `path=blacklist`; such rows remain dormant while the list is empty. Path hashes are truncated routing identifiers, not authenticated identities, so this is a forwarding signal @@ -4055,7 +4130,10 @@ 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. +region-action, or rate fields, nor sender/text patterns, length, route, +probability, enabled-state, or anchored chains. Their compiled legacy +families still accept the `fm`/`fs`/`fsr`/`fb`/`fa` aliases; `fm` requires +moderation support in that build. No partition size changes are required. --- @@ -4069,6 +4147,10 @@ region-action, or rate fields. No partition size changes are required. - `del flood.moderation.` - `del flood.moderation all` +`fm` is the short family name. Options can use `d` (`drop`), `q=N` +(`rate=N/min`), `h=N` (`hops=N`), and `p=...` (`path=...`). The short rate +also accepts `q=N/min` or `q=N/m`; duplicate rate/drop options remain invalid. + **Parameters:** - `n`: Moderation slot from `1` to `16`. - `channel`: @@ -4090,6 +4172,12 @@ At least one of `drop`, `rate=X/min`, or `hops=N` is required. Rate and hop limits can be combined. Rate counters are local to this repeater and rule, use a 60-second window beginning with the first matching message, and reset on reboot. +Configured exact names are limited to 31 bytes. A wildcard `*` with a hop +restriction also matches longer valid sender names. The FGM1 storage format +is unchanged; saves use staging, readback, and backup recovery. A save reported +as unconfirmed may already have published, so rebooted settings can differ +from the restored live table. + **Decode and identity behavior:** Moderation applies only to flood `PAYLOAD_TYPE_GRP_TXT`. The repeater first checks the packet's channel-hash byte, then validates and decrypts with the configured key. It extracts the text before @@ -4111,6 +4199,7 @@ retransmission is denied. There are no moderation rules by default. set flood.moderation public "Noisy User" rate=5/min set flood.moderation #local bot drop path=A1B2C3,D4E5F6 set flood.moderation.3 00112233445566778899AABBCCDDEEFF alice rate=10/min hops=4 path=71CE82 +set fm public "Noisy User" q=5 get flood.moderation get flood.moderation.3 del flood.moderation.3 @@ -4138,14 +4227,20 @@ del flood.moderation.3 non-secret operational/filter status commands and can change the forwarding controls `repeat`, `loop.detect`, `flood.max*`, `flood.channel.data*`, `flood.filter*`, `flood.rule*`, `fr[.N]`, and -`flood.moderation*`. It cannot read +`flood.moderation*`, including the `fm`, `fb`, and `fa` aliases. 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 +is limited to region commands, `flood.channel.scope*` (including `fs` and +`fsr`), and the same non-secret status allowlist. Both delegated manager roles are protected from least-recently-active ACL eviction like administrators. +The same filter aliases work inside enrolled +[signed fleet commands](fleet_management.md), including quoted sender/text +patterns. Fleet signature, expiry, replay, and command-size checks still apply; +ordinary channel text cannot authorize a change. + **Note:** Removes the entry when `permissions` is omitted --- diff --git a/docs/flood_filtering.md b/docs/flood_filtering.md index 873dbcc82..e862f9ce6 100644 --- a/docs/flood_filtering.md +++ b/docs/flood_filtering.md @@ -13,12 +13,15 @@ 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: +Radio rules default to the two flood routes: - `0x00` / `ROUTE_TYPE_TRANSPORT_FLOOD` - flood routing with transport codes - `0x01` / `ROUTE_TYPE_FLOOD` - unscoped flood routing -Direct routes `0x02` and `0x03` are never affected by these rules. +Generalized rules can opt into relayed direct routes (`0x02` and `0x03`) with +`route=direct`, or both route classes with `route=any`. Scope rewriting, +source-path matching, and the legacy moderation table remain flood-only. +Local delivery and requests addressed to this node are not relay decisions. The route and payload values follow the upstream [packet-format reference](https://docs.meshcore.io/packet_format/) and [payload layouts](https://docs.meshcore.io/payloads/), with this fork's LoRa @@ -163,7 +166,7 @@ get flood.moderation ``` `flood.rule` is an alias for `flood.filter`, not another table. Generalized -repeater FPF7 has 63 forward-rule slots plus scope-rewrite and shared-blacklist +repeaters have 63 forward-rule slots plus scope-rewrite and shared-blacklist sections in the same atomic policy file. FULL room servers have 31 forward slots and empty repeater-only sections. Compact target profiles retain their separate FPF6-era controls. `flood.moderation` has 16 slots. A new @@ -195,6 +198,10 @@ del flood.channel.scope. del flood.channel.scope all ``` +Use `fs` in place of `flood.channel.scope` for any of these commands. Its +optional fields also accept `p=bl` for `path=blacklist`, `p=b1` through `p=b6` +for `path=bucket:1` through `path=bucket:6`, and `tx=s`/`tx=f` for slow/fast. + 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 @@ -327,7 +334,7 @@ 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. -On generalized builds these records are the FPF7 rewrite phase, and the file +On generalized builds these records are the shared rewrite phase, and the file stores only through the highest occupied slot. Compact FPF6 builds retain the standalone FCS5 file and the file sizes described by their build profile. @@ -346,6 +353,9 @@ del flood.channel.scope.require. del flood.channel.scope.require all ``` +`fsr` is the short name for `flood.channel.scope.require`, with the same +arguments, slots, and permissions. + An empty table preserves the normal global region behavior. Once at least one row exists, a group-channel packet that authenticates against a listed key must arrive as `ROUTE_TYPE_TRANSPORT_FLOOD` with a transport code matching a locally @@ -414,9 +424,12 @@ On repeaters with the rule engine enabled and on FULL-profile ESP32 room servers, `fr`, `flood.rule`, and `flood.filter` name 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. +OTA or reboot. Existing `flood.filter` commands remain compatible. FPF6, +FPF7, and FPF8 files are accepted; FPF1-FPF5 files are rejected and filtering +fails open. New sender/text, payload-length, route, probability, enabled-state, +or extended-path fields are stored in FPF8. Tables using only older fields +retain FPF7. Firmware without FPF8 support cannot load an FPF8 table; remove +the new fields before downgrading and keep a copy of the rules. Older firmware cannot preserve an FPF7 table containing the packed `retry` action, one-byte `hash:XX` matcher, or `mode=bridge`/`cross` flags; remove those rows before downgrading. Old firmware rejects such a table and fails open, @@ -439,7 +452,7 @@ of FPF7. Normal ordering applies, so a matching higher-priority `stop` row can exempt selected traffic. The compact rule list marks the managed row with `~data`. -FPF7 binds `in=region:` and `region=` to canonical region names, +FPF7 and FPF8 bind `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 @@ -450,7 +463,9 @@ The extended form is: ```text set flood.rule[.] type= [mode=radio|bridge|cross|bridge,cross] [hops=] [channel=] [hashbytes=any|1|2|2+|3] - [prefix=] [in=] [rate=/min] + [prefix=|path=[^]ID>ID...[$]] [in=] + [sender=] [text=] [len=] [route=flood|direct|any] + [prob=<1-100>] [enabled=on|off] [rate=/min] [priority=<0-255>] [stop] [tx=fast|slow] [suspend=tempradio] get flood.rule get flood.rule. @@ -510,8 +525,34 @@ Match fields: - `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. + the first received path entry. Its short name is `p=`. +- `path=` or `pa=` matches an ordered chain of up to four IDs anywhere in a + flood path. Separate IDs with `>` or `,`; `^` anchors the first entry and + `$` the last. Thus `pa=A1>B2` finds adjacent entries anywhere, + `pa=A1$` selects the most recent relay, and `pa=^A1>B2$` requires exactly + those two entries. Each ID must use the packet's full 1-, 2-, or 3-byte + width. Direct paths and TRACE SNR bytes never match. `path=blacklist` + retains its separate unordered-list behavior. + + Saved older prefix rules keep their start-anchored meaning. When retyping an + older `path=A1,B2` command, use `prefix=A1,B2` or `pa=^A1>B2` to retain it. +- `sender=` (`n=`) and `text=` (`x=`) match decrypted group text. An explicit + authenticated channel (`public`, `#name`, or a channel key) is required; + a raw channel hash is insufficient. See [content patterns](#content-patterns). +- `len=` (`l=`) matches payload bytes, using `N`, `N+`, or inclusive `N-M`. + It excludes the route/path envelope; encrypted padding is part of the payload. +- `route=` (`rt=`) selects `flood`, `direct`, or `any`; `f`, `d`, and `a` are + short values. Omitted radio rules remain flood-only. Direct/any rows may + drop, rate-limit, or stop; scope/region rewriting, retry, and TX timing + require flood-only radio rows. +- `prob=` (`pr=`) applies this rule to approximately that percentage of + otherwise-matching packets. Values are `1-100`; omitted means `100`. + A failed roll lets later rules run. This is sampling, not a delivery quota. + The decision stays the same for the same packet and row during one boot, + including repeated timing checks and scope rewrites. +- `enabled=off` (`e=off`) retains a row without evaluating it. `on`/`1` + enables and `off`/`0` disables. Change only this state with + `set fr. e=off` and `set fr. e=on`; the rest of the row stays saved. - `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 @@ -549,12 +590,52 @@ Actions: raw hash, then wildcard) before slot order. `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. + `allow`, `forward`, and `a` are aliases for the same stop action; they + do not bypass higher-priority drops, hard forwarding gates, or moderation. -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. A `hash:XX` row deliberately skips authentication and uses only the -one visible byte. +### Content patterns + +Sender/text patterns are case-sensitive byte matches. They search anywhere +unless anchored with `^` and `$`. Supported forms include `.`, `[A-Z]`, +`[^0-9]`, `*`, `+`, `?`, grouping, and `|` alternatives. Escape punctuation +with `\` to match it literally; use `[0-9]` for digits. Each pattern is at +most 63 bytes. Quote values containing spaces. + +For `Alice: BEACON 123`, `n=^Alice$` matches the display name and +`x=^BEACON` matches the body. They can be combined in one row. The parser +uses the complete fields, including names longer than 31 bytes, and removes +AES zero padding before matching. A message without `:` has no sender field; +a text rule can still inspect its body. Wrong keys and non-text payloads do +not match content rules. Pattern matching shares a 131,072-operation budget +across the rules in one evaluation. Exhausting it rejects the packet rather +than granting an unverified allow, rewrite, or retry action. Keep patterns +short and specific. + +```text +# Block BEACON messages on this channel. +set fr grp_txt c=#local x="^BEACON" d + +# Exempt Alice from lower-order general rules, then block everyone else. +set fr grp_txt c=#local n="^Alice$" pri=200 a +set fr grp_txt c=#local d + +# Temporarily disable an occupied row without deleting its settings. +set fr.4 e=off +set fr.4 e=on + +# Block floods whose most recent relay has this 3-byte ID. +set fr any pa=A1B2C3$ d +``` + +The allow exception remains subject to separate moderation and normal +forwarding gates. A sender name is freely chosen message text, not a verified +public-key identity. Legacy `flood.moderation` keeps its existing exact, +case-insensitive name matching. + +Rows using the same channel key reuse the initial authentication verdict. +Content patterns also decrypt into temporary scratch for matching. Neither +the verdict cache nor plaintext survives the receive evaluation. A `hash:XX` +row skips authentication and uses only the one visible byte. The global retry controls remain hard gates. A `retry` row does not override a zero `flood.retry.count`, path/type attempt caps, disabled forwarding, @@ -696,6 +777,11 @@ set fr.3 any c=#wardriving m=bc d | `mode=radio`, `bridge`, `cross`, `bridge,cross` | `m=r`, `m=b`, `m=c`, `m=bc` | Select the admission path. Omitted means radio; `cb`, `b,c`, and `c,b` also mean both transport paths. | | `channel=...` | `c=...` | Same authenticated name/key or explicit `hash:XX` matcher. | | `prefix=...` | `p=...` | Ordered path prefix. | +| `path=[^]ID>ID...[$]` | `pa=...` | Ordered chain anywhere, or anchored at either end. | +| `sender=...`, `text=...` | `n=...`, `x=...` | Case-sensitive patterns on an authenticated group-text channel. | +| `len=N`, `N+`, or `N-M` | `l=...` | Payload-byte range. | +| `route=flood`, `direct`, `any` | `rt=f`, `rt=d`, `rt=a` | Route class. | +| `prob=N`, `enabled=on/off` | `pr=N`, `e=1/0` | Sample matches or retain a disabled row. | | `hashbytes=any`, `1`, `2`, `2+`, or `3` | `pb=*`, `1`, `2`, `2+`, or `3` | Encoded path-hash width, even with zero hops; `2+` matches 2 and 3. | | `path=blacklist` | `p=bl` | Shared unordered blacklist; repeater only. | | `in=any`, `none`, `scoped`, `allowed`, `unknown` | `i=*`, `i=n`, `i=s`, `i=a`, `i=u` | Incoming scope condition. | @@ -705,6 +791,7 @@ set fr.3 any c=#wardriving m=bc d | `rate=N/min` | `q=N` | Per-row forwarding allowance. | | `priority=N` | `pri=N` | Higher values run first. | | `stop`, `retry` | `s`, `r` | Stop later rules or allow the configured flood retry. | +| `allow`, `forward` | `a` | Same action as `stop`; other forwarding gates still apply. | | `tx=slow`, `suspend=tempradio`, `retry` | `f=s`, `f=t`, `f=r` | Flags can combine, for example `f=str`. | The `=` distinguishes `s=name` (scope) from `s` (stop), and `r=name` @@ -752,9 +839,21 @@ the last rule holding that key, supply the original key instead. An unknown or ambiguous reference is rejected; it never becomes an unauthenticated channel-hash match. -The shorthand does not rename `flood.channel.scope`, `flood.moderation`, or -the blacklist-management commands. Companions and compact FPF6 profiles do -not expose `fr`. +Other repeater families have short names too, with identical arguments, +slot numbers, validation, persistence, and permissions: + +| Full family | Short family | Commands | +| --- | --- | --- | +| `flood.moderation` | `fm` | `get`, `set`, `del` | +| `flood.channel.scope` | `fs` | `get`, `set`, `del` | +| `flood.channel.scope.require` | `fsr` | `get`, `set`, `del` | +| `flood.filter.blacklist` | `fb` | `get`, `set`, `del` | +| `flood.advert` | `fa` | `get`, `clear` | + +For example, `get fa key 1` inspects a full advert key and `clear fa all` +clears the advert limiter history. Companions do not expose these repeater +families. Compact FPF6 profiles retain their compiled legacy commands and +their family aliases, but do not expose `fr` or the new generalized fields. ### Scope actions and legacy rules @@ -791,7 +890,7 @@ radio transmission, CAD, and airtime-budget limits are unchanged. On generalized repeaters, forward rules, `flood.channel.scope` rewrite rows, the shared blacklist, and the `flood.channel.data` compatibility state are one -atomic FPF7 policy image. Existing `/flood_ch_scope`, `/flood_filter_bl`, FPF6, +atomic FPF7/FPF8 policy image. Existing `/flood_ch_scope`, `/flood_filter_bl`, FPF6, and FCB2 data is imported once; the old files are removed only after the new image verifies and commits. Compact FPF6 repeaters retain separate files. Deleting the blacklist leaves `path=blacklist` rows in place but dormant until @@ -975,6 +1074,11 @@ Available actions are: - `path=H1[,H2,H3]` - require the first one to three path hashes to match - `path=*` - match every path; this is the default +`fm` is the short family name. Its option aliases are `d` for `drop`, +`q=N` for `rate=N/min`, `h=N` for `hops=N`, and `p=...` for `path=...`. +`q=N/min` and `q=N/m` are also accepted; mixed duplicate rate/drop options +are rejected just as in the full form. + At least one of `drop`, `rate=X/min`, or `hops=N` is required. Rate and hop limits can be combined. `rate=0/min` is equivalent to `drop`. @@ -995,8 +1099,17 @@ set flood.moderation #local "Noisy User" rate=10/min # Combine a rate limit with a maximum forwarding distance. set flood.moderation public alice rate=4/min hops=5 + +# Same rule using short names. +set fm public alice q=4 h=5 ``` +The wildcard sender `*` works with a hop restriction, including for names +longer than 31 bytes. Exact configured names remain limited to 31 bytes. +Moderation settings retain the FGM1 format and use staged writes, readback, +and backup recovery. A failed save is reported as unconfirmed because a +failure after publication can leave the new settings available on reboot. + ### Match the start of a path Path matching accepts one-, two-, or three-byte hashes. Every hash in one rule @@ -1056,7 +1169,8 @@ setperm 5 A filter manager can read non-secret operational status and manage `repeat`, `loop.detect`, `flood.max*`, `flood.channel.data*`, -`flood.filter*`, `flood.rule*`, `fr[.N]`, and `flood.moderation*`. Delegated `get` access uses an +`flood.filter*`, `flood.rule*`, `fr[.N]`, and `flood.moderation*`, including +the `fm`, `fb`, and `fa` aliases. 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 @@ -1071,7 +1185,19 @@ 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 from regions or direct hashtag names, and select the channels that -require valid incoming scopes. +require valid incoming scopes. The `fs` and `fsr` aliases retain this role. + +Admin CLI sessions and these delegated roles use the same setters and +validation as the serial console. On enrolled builds, the aliases also work +inside [signed fleet commands](fleet_management.md), for example: + +```text +fleet send 3 set fr.4 grp_txt c=public x="^BEACON" d +fleet send 3 set fr.4 e=off +``` + +Fleet enrollment, signature, expiry, replay, and command-size checks still +apply. An ordinary channel message cannot authorize a filter change. ## Security limitations @@ -1092,7 +1218,7 @@ The `mode=` matcher selects a separate admission phase within the same | Mode | Where the rule applies | | --- | --- | -| `radio` | Ordinary LoRa flood forwarding. This is the default when `mode=` is omitted. | +| `radio` | Ordinary LoRa forwarding; flood-only by default, or opt into direct routes with `route=`. This is the default mode. | | `bridge` | Packets entering or leaving an RS232 or ESP-NOW bridge, and MeshCore RX/TX packet publication through MQTT. | | `cross` | Transmissions copied to the other radio profile, including a copy's retries. The same-profile transmission remains eligible. | | `bridge,cross` | Both transport paths using one rule. `cross,bridge` is equivalent. | diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index f8d80d5a0..51959b33d 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -33,6 +33,7 @@ #include // std::nothrow (heap-allocated flood rule table) #include // for qsort() #include +#include #if defined(NRF52_POWER_MANAGEMENT) #include #endif @@ -192,6 +193,8 @@ static_assert(mesh::HostCliBridge::REMOTE_REPLY_MAX #define FLOOD_CHANNEL_SCOPE_REQUIRE_FILE "/flood_ch_req" #define FLOOD_CHANNEL_SCOPE_REQUIRE_TEMP_FILE "/flood_ch_req.tmp" #define FLOOD_GROUP_MODERATION_FILE "/flood_grp_mod" +#define FLOOD_GROUP_MODERATION_TEMP_FILE "/flood_grp_mod.tmp" +#define FLOOD_GROUP_MODERATION_BACKUP_FILE "/flood_grp_mod.bak" #define CLOCK_SYNC_PREFS_FILE "/clock_sync" #define DEFAULT_WARDRIVING_CHANNEL "#wardriving" static const char FLOOD_PACKET_FILTER_USAGE[] = @@ -1070,8 +1073,21 @@ bool MyMesh::floodChannelDataHopApplies(const mesh::Packet* packet) const { } bool MyMesh::allowPacketForward(const mesh::Packet *packet) { + if (!packet) return false; if (_prefs.disable_fwd && !(_prefs.trace_when_repeat_off == 1 && packet && packet->isRouteDirect() && packet->getPayloadType() == PAYLOAD_TYPE_TRACE)) return false; +#if !defined(PORTABLE_MQTT_OBSERVER) && MESH_ENABLE_FLOOD_RULE_ENGINE + uint64_t matches = recv_pkt_filter_match_mask; + if (packet->isRouteDirect()) { + const bool scoped = packet->getRouteType() == ROUTE_TYPE_TRANSPORT_DIRECT; + RegionEntry* incoming_region = scoped + ? region_map.findMatch(packet, REGION_DENY_FLOOD) : NULL; + const bool incoming_allowed = scoped ? incoming_region != NULL + : (region_map.getWildcard().flags & REGION_DENY_FLOOD) == 0; + matches = evaluateFloodPacketFilterMatches(packet, incoming_allowed, incoming_region); + if (shouldBlockFloodPacketForward(packet, matches)) return false; + } +#endif if (packet->isRouteFlood()) { if (mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max, _prefs.flood_max_unscoped, @@ -1123,7 +1139,11 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { #if MESH_ENABLE_FLOOD_GROUP_MODERATION if (packet->isRouteFlood() && shouldBlockFloodGroupTextForward(packet)) return false; #endif +#if MESH_ENABLE_FLOOD_RULE_ENGINE + commitFloodPacketFilterRates(packet, matches); +#else if (packet->isRouteFlood()) commitFloodPacketFilterRates(packet, recv_pkt_filter_match_mask); +#endif // 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 @@ -3812,6 +3832,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc // OTA mesh-integration (receive/begin/loop) is centralized in mesh::Mesh - no per-example wiring. void MyMesh::begin(FILESYSTEM *fs) { +#if MESH_ENABLE_FLOOD_RULE_ENGINE + flood_filter_probability_seed = getRNG()->nextInt(0, UINT32_MAX); +#endif mesh::hilStartupTrace("mesh_dispatcher_begin"); mesh::Mesh::begin(); // also starts OTA (ota_ctx().begin) for all roles mesh::hilStartupTrace("mesh_dispatcher_ready"); @@ -6355,7 +6378,8 @@ bool MyMesh::loadFloodPacketFilters() { uint8_t count = 0; bool success = file.read(magic, sizeof(magic)) == sizeof(magic); bool version_6 = success && memcmp(magic, "FPF6", sizeof(magic)) == 0; - bool version_7 = success && memcmp(magic, "FPF7", sizeof(magic)) == 0; + bool version_8 = success && memcmp(magic, "FPF8", sizeof(magic)) == 0; + bool version_7 = version_8 || (success && memcmp(magic, "FPF7", sizeof(magic)) == 0); success = (version_6 || version_7) && file.read(&count, sizeof(count)) == sizeof(count); // A wider firmware image can write inactive trailing slots. Decode them @@ -6454,6 +6478,9 @@ bool MyMesh::loadFloodPacketFilters() { success = success && file.read(&stop_on_match, sizeof(stop_on_match)) == sizeof(stop_on_match); + if (success && version_8) { + success = FloodRuleExtensions::readFields(file, entry.extra); + } } else { entry.incoming_scope_kind = scope_requires_region_match ? FloodFilterPolicy::RULE_IN_ALLOWED @@ -6557,9 +6584,7 @@ bool MyMesh::loadFloodPacketFilters() { || entry.payload_type == PAYLOAD_TYPE_GRP_DATA; } - bool path_valid = FloodFilterPolicy::pathMatcherValid( - entry.path_hash_size, entry.path_hops, - entry.match_blacklisted_path); + bool path_valid = FloodRuleExtensions::validRuleFields(entry); bool action_valid = entry.drop_on_match || direct_target || region_target || entry.rate_limit_enabled || entry.stop_on_match || entry.retry_on_match; @@ -6827,7 +6852,8 @@ bool MyMesh::isFloodChannelDataRule( && !entry.rate_limit_enabled && entry.priority == 0 && !entry.stop_on_match - && !entry.retry_on_match; + && !entry.retry_on_match + && !FloodRuleExtensions::hasFields(entry.extra); } int MyMesh::findFloodChannelDataRule() const { @@ -7177,10 +7203,12 @@ bool MyMesh::saveFloodPacketFilters(bool empty_scope_phase, return true; }; - const uint8_t magic[4] = {'F', 'P', 'F', '7'}; const uint8_t count = FloodFilterPolicy::forwardPersistenceCount( flood_packet_filters, flood_packet_filter_slots, empty_forward_phase); + const bool version_8 = FloodRuleExtensions::tableUsesExtensions( + flood_packet_filters, count); + const uint8_t magic[4] = {'F', 'P', 'F', version_8 ? uint8_t('8') : uint8_t('7')}; bool success = writeExact(magic, sizeof(magic)) && writeExact(&count, sizeof(count)); for (int i = 0; success && i < count; i++) { @@ -7229,6 +7257,9 @@ bool MyMesh::saveFloodPacketFilters(bool empty_scope_phase, sizeof(entry.target_region_name)) && writeExact(&entry.priority, sizeof(entry.priority)) && writeExact(&stop_on_match, sizeof(stop_on_match)); + if (success && version_8) { + success = FloodRuleExtensions::writeFields(writeExact, entry.extra); + } } static_assert(FLOOD_CHANNEL_SCOPE_SLOTS <= 255, "FPF7 scope phase count is one byte"); @@ -7456,14 +7487,16 @@ bool MyMesh::floodPacketFilterFieldsMatch( bool incoming_region_allowed, const RegionEntry* incoming_region, uint8_t context) const { if (!entry.active || !FloodFilterPolicy::ruleModeMatches( - entry.transport_modes, context, packet)) return false; + entry.transport_modes, context, packet, entry.extra.route) + || !FloodRuleExtensions::matchesEnvelope(entry.extra, packet, context)) return false; if (!FloodFilterPolicy::channelKeyLengthSupported( entry.channel_key_len)) return false; if (entry.suspend_on_temp_radio && isAnyTempRadioActive()) return false; if (entry.match_blacklisted_path && !floodPacketFilterBlacklistMatches(packet)) { return false; } - if (!FloodFilterPolicy::pathStartsWith( + if (!FloodFilterPolicy::pathHashBytesMatch(entry.path_hash_size, packet->getPathHashSize()) + || !FloodRuleExtensions::pathMatches(entry.extra, packet, entry.path_hash_size, entry.path_hops, entry.path)) { return false; } @@ -7471,7 +7504,8 @@ bool MyMesh::floodPacketFilterFieldsMatch( 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) { + || (packet->isRouteFlood() ? hops < entry.min_hops || hops > entry.max_hops + : entry.min_hops != 0 || entry.max_hops != FLOOD_PACKET_FILTER_MAX_HOPS)) { return false; } @@ -7527,7 +7561,7 @@ bool MyMesh::authenticateFloodPacketFilterChannel( bool MyMesh::hasFloodPacketFilterRetryRules() const { for (int i = 0; i < flood_packet_filter_slots; i++) { - if (flood_packet_filters[i].active + if (flood_packet_filters[i].active && !flood_packet_filters[i].extra.disabled && flood_packet_filters[i].retry_on_match) return true; } return false; @@ -7598,9 +7632,9 @@ uint64_t MyMesh::applyFloodPacketFilterStop(uint64_t match_mask) { uint64_t MyMesh::evaluateFloodPacketFilterMatches( const mesh::Packet* packet, bool incoming_region_allowed, const RegionEntry* incoming_region, uint8_t context) { - static_assert(FLOOD_PACKET_FILTER_SLOTS <= 64, - "flood filter match mask supports at most 64 slots"); - if (packet == NULL || (context == FloodFilterPolicy::RULE_MODE_RADIO && !packet->isRouteFlood())) return 0; + static_assert(FLOOD_PACKET_FILTER_SLOTS < 64, + "flood filter mask reserves bit 63 for exhausted pattern work"); + if (packet == NULL) return 0; bool incoming_is_scoped = packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD || packet->getRouteType() == ROUTE_TYPE_TRANSPORT_DIRECT; @@ -7608,6 +7642,7 @@ uint64_t MyMesh::evaluateFloodPacketFilterMatches( ? 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 pattern_work = 131072; uint64_t matches = 0; for (int i = 0; i < flood_packet_filter_slots; i++) { const auto& entry = flood_packet_filters[i]; @@ -7636,7 +7671,18 @@ uint64_t MyMesh::evaluateFloodPacketFilterMatches( channel_auth_checked[i] = true; channel_auth_valid[i] = authenticated; } - if (authenticated) matches |= (uint64_t)1U << i; + bool budget_exceeded = false; + const bool content_matched = authenticated + && FloodRuleExtensions::matchesContent(entry.extra, + packet, entry.channel_secret, entry.channel_key_len, &pattern_work, + false, &budget_exceeded); + if (budget_exceeded) return uint64_t(1) << 63; + if (content_matched + && (entry.extra.probability == 0 || entry.extra.probability == 100 + || FloodRuleExtensions::matchesProbability( + entry.extra, FloodRuleExtensions::probabilityRoll( + packet, flood_filter_probability_seed, i, context)))) + matches |= (uint64_t)1U << i; } return applyFloodPacketFilterStop(matches); } @@ -7692,6 +7738,7 @@ bool MyMesh::applyFloodPacketFilterScope(mesh::Packet* packet, bool MyMesh::shouldBlockFloodPacketForward(const mesh::Packet* packet, uint64_t match_mask) const { if (packet == NULL) return false; + if (match_mask & (uint64_t(1) << 63)) return true; uint8_t type = packet->getPayloadType(); uint8_t hops = packet->getPathHashCount(); @@ -7852,7 +7899,7 @@ void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_ return; } const auto& entry = flood_packet_filters[index]; - if (compact) { + if (compact || FloodRuleExtensions::hasFields(entry.extra) || entry.path_hops > 3) { FloodRuleCLI::formatCompact(reply, reply_len, index + 1, entry); return; } @@ -7951,6 +7998,28 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply, bool compac const char* selector = skipFloodFilterSpaces(args); if (*selector == '.') selector = skipFloodFilterSpaces(selector + 1); if (*selector != 0) { + const char* field = strchr(selector, ' '); + if (field) { + char slot_text[4]; + const size_t length = size_t(field - selector); + uint8_t slot = 0; + if (!length || length >= sizeof(slot_text)) { + strcpy(reply, "Err - invalid rule slot"); return; + } + memcpy(slot_text, selector, length); slot_text[length] = 0; + field = skipFloodFilterSpaces(field); + if (!parseFloodFilterUnsigned(slot_text, flood_packet_filter_slots, slot) + || !slot || !flood_packet_filters[slot - 1].active) { + strcpy(reply, "Err - rule slot is empty"); + } else if (floodFilterAsciiEqual(field, "n") || floodFilterAsciiEqual(field, "sender")) { + snprintf(reply, 160, "> %s", flood_packet_filters[slot - 1].extra.sender); + } else if (floodFilterAsciiEqual(field, "x") || floodFilterAsciiEqual(field, "text")) { + snprintf(reply, 160, "> %s", flood_packet_filters[slot - 1].extra.text); + } else { + strcpy(reply, "Err - field is sender/n or text/x"); + } + return; + } 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); @@ -7987,7 +8056,7 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply, bool compac snprintf(priority, sizeof(priority), "^%u", (unsigned int)entry.priority); } - snprintf(item, sizeof(item), " %d=%s~%s@%s%s%s%s%s%s%s%s%s%s", + snprintf(item, sizeof(item), " %d=%s~%s@%s%s%s%s%s%s%s%s%s%s%s", i + 1, floodFilterPayloadTypeName(entry.payload_type), FloodFilterPolicy::ruleModeName(entry.transport_modes), hops, entry.match_blacklisted_path ? "?blacklist" : "", @@ -7997,7 +8066,8 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply, bool compac entry.retry_on_match ? "~retry" : "", entry.scope_uses_slow_timing ? "~slow" : "", entry.suspend_on_temp_radio ? "~tempradio" : "", - i == findFloodChannelDataRule() ? "~data" : ""); + i == findFloodChannelDataRule() ? "~data" : "", + entry.extra.disabled ? "~off" : ""); size_t item_len = strlen(item); if (used + item_len >= 156) { truncated = true; @@ -8041,6 +8111,32 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, } } cursor = skipFloodFilterSpaces(cursor); + if (requested_slot >= 0) { + const char* state = cursor; + if (!strncmp(state, "enabled=", 8)) state += 8; + else if (!strncmp(state, "e=", 2)) state += 2; + if (state != cursor || !strcmp(state, "on") || !strcmp(state, "off")) { + uint8_t disabled; + if (!FloodRuleExtensions::enabledValue(state, disabled)) { + strcpy(reply, "Err - enabled is on or off"); return; + } + auto& entry = flood_packet_filters[requested_slot]; + if (!entry.active) { strcpy(reply, "Err - empty filter slot"); return; } + const uint8_t previous = entry.extra.disabled; + const uint8_t previous_channel_data_slot = flood_channel_data_rule_slot; + entry.extra.disabled = disabled; + // Editing the compatibility row through fr gives it the same ordinary + // rule ownership as replacing that row through the full setter. + if (previous != disabled && requested_slot == flood_channel_data_rule_slot) + flood_channel_data_rule_slot = 0xFF; + if (previous != disabled && !saveFloodPacketFilters()) { + entry.extra.disabled = previous; + flood_channel_data_rule_slot = previous_channel_data_slot; + strcpy(reply, "Err - filter save unconfirmed; reboot may differ"); return; + } + strcpy(reply, disabled ? "OK - rule disabled" : "OK - rule enabled"); return; + } + } char params[256]; if (strlen(cursor) >= sizeof(params)) { strcpy(reply, "Err - rule parameters too long"); @@ -8048,22 +8144,9 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, } 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) { + char* tokens[26]; + const int token_count = FloodRuleExtensions::tokenize(params, tokens, 26); + if (token_count <= 0) { strcpy(reply, FLOOD_PACKET_FILTER_USAGE); return; } @@ -8117,7 +8200,12 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, bool retry_on_match = false; uint8_t transport_modes = FloodFilterPolicy::RULE_MODE_RADIO; bool mode_set = false; + FloodRuleExtensions::Fields extra{}; + uint8_t extra_seen = 0; for (int i = 1; i < token_count; i++) { + const int extended = FloodRuleExtensions::parseToken(tokens[i], extra, extra_seen, reply); + if (extended < 0) return; + if (extended > 0) continue; if (floodFilterAsciiStartsWith(tokens[i], "mode=") || floodFilterAsciiStartsWith(tokens[i], "m=")) { if (mode_set) { @@ -8312,6 +8400,15 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, StrHelper::strzcpy(target_region_name, region->name, sizeof(target_region_name)); target_set = true; + } else if (floodFilterAsciiEqual(tokens[i], "allow") + || floodFilterAsciiEqual(tokens[i], "forward") + || floodFilterAsciiEqual(tokens[i], "a") + || floodFilterAsciiEqual(tokens[i], "action=allow") + || floodFilterAsciiEqual(tokens[i], "action=forward")) { + if (stop_on_match) { + strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE); return; + } + stop_on_match = true; } else if (floodFilterAsciiEqual(tokens[i], "drop") || floodFilterAsciiEqual(tokens[i], "d") || floodFilterAsciiEqual(tokens[i], "action=drop")) { @@ -8398,6 +8495,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, hashbytes_set = true; } else if (floodFilterAsciiStartsWith(tokens[i], "prefix=") || floodFilterAsciiStartsWith(tokens[i], "p=") + || floodFilterAsciiStartsWith(tokens[i], "pa=") || (floodFilterAsciiStartsWith(tokens[i], "path=") && !floodFilterAsciiEqual(tokens[i], "path=blacklist"))) { if (path_set) { @@ -8405,12 +8503,11 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, 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"); + const bool legacy_prefix = floodFilterAsciiStartsWith(tokens[i], "prefix=") + || floodFilterAsciiStartsWith(tokens[i], "p="); + if (!FloodRuleExtensions::parsePath(value, path_hash_size, path_hops, + path, extra, legacy_prefix)) { + strcpy(reply, "Err - path is [^]ID>...[$], up to four 1/2/3-byte IDs"); return; } path_set = true; @@ -8503,8 +8600,13 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, candidate.stop_on_match = stop_on_match; candidate.retry_on_match = retry_on_match; candidate.transport_modes = transport_modes; + candidate.extra = extra; StrHelper::strzcpy(candidate.scope_name, scope_name, sizeof(candidate.scope_name)); + if (!FloodRuleExtensions::validRuleFields(candidate)) { + strcpy(reply, "Err - content needs a keyed group-text channel; scope/retry needs flood route"); + return; + } int slot = requested_slot; if (slot < 0) { @@ -8553,7 +8655,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, snprintf(reply, 160, "OK - rule %d saved (details exceed reply size)", slot + 1); return; } - snprintf(reply, 160, "OK - %s", detail[0] == '>' ? skipFloodFilterSpaces(detail + 1) : detail); + snprintf(reply, 160, "OK - %.154s", detail[0] == '>' ? skipFloodFilterSpaces(detail + 1) : detail); } #else void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len, bool compact) const { @@ -9517,28 +9619,34 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) { while ((option_result = takeFloodModerationToken(cursor, option, sizeof(option))) == 1) { if (floodFilterAsciiEqual(option, "tx=slow") - || floodFilterAsciiEqual(option, "tx=fast")) { + || floodFilterAsciiEqual(option, "tx=fast") + || floodFilterAsciiEqual(option, "tx=s") + || floodFilterAsciiEqual(option, "tx=f")) { if (timing_set) { strcpy(reply, "Err - duplicate tx timing"); return; } timing_set = true; - slow_timing = floodFilterAsciiEqual(option, "tx=slow"); - } else if (floodFilterAsciiEqual(option, "path=blacklist")) { + slow_timing = floodFilterAsciiEqual(option, "tx=slow") + || floodFilterAsciiEqual(option, "tx=s"); + } else if (floodFilterAsciiEqual(option, "path=blacklist") + || floodFilterAsciiEqual(option, "p=bl")) { if (path_set) { strcpy(reply, "Err - duplicate path matcher"); return; } path_set = true; path_selector = FloodFilterPolicy::SCOPE_PATH_BLACKLIST; - } else if (strncmp(option, "path=bucket:", 12) == 0) { + } else if (strncmp(option, "path=bucket:", 12) == 0 + || strncmp(option, "p=b", 3) == 0) { if (path_set) { strcpy(reply, "Err - duplicate path matcher"); return; } uint8_t bucket = 0; if (!parseFloodFilterUnsigned( - option + 12, FLOOD_RETRY_BRIDGE_BUCKETS, bucket) + option + (option[0] == 'p' && option[1] == '=' ? 3 : 12), + FLOOD_RETRY_BRIDGE_BUCKETS, bucket) || bucket == 0) { strcpy(reply, "Err - path bucket must be 1-6"); return; @@ -9959,36 +10067,45 @@ static bool floodModerationPathMatches(const mesh::Packet* packet, uint8_t hash_ return memcmp(packet->path, path, path_hops * hash_size) == 0; } -void MyMesh::loadFloodGroupModeration() { - memset(flood_group_moderation, 0, sizeof(flood_group_moderation)); - if (_fs == NULL || !_fs->exists(FLOOD_GROUP_MODERATION_FILE)) return; - File file = openFloodSettingsRead(_fs, FLOOD_GROUP_MODERATION_FILE); - if (!file) return; - - FloodGroupModerationEntry loaded[FLOOD_GROUP_MODERATION_SLOTS]; - memset(loaded, 0, sizeof(loaded)); +bool MyMesh::readFloodGroupModerationFile( + const char* path, FloodGroupModerationEntry* entries, bool* readable) const { + if (readable) *readable = false; + if (_fs == NULL) return false; + if (!_fs->exists(path)) { + if (readable) *readable = true; + return false; + } + File file = openFloodSettingsRead(_fs, path); + if (!file) return false; + if (entries) memset(entries, 0, sizeof(FloodGroupModerationEntry) * FLOOD_GROUP_MODERATION_SLOTS); uint8_t magic[4]; uint8_t count = 0; - bool success = file.read(magic, sizeof(magic)) == sizeof(magic) + bool complete = file.size() < 5 + || (file.read(magic, sizeof(magic)) == sizeof(magic) + && file.read(&count, sizeof(count)) == sizeof(count)); + bool success = file.size() >= 5 && complete && memcmp(magic, "FGM1", sizeof(magic)) == 0 - && file.read(&count, sizeof(count)) == sizeof(count) - && count <= FLOOD_GROUP_MODERATION_SLOTS; + && count <= FLOOD_GROUP_MODERATION_SLOTS + && file.size() == 5U + count * (2U + PUB_KEY_SIZE + + 2U * FLOOD_GROUP_MODERATION_NAME_LEN + 3U + + FLOOD_GROUP_MODERATION_PATH_BYTES_MAX + sizeof(uint16_t)); + FloodGroupModerationEntry scratch; for (int i = 0; success && i < count; i++) { - auto& entry = loaded[i]; + auto& entry = entries ? entries[i] : scratch; + memset(&entry, 0, sizeof(entry)); uint8_t active = 0; - success = file.read(&active, sizeof(active)) == sizeof(active); - success = success && file.read(&entry.key_len, sizeof(entry.key_len)) == sizeof(entry.key_len); - success = success && file.read(entry.secret, sizeof(entry.secret)) == sizeof(entry.secret); - success = success && file.read((uint8_t*)entry.channel_name, sizeof(entry.channel_name)) == sizeof(entry.channel_name); - success = success && file.read((uint8_t*)entry.sender, sizeof(entry.sender)) == sizeof(entry.sender); - success = success && file.read(&entry.path_hash_size, sizeof(entry.path_hash_size)) == sizeof(entry.path_hash_size); - success = success && file.read(&entry.path_hops, sizeof(entry.path_hops)) == sizeof(entry.path_hops); - success = success && file.read(entry.path, sizeof(entry.path)) == sizeof(entry.path); - success = success && file.read(&entry.max_hops, sizeof(entry.max_hops)) == sizeof(entry.max_hops); - success = success && file.read((uint8_t*)&entry.rate_per_minute, sizeof(entry.rate_per_minute)) == sizeof(entry.rate_per_minute); - entry.channel_name[sizeof(entry.channel_name) - 1] = 0; - entry.sender[sizeof(entry.sender) - 1] = 0; + complete = file.read(&active, sizeof(active)) == sizeof(active) + && file.read(&entry.key_len, sizeof(entry.key_len)) == sizeof(entry.key_len) + && file.read(entry.secret, sizeof(entry.secret)) == sizeof(entry.secret) + && file.read((uint8_t*)entry.channel_name, sizeof(entry.channel_name)) == sizeof(entry.channel_name) + && file.read((uint8_t*)entry.sender, sizeof(entry.sender)) == sizeof(entry.sender) + && file.read(&entry.path_hash_size, sizeof(entry.path_hash_size)) == sizeof(entry.path_hash_size) + && file.read(&entry.path_hops, sizeof(entry.path_hops)) == sizeof(entry.path_hops) + && file.read(entry.path, sizeof(entry.path)) == sizeof(entry.path) + && file.read(&entry.max_hops, sizeof(entry.max_hops)) == sizeof(entry.max_hops) + && file.read((uint8_t*)&entry.rate_per_minute, sizeof(entry.rate_per_minute)) == sizeof(entry.rate_per_minute); entry.active = active != 0; + success = complete && active <= 1; if (success && entry.active) { bool path_valid = (entry.path_hops == 0 && entry.path_hash_size == 0) || (entry.path_hops >= 1 && entry.path_hops <= FLOOD_GROUP_MODERATION_PATH_HOPS_MAX @@ -9996,6 +10113,8 @@ void MyMesh::loadFloodGroupModeration() { bool hops_valid = entry.max_hops == FLOOD_GROUP_MODERATION_HOPS_ALL || entry.max_hops <= FLOOD_PACKET_FILTER_MAX_HOPS; success = (entry.key_len == CIPHER_KEY_SIZE || entry.key_len == PUB_KEY_SIZE) + && memchr(entry.channel_name, 0, sizeof(entry.channel_name)) != NULL + && memchr(entry.sender, 0, sizeof(entry.sender)) != NULL && entry.channel_name[0] != 0 && floodModerationSenderNameValid(entry.sender) && path_valid && hops_valid; if (success) { @@ -10004,34 +10123,114 @@ void MyMesh::loadFloodGroupModeration() { } } file.close(); - // As with the general table, malformed persistence fails open. - if (success) memcpy(flood_group_moderation, loaded, sizeof(flood_group_moderation)); + if (readable) *readable = complete; + return success; +} + +bool MyMesh::recoverFloodGroupModeration() { + if (_fs == NULL) return false; + bool readable = false; + if (readFloodGroupModerationFile(FLOOD_GROUP_MODERATION_FILE, NULL, &readable)) { + // A complete public image wins over uncommitted scratch and an old backup. + if (_fs->exists(FLOOD_GROUP_MODERATION_TEMP_FILE) + && !_fs->remove(FLOOD_GROUP_MODERATION_TEMP_FILE)) return false; + if (_fs->exists(FLOOD_GROUP_MODERATION_BACKUP_FILE) + && !_fs->remove(FLOOD_GROUP_MODERATION_BACKUP_FILE)) return false; + return true; + } + if (!readable) return false; + const char* candidate = NULL; + if (readFloodGroupModerationFile(FLOOD_GROUP_MODERATION_BACKUP_FILE, NULL, &readable)) { + candidate = FLOOD_GROUP_MODERATION_BACKUP_FILE; + } else { + if (!readable) return false; + if (readFloodGroupModerationFile(FLOOD_GROUP_MODERATION_TEMP_FILE, NULL, &readable)) + candidate = FLOOD_GROUP_MODERATION_TEMP_FILE; + else if (!readable) return false; + } + if (candidate) { + if (_fs->exists(FLOOD_GROUP_MODERATION_FILE) + && !_fs->remove(FLOOD_GROUP_MODERATION_FILE)) return false; + _fs->rename(candidate, FLOOD_GROUP_MODERATION_FILE); + if (!readFloodGroupModerationFile(FLOOD_GROUP_MODERATION_FILE, NULL)) return false; + } + // Invalid files fail open, but must not prevent an administrator repairing + // the table. Never discard a complete candidate after failed promotion. + if (_fs->exists(FLOOD_GROUP_MODERATION_TEMP_FILE) + && !_fs->remove(FLOOD_GROUP_MODERATION_TEMP_FILE)) return false; + if (_fs->exists(FLOOD_GROUP_MODERATION_BACKUP_FILE) + && !_fs->remove(FLOOD_GROUP_MODERATION_BACKUP_FILE)) return false; + if (!candidate && _fs->exists(FLOOD_GROUP_MODERATION_FILE) + && !_fs->remove(FLOOD_GROUP_MODERATION_FILE)) return false; + return true; +} + +void MyMesh::loadFloodGroupModeration() { + memset(flood_group_moderation, 0, sizeof(flood_group_moderation)); + if (_fs == NULL) return; + recoverFloodGroupModeration(); + // A recovery failure can still leave a valid public or backup image. Use + // it for this boot without replacing the sole durable recovery source. + if (!readFloodGroupModerationFile(FLOOD_GROUP_MODERATION_FILE, flood_group_moderation) + && !readFloodGroupModerationFile(FLOOD_GROUP_MODERATION_BACKUP_FILE, flood_group_moderation)) { + memset(flood_group_moderation, 0, sizeof(flood_group_moderation)); + } } bool MyMesh::saveFloodGroupModeration() { - if (_fs == NULL) return false; - File file = openFloodSettingsWrite(_fs, FLOOD_GROUP_MODERATION_FILE); + if (!recoverFloodGroupModeration()) return false; + File file = openFloodSettingsWrite(_fs, FLOOD_GROUP_MODERATION_TEMP_FILE); if (!file) return false; + size_t size = 0; + uint32_t hash = 2166136261UL; + auto writeExact = [&file, &size, &hash](const void* data, size_t length) { + if (file.write((const uint8_t*)data, length) != length) return false; + hash = updateFloodSettingsHash(hash, (const uint8_t*)data, length); + size += length; + return true; + }; const uint8_t magic[4] = {'F', 'G', 'M', '1'}; uint8_t count = FLOOD_GROUP_MODERATION_SLOTS; - bool success = file.write(magic, sizeof(magic)) == sizeof(magic) - && file.write(&count, sizeof(count)) == sizeof(count); + bool success = writeExact(magic, sizeof(magic)) && writeExact(&count, sizeof(count)); for (int i = 0; success && i < FLOOD_GROUP_MODERATION_SLOTS; i++) { const auto& entry = flood_group_moderation[i]; uint8_t active = entry.active ? 1 : 0; - 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(entry.secret, sizeof(entry.secret)) == sizeof(entry.secret); - success = success && file.write((const uint8_t*)entry.channel_name, sizeof(entry.channel_name)) == sizeof(entry.channel_name); - success = success && file.write((const uint8_t*)entry.sender, sizeof(entry.sender)) == sizeof(entry.sender); - 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(&entry.max_hops, sizeof(entry.max_hops)) == sizeof(entry.max_hops); - success = success && file.write((const uint8_t*)&entry.rate_per_minute, sizeof(entry.rate_per_minute)) == sizeof(entry.rate_per_minute); + success = writeExact(&active, sizeof(active)) + && writeExact(&entry.key_len, sizeof(entry.key_len)) + && writeExact(entry.secret, sizeof(entry.secret)) + && writeExact(entry.channel_name, sizeof(entry.channel_name)) + && writeExact(entry.sender, sizeof(entry.sender)) + && 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(&entry.max_hops, sizeof(entry.max_hops)) + && writeExact(&entry.rate_per_minute, sizeof(entry.rate_per_minute)); } + file.flush(); file.close(); - return success; + if (!success || !verifyFloodSettingsWrite(_fs, FLOOD_GROUP_MODERATION_TEMP_FILE, size, hash) + || !readFloodGroupModerationFile(FLOOD_GROUP_MODERATION_TEMP_FILE, NULL)) { + _fs->remove(FLOOD_GROUP_MODERATION_TEMP_FILE); + return false; + } + if (_fs->exists(FLOOD_GROUP_MODERATION_FILE)) { + _fs->rename(FLOOD_GROUP_MODERATION_FILE, FLOOD_GROUP_MODERATION_BACKUP_FILE); + if (_fs->exists(FLOOD_GROUP_MODERATION_FILE) + || !readFloodGroupModerationFile(FLOOD_GROUP_MODERATION_BACKUP_FILE, NULL)) { + _fs->remove(FLOOD_GROUP_MODERATION_TEMP_FILE); + return false; + } + } + _fs->rename(FLOOD_GROUP_MODERATION_TEMP_FILE, FLOOD_GROUP_MODERATION_FILE); + if (!verifyFloodSettingsWrite(_fs, FLOOD_GROUP_MODERATION_FILE, size, hash) + || !readFloodGroupModerationFile(FLOOD_GROUP_MODERATION_FILE, NULL)) { + // Preserve both candidates when publication cannot be confirmed. Recovery + // selects the complete public image, otherwise the previous backup. + return false; + } + if (_fs->exists(FLOOD_GROUP_MODERATION_BACKUP_FILE)) + _fs->remove(FLOOD_GROUP_MODERATION_BACKUP_FILE); + return true; } #endif @@ -10039,35 +10238,36 @@ bool MyMesh::saveFloodGroupModeration() { bool MyMesh::decodeFloodGroupPlainText(const mesh::Packet* packet, const uint8_t* secret, uint8_t key_len, uint32_t& timestamp, char* sender, size_t sender_len) const { if (sender != NULL && sender_len > 0) sender[0] = 0; - if (packet == NULL || secret == NULL || sender == NULL || sender_len < 2 + if (sender == NULL || sender_len < 2) return false; + uint8_t data[MAX_PACKET_PAYLOAD]; + mesh::FloodGroupPlainText::View view; + if (!decodeFloodGroupPlainTextSpan(packet, secret, key_len, data, sizeof(data), view) + || !view.has_sender || view.sender.size >= sender_len) return false; + timestamp = view.timestamp; + memcpy(sender, view.sender.data, view.sender.size); + sender[view.sender.size] = 0; + return true; +} + +bool MyMesh::decodeFloodGroupPlainTextSpan( + const mesh::Packet* packet, const uint8_t* secret, uint8_t key_len, + uint8_t* buffer, size_t capacity, mesh::FloodGroupPlainText::View& view) const { + view = mesh::FloodGroupPlainText::View{}; + if (packet == NULL || secret == NULL || buffer == NULL || !packet->isRouteFlood() || packet->getPayloadType() != PAYLOAD_TYPE_GRP_TXT || (key_len != CIPHER_KEY_SIZE && key_len != PUB_KEY_SIZE) - || packet->payload_len <= PATH_HASH_SIZE + CIPHER_MAC_SIZE) return false; + || 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 + || capacity < size_t(packet->payload_len - PATH_HASH_SIZE - CIPHER_MAC_SIZE)) return false; uint8_t channel_hash = 0; mesh::Utils::sha256(&channel_hash, sizeof(channel_hash), secret, key_len); if (packet->payload[0] != channel_hash) return false; - uint8_t data[MAX_PACKET_PAYLOAD]; - int len = mesh::Utils::MACThenDecrypt(secret, data, &packet->payload[PATH_HASH_SIZE], + int len = mesh::Utils::MACThenDecrypt(secret, buffer, &packet->payload[PATH_HASH_SIZE], packet->payload_len - PATH_HASH_SIZE); - if (len <= 5 || (data[4] >> 2) != TXT_TYPE_PLAIN) return false; - - memcpy(×tamp, data, sizeof(timestamp)); - const uint8_t* text = &data[5]; - size_t text_len = (size_t)len - 5; - const uint8_t* colon = (const uint8_t*)memchr(text, ':', text_len); - if (colon == NULL) return false; - - size_t parsed_len = (size_t)(colon - text); - while (parsed_len > 0 && text[parsed_len - 1] == ' ') parsed_len--; - if (parsed_len == 0 || parsed_len >= sender_len) return false; - for (size_t i = 0; i < parsed_len; i++) { - if (text[i] == '\r' || text[i] == '\n' || text[i] < 0x20) return false; - } - memcpy(sender, text, parsed_len); - sender[parsed_len] = 0; - return true; + return len > 0 && size_t(len) <= capacity + && mesh::FloodGroupPlainText::parse(buffer, size_t(len), TXT_TYPE_PLAIN, view); } #if MESH_ENABLE_FLOOD_GROUP_MODERATION @@ -10080,8 +10280,8 @@ bool MyMesh::shouldBlockFloodGroupTextForward(const mesh::Packet* packet) { uint8_t cached_key_len = 0; bool cache_present = false; bool cache_valid = false; - char decoded_sender[FLOOD_GROUP_MODERATION_NAME_LEN]; - decoded_sender[0] = 0; + uint8_t decrypted[MAX_PACKET_PAYLOAD]; + mesh::FloodGroupPlainText::View decoded; uint8_t hops = packet->getPathHashCount(); uint8_t rate_slots[FLOOD_GROUP_MODERATION_SLOTS]; uint8_t rate_slot_count = 0; @@ -10099,15 +10299,12 @@ bool MyMesh::shouldBlockFloodGroupTextForward(const mesh::Packet* packet) { cached_key_len = entry.key_len; memcpy(cached_secret, entry.secret, entry.key_len); cache_valid = false; - decoded_sender[0] = 0; - - uint32_t ignored_timestamp = 0; - cache_valid = decodeFloodGroupPlainText(packet, entry.secret, entry.key_len, - ignored_timestamp, decoded_sender, - sizeof(decoded_sender)); + cache_valid = decodeFloodGroupPlainTextSpan(packet, entry.secret, entry.key_len, + decrypted, sizeof(decrypted), decoded); } - if (!cache_valid - || (strcmp(entry.sender, "*") != 0 && !floodFilterAsciiEqual(entry.sender, decoded_sender))) continue; + if (!cache_valid || !decoded.has_sender + || (strcmp(entry.sender, "*") != 0 + && !mesh::FloodGroupPlainText::senderEquals(decoded, entry.sender))) continue; bool rule_blocked = entry.max_hops != FLOOD_GROUP_MODERATION_HOPS_ALL && hops >= entry.max_hops; if (entry.rate_per_minute != FLOOD_GROUP_MODERATION_RATE_UNLIMITED) { @@ -10119,8 +10316,9 @@ bool MyMesh::shouldBlockFloodGroupTextForward(const mesh::Packet* packet) { } } if (rule_blocked) { - MESH_DEBUG_PRINTLN("allowPacketForward: flood.moderation slot=%d channel=%s sender=%s hops=%d rate=%d", - i + 1, entry.channel_name, decoded_sender, hops, entry.rate_per_minute); + MESH_DEBUG_PRINTLN("allowPacketForward: flood.moderation slot=%d channel=%s sender=%.*s hops=%d rate=%d", + i + 1, entry.channel_name, int(decoded.sender.size), + (const char*)decoded.sender.data, hops, entry.rate_per_minute); return true; } if (entry.rate_per_minute != FLOOD_GROUP_MODERATION_RATE_UNLIMITED) { @@ -10253,7 +10451,7 @@ void MyMesh::setFloodGroupModeration(const char* args, char* reply) { bool rate_set = false; char option[48]; while ((token_result = takeFloodModerationToken(cursor, option, sizeof(option))) == 1) { - if (strcmp(option, "drop") == 0) { + if (strcmp(option, "drop") == 0 || strcmp(option, "d") == 0) { if (rate_set) { strcpy(reply, "Err - use only one rate/drop option"); return; @@ -10261,23 +10459,26 @@ void MyMesh::setFloodGroupModeration(const char* args, char* reply) { rate_per_minute = 0; rate_set = true; action_set = true; - } else if (strncmp(option, "rate=", 5) == 0) { + } else if (strncmp(option, "rate=", 5) == 0 || strncmp(option, "q=", 2) == 0) { if (rate_set) { strcpy(reply, "Err - use only one rate/drop option"); return; } char rate_text[24]; - if (strlen(option + 5) >= sizeof(rate_text)) { + const bool compact_rate = option[0] == 'q'; + const char* value = option + (compact_rate ? 2 : 5); + if (strlen(value) >= sizeof(rate_text)) { strcpy(reply, "Err - rate value too long"); return; } - StrHelper::strncpy(rate_text, option + 5, sizeof(rate_text)); + StrHelper::strncpy(rate_text, value, sizeof(rate_text)); char* slash = strchr(rate_text, '/'); - if (slash == NULL || !(strcmp(slash, "/min") == 0 || strcmp(slash, "/m") == 0)) { + if ((!slash && !compact_rate) + || (slash && !(strcmp(slash, "/min") == 0 || strcmp(slash, "/m") == 0))) { strcpy(reply, "Err - rate format is X/min"); return; } - *slash = 0; + if (slash) *slash = 0; uint32_t parsed; if (!parseFloodModerationUnsigned(rate_text, FLOOD_GROUP_MODERATION_RATE_UNLIMITED - 1, parsed)) { strcpy(reply, "Err - rate must be 0-65534/min"); @@ -10286,20 +10487,22 @@ void MyMesh::setFloodGroupModeration(const char* args, char* reply) { rate_per_minute = (uint16_t)parsed; rate_set = true; action_set = true; - } else if (strncmp(option, "hops=", 5) == 0) { - if (strcmp(option + 5, "all") == 0) { + } else if (strncmp(option, "hops=", 5) == 0 || strncmp(option, "h=", 2) == 0) { + const char* value = option + (option[1] == '=' ? 2 : 5); + if (strcmp(value, "all") == 0) { max_hops = FLOOD_GROUP_MODERATION_HOPS_ALL; } else { uint32_t parsed; - if (!parseFloodModerationUnsigned(option + 5, FLOOD_PACKET_FILTER_MAX_HOPS, parsed)) { + if (!parseFloodModerationUnsigned(value, FLOOD_PACKET_FILTER_MAX_HOPS, parsed)) { strcpy(reply, "Err - hops must be all or 0-63"); return; } max_hops = (uint8_t)parsed; action_set = true; } - } else if (strncmp(option, "path=", 5) == 0) { - if (!parseFloodModerationPath(option + 5, path_hash_size, path_hops, path)) { + } else if (strncmp(option, "path=", 5) == 0 || strncmp(option, "p=", 2) == 0) { + if (!parseFloodModerationPath(option + (option[1] == '=' ? 2 : 5), + path_hash_size, path_hops, path)) { strcpy(reply, "Err - path is * or 1-3 comma-separated 1/2/3-byte hashes"); return; } @@ -10373,7 +10576,7 @@ void MyMesh::setFloodGroupModeration(const char* args, char* reply) { entry.rate_per_minute = rate_per_minute; if (!saveFloodGroupModeration()) { entry = previous; - strcpy(reply, "Err - unable to save moderation rule"); + strcpy(reply, "Err - moderation save unconfirmed; reboot may differ"); return; } char detail[160]; @@ -10390,7 +10593,7 @@ void MyMesh::deleteFloodGroupModeration(const char* args, char* reply) { memset(flood_group_moderation, 0, sizeof(flood_group_moderation)); if (!saveFloodGroupModeration()) { memcpy(flood_group_moderation, previous, sizeof(flood_group_moderation)); - strcpy(reply, "Err - unable to save moderation rules"); + strcpy(reply, "Err - moderation save unconfirmed; reboot may differ"); } else { strcpy(reply, "OK - all moderation rules removed"); } @@ -10410,7 +10613,7 @@ void MyMesh::deleteFloodGroupModeration(const char* args, char* reply) { memset(&flood_group_moderation[index], 0, sizeof(flood_group_moderation[index])); if (!saveFloodGroupModeration()) { flood_group_moderation[index] = previous; - strcpy(reply, "Err - unable to save moderation rules"); + strcpy(reply, "Err - moderation save unconfirmed; reboot may differ"); } else { strcpy(reply, "OK"); } @@ -11716,9 +11919,7 @@ bool MyMesh::handleClientPathCommand(ClientInfo* sender, char* command, char* re } static bool commandFamilyMatches(const char* command, const char* family) { - size_t len = strlen(family); - if (strncmp(command, family, len) != 0) return false; - return command[len] == 0 || command[len] == ' ' || command[len] == '.'; + return mesh::cli::floodCommandArguments(command, family) != nullptr; } static bool isCommonManagerReadOnlyAllowed(const char* cmd) { @@ -11749,6 +11950,9 @@ static bool isRegionMgrAllowed(const char* cmd) { || commandFamilyMatches(cmd, "get flood.channel.scope") || commandFamilyMatches(cmd, "set flood.channel.scope") || commandFamilyMatches(cmd, "del flood.channel.scope") + || commandFamilyMatches(cmd, "get flood.channel.scope.require") + || commandFamilyMatches(cmd, "set flood.channel.scope.require") + || commandFamilyMatches(cmd, "del flood.channel.scope.require") || isCommonManagerReadOnlyAllowed(cmd); } @@ -11759,7 +11963,9 @@ 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.advert") || commandFamilyMatches(cmd, "get flood.filter") + || commandFamilyMatches(cmd, "get flood.filter.blacklist") #if MESH_ENABLE_FLOOD_RULE_ENGINE || commandFamilyMatches(cmd, "get flood.rule") || commandFamilyMatches(cmd, "get fr") @@ -11768,6 +11974,8 @@ static bool isFilterMgrAllowed(const char* cmd) { // General payload/hop filters plus the remaining flood-hop gates. return commandFamilyMatches(cmd, "set flood.filter") || commandFamilyMatches(cmd, "del flood.filter") + || commandFamilyMatches(cmd, "set flood.filter.blacklist") + || commandFamilyMatches(cmd, "del flood.filter.blacklist") #if MESH_ENABLE_FLOOD_RULE_ENGINE || commandFamilyMatches(cmd, "set flood.rule") || commandFamilyMatches(cmd, "set fr") @@ -11776,6 +11984,7 @@ static bool isFilterMgrAllowed(const char* cmd) { #endif || commandFamilyMatches(cmd, "set flood.moderation") || commandFamilyMatches(cmd, "del flood.moderation") + || commandFamilyMatches(cmd, "clear flood.advert") || commandFamilyMatches(cmd, "set flood.channel.data") || commandFamilyMatches(cmd, "set flood.max") || commandFamilyMatches(cmd, "set loop.detect") @@ -12419,28 +12628,28 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * #endif if (commandFamilyMatches(command, "get flood.channel.scope.require")) { formatFloodChannelScopeRequirements( - command + strlen("get flood.channel.scope.require"), reply); + mesh::cli::floodCommandArguments(command, "get flood.channel.scope.require"), reply); } else if (commandFamilyMatches(command, "set flood.channel.scope.require")) { setFloodChannelScopeRequirement( - command + strlen("set flood.channel.scope.require"), reply); + mesh::cli::floodCommandArguments(command, "set flood.channel.scope.require"), reply); } else if (commandFamilyMatches(command, "del flood.channel.scope.require")) { deleteFloodChannelScopeRequirement( - command + strlen("del flood.channel.scope.require"), reply); + mesh::cli::floodCommandArguments(command, "del flood.channel.scope.require"), reply); } else if (commandFamilyMatches(command, "get flood.channel.scope")) { - formatFloodChannelScopes(command + strlen("get flood.channel.scope"), reply); + formatFloodChannelScopes(mesh::cli::floodCommandArguments(command, "get flood.channel.scope"), reply); } else if (commandFamilyMatches(command, "set flood.channel.scope")) { - setFloodChannelScope(command + strlen("set flood.channel.scope"), reply); + setFloodChannelScope(mesh::cli::floodCommandArguments(command, "set flood.channel.scope"), reply); } else if (commandFamilyMatches(command, "del flood.channel.scope")) { - deleteFloodChannelScope(command + strlen("del flood.channel.scope"), reply); + deleteFloodChannelScope(mesh::cli::floodCommandArguments(command, "del flood.channel.scope"), reply); } else if (commandFamilyMatches(command, "get flood.filter.blacklist")) { formatFloodPacketFilterBlacklist( - command + strlen("get flood.filter.blacklist"), reply); + mesh::cli::floodCommandArguments(command, "get flood.filter.blacklist"), reply); } else if (commandFamilyMatches(command, "set flood.filter.blacklist")) { setFloodPacketFilterBlacklist( - command + strlen("set flood.filter.blacklist"), reply); + mesh::cli::floodCommandArguments(command, "set flood.filter.blacklist"), reply); } else if (commandFamilyMatches(command, "del flood.filter.blacklist")) { deleteFloodPacketFilterBlacklist( - command + strlen("del flood.filter.blacklist"), reply); + mesh::cli::floodCommandArguments(command, "del flood.filter.blacklist"), reply); } else if (commandFamilyMatches(command, "get flood.filter")) { formatFloodPacketFilters(command + strlen("get flood.filter"), reply); } else if (commandFamilyMatches(command, "set flood.filter")) { @@ -12455,20 +12664,20 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * } else if (commandFamilyMatches(command, "del fr")) { deleteFloodPacketFilter(command + strlen("del fr"), reply); } else if (commandFamilyMatches(command, "get flood.rule")) { - formatFloodPacketFilters(command + strlen("get flood.rule"), reply); + formatFloodPacketFilters(mesh::cli::floodCommandArguments(command, "get flood.rule"), reply); } else if (commandFamilyMatches(command, "set flood.rule")) { - setFloodPacketFilter(command + strlen("set flood.rule"), reply, true); + setFloodPacketFilter(mesh::cli::floodCommandArguments(command, "set flood.rule"), reply, true); } else if (commandFamilyMatches(command, "del flood.rule")) { - deleteFloodPacketFilter(command + strlen("del flood.rule"), reply); + deleteFloodPacketFilter(mesh::cli::floodCommandArguments(command, "del flood.rule"), reply); #endif } #if MESH_ENABLE_FLOOD_GROUP_MODERATION else if (commandFamilyMatches(command, "get flood.moderation")) { - formatFloodGroupModeration(command + strlen("get flood.moderation"), reply); + formatFloodGroupModeration(mesh::cli::floodCommandArguments(command, "get flood.moderation"), reply); } else if (commandFamilyMatches(command, "set flood.moderation")) { - setFloodGroupModeration(command + strlen("set flood.moderation"), reply); + setFloodGroupModeration(mesh::cli::floodCommandArguments(command, "set flood.moderation"), reply); } else if (commandFamilyMatches(command, "del flood.moderation")) { - deleteFloodGroupModeration(command + strlen("del flood.moderation"), reply); + deleteFloodGroupModeration(mesh::cli::floodCommandArguments(command, "del flood.moderation"), reply); } #endif #if MESH_ENABLE_CLOCK_SYNC diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 08aac69f3..76dfbe5ab 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -5,6 +5,8 @@ #include #include #include +#include +#include #if defined(ENABLE_OTA) #include #endif @@ -443,6 +445,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks char target_region_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN]; uint8_t priority; uint8_t transport_modes; + FloodRuleExtensions::Fields extra; #else bool active; uint8_t payload_type; @@ -496,7 +499,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks }; mutable FloodRetryBridgeState flood_retry_bridge_states[TOTAL_FLOOD_RETRY_SLOTS]; FloodRetryBridgeReachability flood_retry_bridge_reachability[FLOOD_RETRY_BRIDGE_BUCKETS + 1]; - static_assert(sizeof(FloodPacketFilterEntry) <= (MESH_ENABLE_FLOOD_RULE_ENGINE ? 192 : 40), + static_assert(sizeof(FloodPacketFilterEntry) <= (MESH_ENABLE_FLOOD_RULE_ENGINE ? 336 : 40), "Update the flood-table runtime RAM budget in check_firmware_ram.py"); // The rule table is the single largest member of this object. It is heap // allocated in the constructor (before setup(), while the heap is still @@ -506,6 +509,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks // zero-capacity node simply forwards without rule filtering. FloodPacketFilterEntry* flood_packet_filters; uint8_t flood_packet_filter_slots; +#if MESH_ENABLE_FLOOD_RULE_ENGINE + uint32_t flood_filter_probability_seed; +#endif uint8_t flood_packet_filter_blacklist_count; uint8_t flood_packet_filter_blacklist[FLOOD_PACKET_FILTER_BLACKLIST_MAX] [FLOOD_PACKET_FILTER_PATH_ID_SIZE]; @@ -917,6 +923,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks void deleteFloodChannelScopeRequirement(const char* args, char* reply); void loadFloodGroupModeration(); bool saveFloodGroupModeration(); + bool readFloodGroupModerationFile(const char* path, FloodGroupModerationEntry* entries, + bool* readable = nullptr) const; + bool recoverFloodGroupModeration(); bool shouldBlockFloodGroupTextForward(const mesh::Packet* packet); void formatFloodGroupModeration(const char* args, char* reply) const; void formatFloodGroupModerationDetail(int index, char* reply, size_t reply_len) const; @@ -924,6 +933,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks void deleteFloodGroupModeration(const char* args, char* reply); bool decodeFloodGroupPlainText(const mesh::Packet* packet, const uint8_t* secret, uint8_t key_len, uint32_t& timestamp, char* sender, size_t sender_len) const; + bool decodeFloodGroupPlainTextSpan(const mesh::Packet* packet, const uint8_t* secret, + uint8_t key_len, uint8_t* buffer, size_t capacity, + mesh::FloodGroupPlainText::View& view) const; void loadClockSyncPrefs(); bool saveClockSyncPrefs(); void resetClockSyncAttempt(); diff --git a/examples/simple_room_server/FloodRuleEngine.cpp b/examples/simple_room_server/FloodRuleEngine.cpp index 44069042a..cf671e20e 100644 --- a/examples/simple_room_server/FloodRuleEngine.cpp +++ b/examples/simple_room_server/FloodRuleEngine.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -282,48 +283,6 @@ static bool parseChannel(const char* text, uint8_t secret[PUB_KEY_SIZE], 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]) { @@ -347,9 +306,7 @@ static void formatPathPrefix( } 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] == ' '); + return mesh::cli::floodCommandArguments(command, base) != nullptr; } static File openRead(FILESYSTEM* fs, const char* path) { @@ -397,16 +354,17 @@ static bool verifyWrittenFile(FILESYSTEM* fs, const char* path, FloodRuleEngine::FloodRuleEngine() : _fs(NULL), _regions(NULL), - _entries(new (std::nothrow) Entry[RULE_SLOTS]) { + _entries(new (std::nothrow) Entry[RULE_SLOTS]), _probability_seed(0) { // MyMesh is constructed before setup(), while the internal heap is // unfragmented. The runtime RAM gate budgets this exact allocation. if (_entries == NULL) abort(); memset(_entries, 0, sizeof(Entry) * RULE_SLOTS); } -void FloodRuleEngine::begin(FILESYSTEM* fs, RegionMap* regions) { +void FloodRuleEngine::begin(FILESYSTEM* fs, RegionMap* regions, mesh::RNG* rng) { _fs = fs; _regions = regions; + if (rng) rng->random(reinterpret_cast(&_probability_seed), sizeof(_probability_seed)); load(); } @@ -443,7 +401,8 @@ void FloodRuleEngine::load() { 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; + bool version_8 = success && memcmp(magic, "FPF8", 4) == 0; + bool version_7 = version_8 || (success && memcmp(magic, "FPF7", 4) == 0); success = (version_6 || version_7) && readExact(&count, sizeof(count)) && count <= RULE_SLOTS; @@ -502,6 +461,9 @@ void FloodRuleEngine::load() { : FloodFilterPolicy::RULE_IN_ANY; drop_on_match = loaded[i].scope_name[0] == 0 ? 1 : 0; } + if (success && version_8) { + success = FloodRuleExtensions::readFields(file, loaded[i].extra); + } if (success && version_7) { success = FloodFilterPolicy::decodeStoredRuleChannel( stored_rule_channel, loaded[i].channel_key_len, @@ -603,9 +565,12 @@ void FloodRuleEngine::load() { || loaded[i].payload_type == PAYLOAD_TYPE_GRP_DATA; } - bool path_valid = FloodFilterPolicy::pathMatcherValid( - loaded[i].path_hash_size, loaded[i].path_hops, - loaded[i].match_blacklisted_path); + bool path_valid = loaded[i].path_hops != 0 + ? (loaded[i].path_hash_size >= 1 && loaded[i].path_hash_size <= 3 + && loaded[i].path_hops <= 4 && !loaded[i].match_blacklisted_path) + : FloodFilterPolicy::pathMatcherValid( + loaded[i].path_hash_size, 0, loaded[i].match_blacklisted_path); + bool extra_valid = FloodRuleExtensions::validRuleFields(loaded[i]); bool action_valid = loaded[i].drop_on_match || direct_target || region_target || loaded[i].rate_limit_enabled || loaded[i].stop_on_match || loaded[i].retry_on_match; @@ -630,7 +595,7 @@ void FloodRuleEngine::load() { || loaded[i].rate_per_minute < RATE_UNLIMITED) && (!loaded[i].scope_uses_slow_timing || direct_target || region_target) - && incoming_valid && channel_valid && path_valid + && incoming_valid && channel_valid && path_valid && extra_valid && action_valid)) { success = false; } @@ -724,7 +689,8 @@ bool FloodRuleEngine::save() { return true; }; - const uint8_t magic[4] = {'F', 'P', 'F', '7'}; + const bool extended = FloodRuleExtensions::tableUsesExtensions(_entries, RULE_SLOTS); + const uint8_t magic[4] = {'F', 'P', 'F', uint8_t(extended ? '8' : '7')}; uint8_t count = RULE_SLOTS; bool success = writeExact(magic, sizeof(magic)) && writeExact(&count, sizeof(count)); @@ -775,6 +741,8 @@ bool FloodRuleEngine::save() { sizeof(entry.target_region_name)) && writeExact(&entry.priority, sizeof(entry.priority)) && writeExact(&stop_on_match, sizeof(stop_on_match)); + if (success && extended) + success = FloodRuleExtensions::writeFields(writeExact, entry.extra); } const uint8_t section_magic[4] = {'F', 'P', 'S', '1'}; const uint8_t no_channel_data_slot = 0xFF; @@ -813,22 +781,25 @@ bool FloodRuleEngine::fieldsMatch( uint16_t incoming_transport_code, bool incoming_region_allowed, const RegionEntry* incoming_region, uint8_t context) const { if (!entry.active || !FloodFilterPolicy::ruleModeMatches( - entry.transport_modes, context, packet)) return false; + entry.transport_modes, context, packet, entry.extra.route) + || !FloodRuleExtensions::matchesEnvelope(entry.extra, packet, context)) return false; if (!FloodFilterPolicy::channelKeyLengthSupported( entry.channel_key_len)) 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)) { + if (!FloodFilterPolicy::pathHashBytesMatch(entry.path_hash_size, packet->getPathHashSize()) + || !FloodRuleExtensions::pathMatches( + entry.extra, 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) { + || (packet->isRouteFlood() ? hops < entry.min_hops || hops > entry.max_hops + : entry.min_hops != 0 || entry.max_hops != MAX_HOPS)) { return false; } @@ -938,7 +909,7 @@ uint32_t FloodRuleEngine::evaluate( const RegionEntry* incoming_region, uint8_t context) { static_assert(RULE_SLOTS <= 32, "flood rule match mask supports at most 32 slots"); - if (packet == NULL || (context == FloodFilterPolicy::RULE_MODE_RADIO && !packet->isRouteFlood())) return 0; + if (packet == NULL) return 0; bool incoming_is_scoped = packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD || packet->getRouteType() == ROUTE_TYPE_TRANSPORT_DIRECT; @@ -947,6 +918,7 @@ uint32_t FloodRuleEngine::evaluate( bool channel_auth_checked[RULE_SLOTS] = { false }; bool channel_auth_valid[RULE_SLOTS] = { false }; uint32_t result = 0; + uint32_t pattern_work = 131072; for (int i = 0; i < RULE_SLOTS; i++) { const Entry& entry = _entries[i]; if (!fieldsMatch(entry, packet, temp_radio_active, incoming_is_scoped, @@ -973,14 +945,23 @@ uint32_t FloodRuleEngine::evaluate( channel_auth_checked[i] = true; channel_auth_valid[i] = authenticated; } - if (authenticated) result |= (uint32_t)1U << i; + bool budget_exceeded = false; + const bool content_matches = authenticated && FloodRuleExtensions::matchesContent( + entry.extra, packet, entry.channel_secret, entry.channel_key_len, + &pattern_work, false, &budget_exceeded); + if (budget_exceeded) return uint32_t(1) << 31; + if (!content_matches) continue; + if (entry.extra.probability && entry.extra.probability < 100 + && !FloodRuleExtensions::matchesProbability(entry.extra, + FloodRuleExtensions::probabilityRoll(packet, _probability_seed, unsigned(i), context))) continue; + result |= (uint32_t)1U << i; } return applyStop(result); } bool FloodRuleEngine::hasRetryRules() const { for (int i = 0; i < RULE_SLOTS; i++) { - if (_entries[i].active && _entries[i].retry_on_match) return true; + if (_entries[i].active && !_entries[i].extra.disabled && _entries[i].retry_on_match) return true; } return false; } @@ -1042,6 +1023,7 @@ bool FloodRuleEngine::shouldBlock(const mesh::Packet* packet, uint32_t match_mask, uint32_t now_millis) const { if (packet == NULL) return false; + if (match_mask & (uint32_t(1) << 31)) return true; uint8_t type = packet->getPayloadType(); uint8_t hops = packet->getPathHashCount(); @@ -1096,7 +1078,7 @@ void FloodRuleEngine::formatDetail(int index, char* reply, return; } const Entry& entry = _entries[index]; - if (compact) { + if (compact || FloodRuleExtensions::hasFields(entry.extra) || entry.path_hops > 3) { FloodRuleCLI::formatCompact(reply, reply_len, index + 1, entry); return; } @@ -1187,6 +1169,28 @@ void FloodRuleEngine::format(const char* args, char* reply, bool compact) const const char* selector = skipSpaces(args); if (*selector == '.') selector = skipSpaces(selector + 1); if (*selector != 0) { + const char* field = strchr(selector, ' '); + if (field) { + char slot_text[8]; + const size_t length = size_t(field - selector); + uint32_t slot = 0; + if (!length || length >= sizeof(slot_text)) { + copyString(reply, "Err - bad filter slot", 160); + return; + } + memcpy(slot_text, selector, length); slot_text[length] = 0; + field = skipSpaces(field); + if (!parseUnsigned(slot_text, RULE_SLOTS, slot) || !slot || !_entries[slot - 1].active) { + copyString(reply, "Err - empty or invalid filter slot", 160); + } else if (asciiEqual(field, "n") || asciiEqual(field, "sender")) { + snprintf(reply, 160, "> %s", _entries[slot - 1].extra.sender); + } else if (asciiEqual(field, "x") || asciiEqual(field, "text")) { + snprintf(reply, 160, "> %s", _entries[slot - 1].extra.text); + } else { + copyString(reply, "Err - field is sender/n or text/x", 160); + } + return; + } uint32_t slot = 0; if (!parseUnsigned(selector, RULE_SLOTS, slot) || slot == 0) { snprintf(reply, 160, "Err - filter slot must be 1-%u", @@ -1224,7 +1228,7 @@ void FloodRuleEngine::format(const char* args, char* reply, bool compact) const snprintf(priority, sizeof(priority), "^%u", (unsigned int)entry.priority); } - snprintf(item, sizeof(item), " %d=%s~%s@%s%s%s%s%s%s%s%s%s", + snprintf(item, sizeof(item), " %d=%s~%s@%s%s%s%s%s%s%s%s%s%s", i + 1, payloadTypeName(entry.payload_type), FloodFilterPolicy::ruleModeName(entry.transport_modes), hops, entry.match_blacklisted_path ? "?blacklist" : "", target, @@ -1232,7 +1236,8 @@ void FloodRuleEngine::format(const char* args, char* reply, bool compact) const entry.rate_limit_enabled ? "~rate" : "", entry.retry_on_match ? "~retry" : "", entry.scope_uses_slow_timing ? "~slow" : "", - entry.suspend_on_temp_radio ? "~tempradio" : ""); + entry.suspend_on_temp_radio ? "~tempradio" : "", + entry.extra.disabled ? "~off" : ""); size_t item_len = strlen(item); if (used + item_len >= 156) { truncated = true; @@ -1275,6 +1280,27 @@ void FloodRuleEngine::set(const char* args, char* reply, } } cursor = skipSpaces(cursor); + const char* enabled = asciiStartsWith(cursor, "enabled=") ? cursor + 8 + : asciiStartsWith(cursor, "e=") ? cursor + 2 + : (asciiEqual(cursor, "on") || asciiEqual(cursor, "off")) ? cursor : nullptr; + if (enabled) { + uint8_t disabled; + if (requested_slot < 0 || !_entries[requested_slot].active) { + copyString(reply, "Err - select an occupied filter slot", 160); + } else if (!FloodRuleExtensions::enabledValue(enabled, disabled)) { + copyString(reply, "Err - enabled is on or off", 160); + } else { + const uint8_t previous = _entries[requested_slot].extra.disabled; + _entries[requested_slot].extra.disabled = disabled; + if (previous != disabled && !save()) { + _entries[requested_slot].extra.disabled = previous; + copyString(reply, "Err - unable to save flood filter", 160); + } else { + copyString(reply, "OK", 160); + } + } + return; + } char params[256]; if (strlen(cursor) >= sizeof(params)) { copyString(reply, "Err - rule parameters too long", 160); @@ -1282,20 +1308,11 @@ void FloodRuleEngine::set(const char* args, char* reply, } 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; + char* tokens[32]; + int token_count = FloodRuleExtensions::tokenize(params, tokens, 32); + if (token_count < 0) { + copyString(reply, "Err - invalid quotes or too many rule parameters", 160); + return; } if (token_count == 0) { copyString(reply, RULE_USAGE, 160); @@ -1352,8 +1369,13 @@ void FloodRuleEngine::set(const char* args, char* reply, bool retry_on_match = false; uint8_t transport_modes = FloodFilterPolicy::RULE_MODE_RADIO; bool mode_set = false; + FloodRuleExtensions::Fields extra{}; + uint8_t extra_seen = 0; for (int i = 1; i < token_count; i++) { + const int parsed_extra = FloodRuleExtensions::parseToken(tokens[i], extra, extra_seen, reply); + if (parsed_extra < 0) return; + if (parsed_extra > 0) continue; if (asciiStartsWith(tokens[i], "mode=") || asciiStartsWith(tokens[i], "m=")) { if (mode_set) { @@ -1486,6 +1508,9 @@ void FloodRuleEngine::set(const char* args, char* reply, priority_set = true; } else if (asciiEqual(tokens[i], "stop") || asciiEqual(tokens[i], "s") + || asciiEqual(tokens[i], "allow") || asciiEqual(tokens[i], "forward") + || asciiEqual(tokens[i], "a") || asciiEqual(tokens[i], "action=allow") + || asciiEqual(tokens[i], "action=forward") || asciiEqual(tokens[i], "action=stop")) { if (stop_on_match) { copyString(reply, DUPLICATE_OPTION, 160); @@ -1625,15 +1650,18 @@ void FloodRuleEngine::set(const char* args, char* reply, hashbytes_set = true; } else if (asciiStartsWith(tokens[i], "prefix=") || asciiStartsWith(tokens[i], "p=") + || asciiStartsWith(tokens[i], "pa=") || 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)) { + const bool prefix_legacy = asciiStartsWith(tokens[i], "prefix=") + || asciiStartsWith(tokens[i], "p="); + if (!FloodRuleExtensions::parsePath(value, path_hash_size, path_hops, path, extra, prefix_legacy)) { copyString(reply, - "Err - prefix is * or 1-3 comma-separated 1/2/3-byte IDs", + "Err - path is [^]ID>ID[>ID>IDs][$] using 1/2/3-byte IDs", 160); return; } @@ -1685,6 +1713,12 @@ void FloodRuleEngine::set(const char* args, char* reply, 160); return; } + if (FloodRuleExtensions::contentRequested(extra) + && (!FloodFilterPolicy::channelRequiresAuthentication(channel_key_len) + || (payload_type != ANY_TYPE && payload_type != PAYLOAD_TYPE_GRP_TXT))) { + copyString(reply, "Err - sender/text requires an authenticated group-text channel", 160); + return; + } if (!FloodFilterPolicy::transportActionsSupported( transport_modes, target_set, retry_on_match, scope_uses_slow_timing)) { strcpy(reply, "Err - bridge/cross supports drop, rate=, priority=, stop only"); @@ -1730,6 +1764,11 @@ void FloodRuleEngine::set(const char* args, char* reply, candidate.stop_on_match = stop_on_match; candidate.retry_on_match = retry_on_match; candidate.transport_modes = transport_modes; + candidate.extra = extra; + if (!FloodRuleExtensions::validRuleFields(candidate)) { + copyString(reply, "Err - invalid rule fields or flood-only action on a direct route", 160); + return; + } int slot = requested_slot; if (slot < 0) { @@ -1775,15 +1814,20 @@ 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)); + Entry* previous = new (std::nothrow) Entry[RULE_SLOTS]; + if (!previous) { + copyString(reply, "Err - unable to stage filter deletion", 160); + return; + } + memcpy(previous, _entries, sizeof(Entry) * RULE_SLOTS); memset(_entries, 0, sizeof(Entry) * RULE_SLOTS); if (!save()) { - memcpy(_entries, previous, sizeof(_entries)); + memcpy(_entries, previous, sizeof(Entry) * RULE_SLOTS); copyString(reply, "Err - unable to save flood filter", 160); } else { copyString(reply, "OK - all flood filters removed", 160); } + delete[] previous; return; } @@ -1831,15 +1875,15 @@ bool FloodRuleEngine::handleCommand(const char* command, char* reply) { return true; } if (commandMatches(command, "get flood.rule")) { - format(command + strlen("get flood.rule"), reply); + format(mesh::cli::floodCommandArguments(command, "get flood.rule"), reply); return true; } if (commandMatches(command, "set flood.rule")) { - set(command + strlen("set flood.rule"), reply, true); + set(mesh::cli::floodCommandArguments(command, "set flood.rule"), reply, true); return true; } if (commandMatches(command, "del flood.rule")) { - remove(command + strlen("del flood.rule"), reply); + remove(mesh::cli::floodCommandArguments(command, "del flood.rule"), reply); return true; } if (commandMatches(command, "get flood.filter")) { diff --git a/examples/simple_room_server/FloodRuleEngine.h b/examples/simple_room_server/FloodRuleEngine.h index 903702a2a..5c1b83196 100644 --- a/examples/simple_room_server/FloodRuleEngine.h +++ b/examples/simple_room_server/FloodRuleEngine.h @@ -10,6 +10,7 @@ #include #include +#include class FloodRuleEngine { public: @@ -23,7 +24,7 @@ public: FloodRuleEngine(); - void begin(FILESYSTEM* fs, RegionMap* regions); + void begin(FILESYSTEM* fs, RegionMap* regions, mesh::RNG* rng = nullptr); uint32_t evaluate(const mesh::Packet* packet, bool temp_radio_active, bool incoming_region_allowed, @@ -73,18 +74,20 @@ private: char target_region_name[NAME_LEN]; uint8_t priority; uint8_t transport_modes; + FloodRuleExtensions::Fields extra; }; - // This table is at most 5,952 bytes. Keep it in the constructor-time heap rather + // This table is at most 10,416 bytes. Keep it in the constructor-time heap rather // than inside the global MyMesh object: classic ESP32 has a small static // DRAM window but substantially more internal heap at this point in boot. // Allocation is deliberately all-or-nothing. A node must never fall back // to silently running without its configured flood rules. - static_assert(sizeof(Entry) <= 192, + static_assert(sizeof(Entry) <= 336, "Update the flood-rule runtime RAM budget in check_firmware_ram.py"); FILESYSTEM* _fs; RegionMap* _regions; Entry* _entries; + uint32_t _probability_seed; void seedDefaults(); void load(); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index cbdd7523a..607889c3f 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1738,14 +1738,10 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { _prefs.flood_max_advert)) { return false; } -#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE - if (flood_rules.shouldBlock(packet, recv_pkt_rule_match_mask, - _ms->getMillis())) { - return false; - } -#endif } #if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE + if (flood_rules.shouldBlock(packet, recv_pkt_rule_match_mask, + _ms->getMillis())) return false; if (packet->isRouteFlood()) { if (recv_pkt_region == NULL && !recv_pkt_regionless_scope_set) { MESH_DEBUG_PRINTLN( @@ -1763,9 +1759,9 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { return false; } } - flood_rules.commitRates(packet, recv_pkt_rule_match_mask, - _ms->getMillis()); } + flood_rules.commitRates(packet, recv_pkt_rule_match_mask, + _ms->getMillis()); #endif _clock_sync.observeAcceptedFlood(packet); return true; @@ -1778,19 +1774,21 @@ mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { recv_pkt_regionless_scope_set = false; recv_pkt_rule_match_mask = 0; pkt->flood_retry_policy = mesh::FLOOD_RETRY_POLICY_DEFAULT; - if (pkt->isRouteFlood()) { + { bool incoming_region_allowed = false; RegionEntry* incoming_region = NULL; - if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { + if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD + || pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_DIRECT) { incoming_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); incoming_region_allowed = incoming_region != NULL; - } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { + } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD + || pkt->getRouteType() == ROUTE_TYPE_DIRECT) { incoming_region_allowed = (region_map.getWildcard().flags & REGION_DENY_FLOOD) == 0; } recv_pkt_rule_match_mask = flood_rules.evaluate( pkt, isAnyTempRadioActive(), incoming_region_allowed, incoming_region); - if (flood_rules.hasRetryRules()) { + if (pkt->isRouteFlood() && flood_rules.hasRetryRules()) { pkt->flood_retry_policy = flood_rules.allowsRetry( recv_pkt_rule_match_mask) ? mesh::FLOOD_RETRY_POLICY_ALLOW @@ -2648,7 +2646,7 @@ void MyMesh::begin(FILESYSTEM *fs) { MESH_DEBUG_PRINTLN("Room topic unavailable; stored image preserved"); } #if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE - flood_rules.begin(_fs, ®ion_map); + flood_rules.begin(_fs, ®ion_map, getRNG()); #endif // establish default-scope diff --git a/scripts/check_firmware_ram.py b/scripts/check_firmware_ram.py index 6fd43d282..0fb74fcce 100644 --- a/scripts/check_firmware_ram.py +++ b/scripts/check_firmware_ram.py @@ -289,12 +289,12 @@ def requirements(platform, defines, target): if repeater: engine = integer(defines, "MESH_ENABLE_FLOOD_RULE_ENGINE", int(platform != "STM32_PLATFORM")) slots = integer(defines, "FLOOD_PACKET_FILTER_SLOTS", 63 if engine else 16) - parts["flood_filter_table"] = slots * (192 if engine else 40) + 16 + parts["flood_filter_table"] = slots * (336 if engine else 40) + 16 if "room_server" in target.lower() and integer( defines, "MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE", 0): # FloodRuleEngine keeps all 31 persisted rules in one startup heap - # allocation. Its C++ assertion pins Entry to the budgeted 192 bytes. - parts["room_flood_rule_table"] = 31 * 192 + 16 + # allocation. Its C++ assertion pins Entry to the budgeted 336 bytes. + parts["room_flood_rule_table"] = 31 * 336 + 16 if "ENABLE_OTA" in defines and "OTA_HEAP_CONTEXT" in defines: parts["ota_context"] = 16384 + 16 if "ENABLE_OTA" in defines: diff --git a/src/helpers/FleetCommand.cpp b/src/helpers/FleetCommand.cpp index d8286ec44..66f8f5a57 100644 --- a/src/helpers/FleetCommand.cpp +++ b/src/helpers/FleetCommand.cpp @@ -230,6 +230,42 @@ bool rowKey(const char* key, size_t length, const char* family) { return true; } +// Regex punctuation is data only in the two content-pattern fields. Keep the +// existing management-command character policy for every other argument. +bool filterPatternCharactersAllowed(const char* text) { + while (*text) { + while (*text == ' ') ++text; + if (!*text) break; + const char* key = text; + while (*text && *text != '=' && *text != ' ') { + if (*text == '|' || *text == '\\') return false; + ++text; + } + const size_t key_length = size_t(text - key); + const bool pattern = keyEquals(key, key_length, "sender") + || keyEquals(key, key_length, "n") || keyEquals(key, key_length, "text") + || keyEquals(key, key_length, "x"); + if (*text == '=') ++text; + const char quote = *text == '"' || *text == '\'' ? *text++ : 0; + bool closed = quote == 0; + while (*text) { + if (quote ? *text == quote : *text == ' ') { + if (quote) { ++text; closed = true; } + break; + } + if (*text == '|' && !pattern) return false; + if (*text == '\\') { + if (!pattern || !text[1]) return false; + text += 2; + } else { + ++text; + } + } + if (!closed || (*text && *text != ' ')) return false; + } + return true; +} + bool decimalToken(const char* text, size_t length) { if (!length) return false; bool dot = false, digit = false; @@ -458,11 +494,12 @@ bool FleetCommand::withinRadius(int32_t center_latitude_e6, int32_t center_longi bool FleetCommand::commandAllowed(const char* command) { if (!command) return false; size_t length = 0; + bool pattern_characters = false; while (command[length]) { const unsigned char byte = command[length]; if (++length > MaxCommandLength || byte < 0x20 || byte > 0x7e - || byte == ';' || byte == '|' || byte == '&' || byte == '`' - || byte == '\\') return false; + || byte == ';' || byte == '&' || byte == '`') return false; + pattern_characters = pattern_characters || byte == '|' || byte == '\\'; } if (!length || command[0] == ' ' || command[length - 1] == ' ') return false; if (!strcmp(command, "clock") || !strcmp(command, "clock sync")) return true; @@ -471,17 +508,40 @@ bool FleetCommand::commandAllowed(const char* command) { return boundedIntegerToken(command + 5, length - 5, UINT32_MAX, value) && value >= MinEpoch; } - bool get = false, set = false, del = false; + bool get = false, set = false, del = false, clear = false; if (!strncmp(command, "get ", 4)) get = true; else if (!strncmp(command, "set ", 4)) set = true; else if (!strncmp(command, "del ", 4)) del = true; + else if (!strncmp(command, "clear ", 6)) clear = true; else return false; - const char* key = command + 4; + const char* key = command + (clear ? 6 : 4); const char* separator = strchr(key, ' '); const size_t key_length = separator ? size_t(separator - key) : strlen(key); const char* arguments = separator ? separator + 1 : ""; if (!key_length || (separator && (!*arguments || *arguments == ' '))) return false; + const bool filter_rows = rowKey(key, key_length, "flood.filter") + || rowKey(key, key_length, "flood.rule") || rowKey(key, key_length, "fr"); + if (pattern_characters && (!set || !filter_rows + || !filterPatternCharactersAllowed(arguments))) return false; + + if (keyEquals(key, key_length, "flood.advert") || keyEquals(key, key_length, "fa")) { + if (get) { + if (!*arguments) return true; + const bool detail = !strncmp(arguments, "key ", 4); + const char* number = arguments + (detail ? 4 : 0); + uint32_t value; + return *number >= '1' && *number <= '9' + && boundedIntegerToken(number, strlen(number), 65535, value); + } + if (!clear) return false; + if (!strcmp(arguments, "all")) return true; + if (strlen(arguments) != PUB_KEY_SIZE * 2) return false; + for (size_t i = 0; arguments[i]; ++i) if (hexDigit(arguments[i]) < 0) return false; + return true; + } + if (clear) return false; + const bool scheduled_primary = keyEquals(key, key_length, "radioat") || keyEquals(key, key_length, "tempradioat"); const bool scheduled_secondary = keyEquals(key, key_length, "radioat2") @@ -500,13 +560,21 @@ bool FleetCommand::commandAllowed(const char* command) { return get ? !*arguments : set && (!strcmp(arguments, "on") || !strcmp(arguments, "off") || !strcmp(arguments, "auto")); } - const bool rows = rowKey(key, key_length, "flood.filter") + const bool rows = filter_rows || rowKey(key, key_length, "flood.filter.blacklist") - || rowKey(key, key_length, "flood.rule") + || rowKey(key, key_length, "fb") || rowKey(key, key_length, "flood.moderation") + || rowKey(key, key_length, "fm") || rowKey(key, key_length, "flood.channel.scope") - || rowKey(key, key_length, "flood.channel.scope.require"); - if (rows) return get ? !*arguments + || rowKey(key, key_length, "fs") + || rowKey(key, key_length, "flood.channel.scope.require") + || rowKey(key, key_length, "fsr"); + if (rows) return get ? (!*arguments || (filter_rows + && !keyEquals(key, key_length, "flood.filter") + && !keyEquals(key, key_length, "flood.rule") + && !keyEquals(key, key_length, "fr") + && (!strcmp(arguments, "sender") || !strcmp(arguments, "n") + || !strcmp(arguments, "text") || !strcmp(arguments, "x")))) : set ? *arguments != 0 : del && (!*arguments || !strcmp(arguments, "all")); const bool settings = keyEquals(key, key_length, "flood.channel.data") || keyEquals(key, key_length, "flood.channel.data.hops") diff --git a/src/helpers/FloodAdvertCLI.h b/src/helpers/FloodAdvertCLI.h index d3f9ec3df..5bfaf4f41 100644 --- a/src/helpers/FloodAdvertCLI.h +++ b/src/helpers/FloodAdvertCLI.h @@ -1,6 +1,7 @@ #pragma once #include "FloodAdvertLimiter.h" +#include "FloodCommandAliases.h" #include #include @@ -17,10 +18,8 @@ inline const char* floodAdvertLimitReason(uint8_t reasons) { // Row numbers refer to the current filtered list, not permanent table slots. inline bool handleFloodAdvertGet(FloodAdvertLimiter* limiter, const char* command, char* reply, uint32_t now) { - static const char prefix[] = "get flood.advert"; - const size_t prefix_len = sizeof(prefix) - 1; - if (strncmp(command, prefix, prefix_len) != 0) return false; - const char* arg = command + prefix_len; + const char* arg = floodCommandArguments(command, "get flood.advert"); + if (!arg) return false; if (*arg && *arg != ' ' && *arg != '\t' && *arg != '\r' && *arg != '\n') return false; while (*arg == ' ' || *arg == '\t') ++arg; const bool detail = strncmp(arg, "key", 3) == 0 && (arg[3] == ' ' || arg[3] == '\t'); @@ -81,10 +80,8 @@ inline bool handleFloodAdvertGet(FloodAdvertLimiter* limiter, const char* comman // Dispatch only after the role's ordinary CLI authorization checks. Requiring // an explicit "all" avoids treating missing/malformed keys as a global reset. inline bool handleFloodAdvertClear(FloodAdvertLimiter* limiter, const char* command, char* reply) { - static const char prefix[] = "clear flood.advert"; - const size_t prefix_len = sizeof(prefix) - 1; - if (strncmp(command, prefix, prefix_len) != 0) return false; - const char* arg = command + prefix_len; + const char* arg = floodCommandArguments(command, "clear flood.advert"); + if (!arg) return false; if (*arg && *arg != ' ' && *arg != '\t') return false; while (*arg == ' ' || *arg == '\t') ++arg; const char* end = arg; diff --git a/src/helpers/FloodCommandAliases.h b/src/helpers/FloodCommandAliases.h new file mode 100644 index 000000000..270ce0d6b --- /dev/null +++ b/src/helpers/FloodCommandAliases.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +namespace mesh { +namespace cli { + +// Return the untouched argument suffix. Aliases do not expand the caller's +// bounded command buffer or change the bytes covered by a fleet signature. +inline const char* floodCommandArguments(const char* command, const char* family) { + if (!command || !family) return nullptr; + const size_t length = strlen(family); + if (strncmp(command, family, length) == 0 + && (command[length] == 0 || command[length] == ' ' || command[length] == '.')) + return command + length; + + const char* name = strchr(family, ' '); + if (!name) return nullptr; + const size_t verb_length = size_t(++name - family); + if (strncmp(command, family, verb_length) != 0) return nullptr; + const char* alias = nullptr; + if (strcmp(name, "flood.rule") == 0) alias = "fr"; + else if (strcmp(name, "flood.moderation") == 0) alias = "fm"; + else if (strcmp(name, "flood.channel.scope") == 0) alias = "fs"; + else if (strcmp(name, "flood.channel.scope.require") == 0) alias = "fsr"; + else if (strcmp(name, "flood.filter.blacklist") == 0) alias = "fb"; + else if (strcmp(name, "flood.advert") == 0) alias = "fa"; + if (!alias) return nullptr; + const size_t alias_length = strlen(alias); + const char* key = command + verb_length; + if (strncmp(key, alias, alias_length) != 0) return nullptr; + const char* suffix = key + alias_length; + return *suffix == 0 || *suffix == ' ' || *suffix == '.' ? suffix : nullptr; +} + +} // namespace cli +} // namespace mesh diff --git a/src/helpers/FloodFilterPolicy.h b/src/helpers/FloodFilterPolicy.h index 4135d5b2e..df00a9c73 100644 --- a/src/helpers/FloodFilterPolicy.h +++ b/src/helpers/FloodFilterPolicy.h @@ -5,6 +5,7 @@ #include #include +#include "FloodRuleExtensions.h" namespace FloodFilterPolicy { @@ -52,6 +53,14 @@ enum ChannelScopeGate { // Rules can be reordered/compacted in RAM without changing their identity. // Compare configured fields explicitly: padding and live rate-window state // must never decide whether an unnumbered setter reuses an existing slot. +template +auto sameRuleExtras(const Entry& left, const Entry& right, int) + -> decltype(left.extra, right.extra, bool()) { + return FloodRuleExtensions::sameFields(left.extra, right.extra); +} +template +bool sameRuleExtras(const Entry&, const Entry&, long) { return true; } + template bool sameRuleConfiguration(const Entry& left, const Entry& right) { return left.active == right.active @@ -81,7 +90,8 @@ bool sameRuleConfiguration(const Entry& left, const Entry& right) { && left.priority == right.priority && left.stop_on_match == right.stop_on_match && left.retry_on_match == right.retry_on_match - && left.transport_modes == right.transport_modes; + && left.transport_modes == right.transport_modes + && sameRuleExtras(left, right, 0); } inline bool channelKeyLengthSupported(uint8_t key_len) { @@ -184,10 +194,10 @@ inline bool parseRuleModes(const char* text, uint8_t& modes) { // These are separate admission phases. A transport rule cannot suppress // ordinary LoRa forwarding, nor can a radio stop rule bypass a transport deny. inline bool ruleModeMatches(uint8_t modes, uint8_t context, - const mesh::Packet* packet) { + const mesh::Packet* packet, uint8_t route = 0) { return packet && (modes == RULE_MODE_RADIO ? context == RULE_MODE_RADIO : (modes & context) != 0) - && (context != RULE_MODE_RADIO || packet->isRouteFlood()); + && (route != 0 || context != RULE_MODE_RADIO || packet->isRouteFlood()); } inline bool transportActionsSupported(uint8_t modes, bool scope_action, diff --git a/src/helpers/FloodGroupPlainText.h b/src/helpers/FloodGroupPlainText.h new file mode 100644 index 000000000..448111108 --- /dev/null +++ b/src/helpers/FloodGroupPlainText.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include + +namespace mesh { namespace FloodGroupPlainText { + +struct Span { + const uint8_t* data; + size_t size; + Span(const uint8_t* bytes = nullptr, size_t length = 0) + : data(bytes), size(length) {} +}; + +struct View { + uint32_t timestamp = 0; + Span sender; + Span text; + bool has_sender = false; +}; + +// Views borrow the decrypted buffer. Never truncate fields before matching: +// a long display name still needs to match wildcard or content rules. +inline bool parse(const uint8_t* data, size_t size, uint8_t plain_type, + View& view) { + view = View{}; + if (!data || size <= 5 || (data[4] >> 2) != plain_type) return false; + memcpy(&view.timestamp, data, sizeof(view.timestamp)); + view.text = {data + 5, size - 5}; + // MACThenDecrypt returns complete AES blocks, including zero padding. + const uint8_t* end = static_cast( + memchr(view.text.data, 0, view.text.size)); + if (!end) end = data + size; + view.text.size = size_t(end - view.text.data); + const uint8_t* colon = static_cast( + memchr(view.text.data, ':', view.text.size)); + if (!colon) return true; + + size_t sender_size = size_t(colon - view.text.data); + while (sender_size && view.text.data[sender_size - 1] == ' ') --sender_size; + bool valid_sender = sender_size != 0; + for (size_t i = 0; i < sender_size; ++i) { + if (view.text.data[i] < 0x20) valid_sender = false; + } + if (valid_sender) { + view.sender = {view.text.data, sender_size}; + view.has_sender = true; + } + const uint8_t* body = colon + 1; + if (body < end && *body == ' ') ++body; + view.text = {body, size_t(end - body)}; + return true; +} + +inline bool senderEquals(const View& view, const char* name) { + if (!view.has_sender || !name) return false; + const size_t size = strlen(name); + if (size != view.sender.size) return false; + for (size_t i = 0; i < size; ++i) { + uint8_t left = view.sender.data[i]; + uint8_t right = uint8_t(name[i]); + if (left >= 'A' && left <= 'Z') left += 'a' - 'A'; + if (right >= 'A' && right <= 'Z') right += 'a' - 'A'; + if (left != right) return false; + } + return true; +} + +} } // namespace mesh::FloodGroupPlainText diff --git a/src/helpers/FloodRuleCLI.h b/src/helpers/FloodRuleCLI.h index cf78f3161..9a6bb82e3 100644 --- a/src/helpers/FloodRuleCLI.h +++ b/src/helpers/FloodRuleCLI.h @@ -66,6 +66,27 @@ public: // Generalized repeater and room-server rows have the same named fields. // Defaults are omitted only when the setter reconstructs exactly that value. +template +auto appendPath(CommandWriter& text, const Entry& entry, int) + -> decltype(entry.extra, void()) { + FloodRuleExtensions::appendPath(text, entry.extra, entry.path_hash_size, + entry.path_hops, entry.path, true); +} +template +void appendPath(CommandWriter& text, const Entry& entry, long) { + text.append(" p="); + for (unsigned hop = 0; hop < entry.path_hops; ++hop) { + if (hop) text.append(","); + for (unsigned byte = 0; byte < entry.path_hash_size; ++byte) + text.append("%02X", entry.path[hop * entry.path_hash_size + byte]); + } +} +template +auto appendExtras(CommandWriter& text, const Entry& entry, int) + -> decltype(entry.extra, void()) { FloodRuleExtensions::appendFields(text, entry.extra, true); } +template +void appendExtras(CommandWriter&, const Entry&, long) {} + template bool formatCompact(char* out, size_t capacity, unsigned slot, const Entry& entry) { using namespace FloodFilterPolicy; @@ -84,14 +105,8 @@ bool formatCompact(char* out, size_t capacity, unsigned slot, const Entry& entry if (entry.path_hops == 0 && entry.path_hash_size != 0) text.append(" pb=%s", pathHashBytesName(entry.path_hash_size)); if (entry.match_blacklisted_path) text.append(" p=bl"); - else if (entry.path_hops) { - text.append(" p="); - for (unsigned hop = 0; hop < entry.path_hops; hop++) { - if (hop) text.append(","); - for (unsigned byte = 0; byte < entry.path_hash_size; byte++) - text.append("%02X", entry.path[hop * entry.path_hash_size + byte]); - } - } + else if (entry.path_hops) appendPath(text, entry, 0); + appendExtras(text, entry, 0); switch (entry.incoming_scope_kind) { case RULE_IN_NONE: text.append(" i=n"); break; case RULE_IN_SCOPED: text.append(" i=s"); break; diff --git a/src/helpers/FloodRuleExtensions.h b/src/helpers/FloodRuleExtensions.h new file mode 100644 index 000000000..bd3b8358c --- /dev/null +++ b/src/helpers/FloodRuleExtensions.h @@ -0,0 +1,417 @@ +#pragma once + +#include +#include +#include +#include +#include "TxtDataHelpers.h" +#include "FloodTextPattern.h" +#include "FloodGroupPlainText.h" + +namespace mesh { class Utils; } + +namespace FloodRuleExtensions { + +enum Route : uint8_t { LegacyRoute, FloodRoute, DirectRoute, AnyRoute }; +enum PathPosition : uint8_t { Prefix, Anywhere, Suffix, Exact }; + +// Zero initialization retains every FPF7 default. Keep the old path's first +// nine bytes in its established field; only a fourth 3-byte ID needs a tail. +struct Fields { + uint16_t min_payload; + uint16_t max_payload; + uint8_t route; + uint8_t probability; + uint8_t path_position; + uint8_t disabled; + uint8_t payload_set; + uint8_t path_tail[3]; + char sender[64]; + char text[64]; +}; +static_assert(sizeof(Fields) <= 140, "Update the extended filter RAM budget"); + +inline bool hasFields(const Fields& fields) { + return fields.min_payload || fields.max_payload || fields.route + || fields.probability || fields.path_position || fields.disabled || fields.payload_set + || fields.path_tail[0] || fields.path_tail[1] || fields.path_tail[2] + || fields.sender[0] || fields.text[0]; +} + +inline bool sameFields(const Fields& left, const Fields& right) { + return left.min_payload == right.min_payload && left.max_payload == right.max_payload + && left.route == right.route && left.probability == right.probability + && left.path_position == right.path_position && left.disabled == right.disabled + && left.payload_set == right.payload_set + && !memcmp(left.path_tail, right.path_tail, sizeof(left.path_tail)) + && !strcmp(left.sender, right.sender) && !strcmp(left.text, right.text); +} + +template +inline bool tableUsesExtensions(const Entry* entries, unsigned count) { + for (unsigned i = 0; i < count; ++i) + if (entries[i].active && (hasFields(entries[i].extra) || entries[i].path_hops > 3)) return true; + return false; +} + +inline bool contentRequested(const Fields& fields) { + return fields.sender[0] || fields.text[0]; +} + +inline bool validFields(const Fields& fields) { + return fields.route <= AnyRoute && fields.probability <= 100 + && fields.path_position <= Exact && fields.disabled <= 1 && fields.payload_set <= 1 + && fields.min_payload <= MAX_PACKET_PAYLOAD + && fields.max_payload <= MAX_PACKET_PAYLOAD + && (!fields.payload_set || fields.min_payload <= fields.max_payload) + && memchr(fields.sender, 0, sizeof(fields.sender)) + && memchr(fields.text, 0, sizeof(fields.text)) + && mesh::flood::textPatternValid(fields.sender, strlen(fields.sender)) + && mesh::flood::textPatternValid(fields.text, strlen(fields.text)); +} + +template +inline bool validRuleFields(const Entry& entry) { + if (!validFields(entry.extra)) return false; + const bool path_valid = entry.path_hops == 0 + ? entry.path_hash_size <= 3 || entry.path_hash_size == 0x82 + : entry.path_hash_size >= 1 && entry.path_hash_size <= 3 + && entry.path_hops <= 4 && !entry.match_blacklisted_path; + const bool content_valid = !contentRequested(entry.extra) + || ((entry.channel_key_len == 16 || entry.channel_key_len == 32) + && (entry.payload_type == 0xFF || entry.payload_type == PAYLOAD_TYPE_GRP_TXT)); + const bool flood_actions = entry.scope_name[0] || entry.target_region_name[0] + || entry.retry_on_match || entry.scope_uses_slow_timing; + return path_valid && content_valid && (!flood_actions + || entry.extra.route == LegacyRoute || entry.extra.route == FloodRoute); +} + +// Strip grouping quotes, preserving regex escapes and all quoted whitespace. +// Token pointers borrow this buffer; malformed input never reaches a setter. +inline int tokenize(char* buffer, char* tokens[], unsigned capacity) { + char* read = buffer; + char* write = buffer; + unsigned count = 0; + while (*read) { + while (*read == ' ' || *read == '\t') ++read; + if (!*read) break; + if (count == capacity) return -1; + tokens[count++] = write; + char quote = 0; + while (*read) { + char c = *read++; + if (c == '\\' && *read) { + if (quote && (*read == quote || *read == '\\')) *write++ = *read++; + else { *write++ = c; *write++ = *read++; } + } else if (quote && c == quote) { + quote = 0; + } else if (!quote && (c == '"' || c == '\'')) { + quote = c; + } else if (!quote && (c == ' ' || c == '\t')) { + break; + } else { + *write++ = c; + } + } + if (quote) return -1; + *write++ = 0; + } + return int(count); +} + +inline bool unsignedValue(const char* text, uint16_t maximum, uint16_t& value) { + if (!text || !*text) return false; + uint32_t parsed = 0; + do { + if (*text < '0' || *text > '9') return false; + parsed = parsed * 10U + uint8_t(*text++ - '0'); + if (parsed > maximum) return false; + } while (*text); + value = uint16_t(parsed); + return true; +} + +inline bool enabledValue(const char* value, uint8_t& disabled) { + if (!strcmp(value, "on") || !strcmp(value, "1")) disabled = 0; + else if (!strcmp(value, "off") || !strcmp(value, "0")) disabled = 1; + else return false; + return true; +} + +// Return 0 for an existing-parser token, 1 when handled, -1 on invalid input. +inline int parseToken(const char* token, Fields& fields, uint8_t& seen, char* reply) { + const char* value = strchr(token, '='); + if (!value) return 0; + const size_t length = size_t(value++ - token); + unsigned bit; + if ((length == 6 && !strncmp(token, "sender", 6)) || (length == 1 && *token == 'n')) bit = 0; + else if ((length == 4 && !strncmp(token, "text", 4)) || (length == 1 && *token == 'x')) bit = 1; + else if ((length == 3 && !strncmp(token, "len", 3)) || (length == 1 && *token == 'l')) bit = 2; + else if ((length == 5 && !strncmp(token, "route", 5)) || (length == 2 && !strncmp(token, "rt", 2))) bit = 3; + else if ((length == 4 && !strncmp(token, "prob", 4)) || (length == 2 && !strncmp(token, "pr", 2))) bit = 4; + else if ((length == 7 && !strncmp(token, "enabled", 7)) || (length == 1 && *token == 'e')) bit = 5; + else return 0; + if (seen & (1U << bit)) { + strcpy(reply, "Err - duplicate rule parameter"); + return -1; + } + seen |= uint8_t(1U << bit); + bool valid = true; + if (bit <= 1) { + valid = *value && strlen(value) < sizeof(fields.sender) + && mesh::flood::textPatternValid(value, strlen(value)); + if (valid) strcpy(bit == 0 ? fields.sender : fields.text, value); + else strcpy(reply, "Err - invalid pattern (maximum 63 bytes)"); + } else if (bit == 2) { + char range[20]; + valid = strlen(value) < sizeof(range); + if (valid) { + strcpy(range, value); + char* separator = strchr(range, '-'); + char* plus = strchr(range, '+'); + if (separator) *separator++ = 0; + if (plus) *plus = 0; + valid = unsignedValue(range, MAX_PACKET_PAYLOAD, fields.min_payload); + if (valid && separator) valid = !plus + && unsignedValue(separator, MAX_PACKET_PAYLOAD, fields.max_payload) + && fields.min_payload <= fields.max_payload; + else if (valid) { + valid = !plus || plus[1] == 0; + fields.max_payload = plus ? MAX_PACKET_PAYLOAD : fields.min_payload; + } + } + if (valid) fields.payload_set = 1; + if (!valid) strcpy(reply, "Err - len is N, N+, or N-M payload bytes"); + } else if (bit == 3) { + if (!strcmp(value, "flood") || !strcmp(value, "f")) fields.route = FloodRoute; + else if (!strcmp(value, "direct") || !strcmp(value, "d")) fields.route = DirectRoute; + else if (!strcmp(value, "any") || !strcmp(value, "a")) fields.route = AnyRoute; + else { valid = false; strcpy(reply, "Err - route is flood, direct, or any"); } + } else if (bit == 4) { + uint16_t probability; + valid = unsignedValue(value, 100, probability) && probability != 0; + if (valid) fields.probability = probability == 100 ? 0 : uint8_t(probability); + else strcpy(reply, "Err - prob must be 1-100"); + } else { + valid = enabledValue(value, fields.disabled); + if (!valid) strcpy(reply, "Err - enabled is on or off"); + } + return valid ? 1 : -1; +} + +inline bool routeMatches(const Fields& fields, const mesh::Packet* packet, uint8_t context) { + if (!packet) return false; + if (fields.route == LegacyRoute) return context != 0 || packet->isRouteFlood(); + return fields.route == AnyRoute || (fields.route == FloodRoute ? packet->isRouteFlood() : packet->isRouteDirect()); +} + +inline bool matchesEnvelope(const Fields& fields, const mesh::Packet* packet, uint8_t context) { + return packet && !fields.disabled && routeMatches(fields, packet, context) + && (!fields.payload_set || (packet->payload_len >= fields.min_payload + && packet->payload_len <= fields.max_payload)); +} + +inline bool matchesProbability(const Fields& fields, uint32_t roll) { + return fields.probability == 0 || fields.probability == 100 + || roll % 100U < fields.probability; +} + +// A received packet can be evaluated during both timing and forwarding. +// Use one boot-randomized decision per payload/rule/admission phase, stable +// across path growth and scope rewrites. Repeated evaluation cannot reroll it. +template +inline uint32_t probabilityRoll(const PacketType* packet, uint32_t seed, + unsigned slot, uint8_t context) { + uint32_t hash = 2166136261UL; + const auto add = [&hash](uint8_t byte) { hash = (hash ^ byte) * 16777619UL; }; + for (unsigned i = 0; i < 4; ++i) add(uint8_t(seed >> (8 * i))); + add(uint8_t(slot)); add(context); + if (packet) { + add(packet->getPayloadType()); + add(uint8_t(packet->payload_len)); add(uint8_t(packet->payload_len >> 8)); + const unsigned length = packet->payload_len <= MAX_PACKET_PAYLOAD + ? packet->payload_len : MAX_PACKET_PAYLOAD; + for (unsigned i = 0; i < length; ++i) add(packet->payload[i]); + } + hash ^= hash >> 16; + hash *= 0x85ebca6bUL; + hash ^= hash >> 13; + hash *= 0xc2b2ae35UL; + return hash ^ (hash >> 16); +} + +inline int hexValue(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +inline bool parsePath(const char* value, uint8_t& width, uint8_t& hops, + uint8_t first[9], Fields& fields, bool prefix_legacy) { + memset(first, 0, 9); + memset(fields.path_tail, 0, sizeof(fields.path_tail)); + width = hops = 0; + fields.path_position = prefix_legacy ? Prefix : Anywhere; + if (!strcmp(value, "*")) { fields.path_position = Prefix; return true; } + bool start = *value == '^'; + if (start) ++value; + const size_t size = strlen(value); + bool end = size && value[size - 1] == '$'; + const char* finish = value + size - (end ? 1 : 0); + if (start || end) fields.path_position = start ? (end ? Exact : Prefix) : Suffix; + uint8_t bytes[12] = {}; + while (value < finish) { + const char* next = value; + while (next < finish && *next != '>' && *next != ',') ++next; + const size_t digits = size_t(next - value); + if ((digits != 2 && digits != 4 && digits != 6) || hops >= 4 + || (width && digits != size_t(width) * 2)) return false; + width = uint8_t(digits / 2); + for (unsigned i = 0; i < width; ++i) { + const int high = hexValue(value[i * 2]), low = hexValue(value[i * 2 + 1]); + if (high < 0 || low < 0) return false; + bytes[hops * width + i] = uint8_t((high << 4) | low); + } + ++hops; + if (next == finish) break; + value = next + 1; + if (value == finish) return false; + } + if (!hops) return false; + memcpy(first, bytes, 9); + memcpy(fields.path_tail, bytes + 9, 3); + return true; +} + +inline bool pathMatches(const Fields& fields, const mesh::Packet* packet, + uint8_t width, uint8_t hops, const uint8_t first[9]) { + if (!hops) return true; // Existing hash-width-only condition is checked separately. + if (!packet || !packet->isRouteFlood() || packet->getPayloadType() == 9 + || width < 1 || width > 3 || width != packet->getPathHashSize() + || hops > 4 || packet->getPathHashCount() < hops + || unsigned(packet->getPathHashCount()) * width > MAX_PATH_SIZE) return false; + const unsigned count = packet->getPathHashCount(); + if (fields.path_position == Exact && count != hops) return false; + uint8_t wanted[12]; + memcpy(wanted, first, 9); + memcpy(wanted + 9, fields.path_tail, 3); + unsigned begin = fields.path_position == Suffix ? count - hops : 0; + unsigned last = fields.path_position == Anywhere ? count - hops : begin; + for (unsigned at = begin; at <= last; ++at) + if (!memcmp(packet->path + at * width, wanted, hops * width)) return true; + return false; +} + +template +inline bool matchesContent(const Fields& fields, const PacketType* packet, + const uint8_t* secret, uint8_t key_len, + uint32_t* work = nullptr, bool conservative = false, + bool* budget_exceeded = nullptr) { + if (budget_exceeded) *budget_exceeded = false; + if (!contentRequested(fields)) return true; + if (!packet || !secret || (key_len != 16 && key_len != 32) + || packet->getPayloadType() != PAYLOAD_TYPE_GRP_TXT + || packet->payload_len <= PATH_HASH_SIZE + CIPHER_MAC_SIZE) return false; + uint8_t plain[MAX_PACKET_PAYLOAD]; + int size = Crypto::MACThenDecrypt(secret, plain, + packet->payload + PATH_HASH_SIZE, packet->payload_len - PATH_HASH_SIZE); + mesh::FloodGroupPlainText::View view; + if (size <= 0 || !mesh::FloodGroupPlainText::parse( + plain, size_t(size), TXT_TYPE_PLAIN, view)) return false; + using mesh::flood::PatternMatchResult; + if (fields.sender[0]) { + if (!view.has_sender) return false; + const auto result = mesh::flood::matchTextPattern(fields.sender, strlen(fields.sender), + view.sender.data, view.sender.size, false, work); + if (result == PatternMatchResult::BudgetExceeded) { + if (budget_exceeded) *budget_exceeded = true; + return conservative; + } + if (result != PatternMatchResult::Match) return false; + } + if (!fields.text[0]) return true; + const auto result = mesh::flood::matchTextPattern(fields.text, strlen(fields.text), + view.text.data, view.text.size, false, work); + if (result == PatternMatchResult::BudgetExceeded && budget_exceeded) + *budget_exceeded = true; + return result == PatternMatchResult::Match + || (result == PatternMatchResult::BudgetExceeded && conservative); +} + +template +bool readFields(File& file, Fields& fields) { + memset(&fields, 0, sizeof(fields)); + auto read = [&file](void* out, size_t size) { + return file.read(static_cast(out), size) == size; + }; + uint8_t sender_size = 0, text_size = 0; + return read(&fields.min_payload, 2) && read(&fields.max_payload, 2) + && read(&fields.route, 1) && read(&fields.probability, 1) + && read(&fields.path_position, 1) && read(&fields.disabled, 1) + && read(&fields.payload_set, 1) + && read(fields.path_tail, 3) + && read(&sender_size, 1) && sender_size < sizeof(fields.sender) + && read(fields.sender, sender_size) + && read(&text_size, 1) && text_size < sizeof(fields.text) + && read(fields.text, text_size) && validFields(fields); +} + +template +bool writeFields(Writer& write, const Fields& fields) { + uint8_t sender_size = uint8_t(strlen(fields.sender)); + uint8_t text_size = uint8_t(strlen(fields.text)); + return write(&fields.min_payload, 2) && write(&fields.max_payload, 2) + && write(&fields.route, 1) && write(&fields.probability, 1) + && write(&fields.path_position, 1) && write(&fields.disabled, 1) + && write(&fields.payload_set, 1) + && write(fields.path_tail, 3) + && write(&sender_size, 1) && write(fields.sender, sender_size) + && write(&text_size, 1) && write(fields.text, text_size); +} + +template +void appendQuoted(Writer& writer, const char* key, const char* value) { + writer.append(" %s=\"", key); + for (const char* p = value; *p; ++p) { + if (*p == '"' || *p == '\\') writer.append("\\"); + writer.append("%c", *p); + } + writer.append("\""); +} + +template +void appendFields(Writer& writer, const Fields& fields, bool compact) { + if (fields.sender[0]) appendQuoted(writer, compact ? "n" : "sender", fields.sender); + if (fields.text[0]) appendQuoted(writer, compact ? "x" : "text", fields.text); + if (fields.payload_set) { + const char* key = compact ? "l" : "len"; + if (fields.max_payload == fields.min_payload) writer.append(" %s=%u", key, unsigned(fields.min_payload)); + else if (fields.max_payload == MAX_PACKET_PAYLOAD) writer.append(" %s=%u+", key, unsigned(fields.min_payload)); + else writer.append(" %s=%u-%u", key, unsigned(fields.min_payload), unsigned(fields.max_payload)); + } + if (fields.route) writer.append(" %s=%s", compact ? "rt" : "route", + fields.route == FloodRoute ? (compact ? "f" : "flood") + : fields.route == DirectRoute ? (compact ? "d" : "direct") : (compact ? "a" : "any")); + if (fields.probability) + writer.append(" %s=%u", compact ? "pr" : "prob", unsigned(fields.probability)); + if (fields.disabled) writer.append(" %s=off", compact ? "e" : "enabled"); +} + +template +void appendPath(Writer& writer, const Fields& fields, uint8_t width, + uint8_t hops, const uint8_t first[9], bool compact) { + if (!hops) return; + const bool legacy = fields.path_position == Prefix && hops <= 3; + writer.append(" %s=", legacy ? (compact ? "p" : "prefix") : (compact ? "pa" : "path")); + if (!legacy && (fields.path_position == Prefix || fields.path_position == Exact)) writer.append("^"); + uint8_t path[12]; + memcpy(path, first, 9); memcpy(path + 9, fields.path_tail, 3); + for (unsigned i = 0; i < hops; ++i) { + if (i) writer.append(legacy ? "," : ">"); + for (unsigned j = 0; j < width; ++j) writer.append("%02X", unsigned(path[i * width + j])); + } + if (fields.path_position == Suffix || fields.path_position == Exact) writer.append("$"); +} + +} // namespace FloodRuleExtensions diff --git a/src/helpers/FloodTextPattern.h b/src/helpers/FloodTextPattern.h new file mode 100644 index 000000000..5b432895e --- /dev/null +++ b/src/helpers/FloodTextPattern.h @@ -0,0 +1,319 @@ +#pragma once + +#include +#include +#include + +namespace mesh { +namespace flood { + +// Byte patterns: substring search by default, ^/$ anchors, ., escaped literals, +// classes/ranges/[^...], groups, alternation, and *, +, ? repetition. Matching is +// case-sensitive unless ASCII case folding is explicitly requested. No heap or +// backtracking is used; one budget can be shared across every rule for a packet. +static constexpr size_t MAX_TEXT_PATTERN_LENGTH = 63; +static constexpr uint32_t TEXT_PATTERN_WORK_BUDGET = 32768; + +enum class PatternMatchResult : uint8_t { + NoMatch, + Match, + Invalid, + BudgetExceeded, +}; + +namespace pattern_detail { + +static constexpr uint8_t NO_EDGE = 255; +static constexpr uint8_t MAX_STATES = 96; +static constexpr uint8_t MAX_GROUP_DEPTH = 8; +enum Op : uint8_t { Literal, Any, Class, Begin, End, Split, Jump, Accept }; + +struct State { + uint8_t op, out, other, value; +}; + +struct Fragment { + uint8_t start, head, tail; +}; + +// Thompson fragments use unfinished edges as a linked patch list while +// compiling. After compilation all those edges contain ordinary state indices. +class Compiler { + const char* pattern; + size_t length, cursor; + State* states; + uint8_t used, depth; + bool good; + + uint8_t emit(Op op, uint8_t value = 0) { + if (used == MAX_STATES) { good = false; return 0; } + const uint8_t index = used++; + states[index] = {op, NO_EDGE, NO_EDGE, value}; + return index; + } + + uint8_t& edge(uint8_t index) { + return index & 1 ? states[index / 2].other : states[index / 2].out; + } + + Fragment single(Op op, uint8_t value = 0) { + const uint8_t index = emit(op, value); + return {index, uint8_t(index * 2), uint8_t(index * 2)}; + } + + void patch(Fragment fragment, uint8_t target) { + uint8_t link = fragment.head; + while (link != NO_EDGE) { + const uint8_t next = edge(link); + edge(link) = target; + link = next; + } + } + + Fragment append(Fragment left, Fragment right) { + edge(left.tail) = right.head; + return {left.start, left.head, right.tail}; + } + + bool classChar(uint8_t& value) { + if (cursor == length || pattern[cursor] == ']') return false; + value = uint8_t(pattern[cursor++]); + if (value == '\\') { + if (cursor == length) return false; + value = uint8_t(pattern[cursor++]); + } + return true; + } + + bool characterClass() { + if (cursor < length && pattern[cursor] == '^') ++cursor; + bool any = false; + while (cursor < length && pattern[cursor] != ']') { + uint8_t first, last; + if (!classChar(first)) return false; + any = true; + if (cursor + 1 < length && pattern[cursor] == '-' && pattern[cursor + 1] != ']') { + ++cursor; + if (!classChar(last) || first > last) return false; + } + } + if (!any || cursor == length) return false; + ++cursor; + return true; + } + + Fragment atom() { + const uint8_t value = uint8_t(pattern[cursor++]); + switch (value) { + case '(' : { + if (++depth > MAX_GROUP_DEPTH) { good = false; return single(Jump); } + Fragment result = expression(); + --depth; + if (cursor == length || pattern[cursor++] != ')') good = false; + return result; + } + case '[' : { + const uint8_t offset = uint8_t(cursor); + if (!characterClass()) good = false; + return single(Class, offset); + } + case '\\' : + if (cursor == length) { good = false; return single(Jump); } + return single(Literal, uint8_t(pattern[cursor++])); + case '.' : return single(Any); + case '^' : return single(Begin); + case '$' : return single(End); + case '*' : case '+' : case '?' : case ']' : + good = false; + return single(Jump); + default : return single(Literal, value); + } + } + + Fragment sequence() { + Fragment result = {NO_EDGE, NO_EDGE, NO_EDGE}; + while (good && cursor < length && pattern[cursor] != ')' && pattern[cursor] != '|') { + const uint8_t first = uint8_t(pattern[cursor]); + Fragment next = atom(); + if (!good) return next; + if (good && cursor < length && (pattern[cursor] == '*' || pattern[cursor] == '+' || pattern[cursor] == '?')) { + const char repeat = pattern[cursor++]; + if (first == '^' || first == '$') { good = false; break; } + const uint8_t split = emit(Split); + if (!good) return next; + states[split].out = next.start; + const uint8_t tail = uint8_t(split * 2 + 1); + if (repeat == '?') { + const uint8_t start = split; + next = append(next, {start, tail, tail}); + next.start = start; + } else { + const uint8_t start = repeat == '*' ? split : next.start; + patch(next, split); + next = {start, tail, tail}; + } + // Lazy/stacked quantifiers are deliberately unsupported. + if (cursor < length && (pattern[cursor] == '*' || pattern[cursor] == '+' || pattern[cursor] == '?')) good = false; + } + if (result.start == NO_EDGE) result = next; + else { patch(result, next.start); result.head = next.head; result.tail = next.tail; } + } + return result.start == NO_EDGE ? single(Jump) : result; + } + + Fragment expression() { + Fragment result = sequence(); + while (good && cursor < length && pattern[cursor] == '|') { + ++cursor; + Fragment right = sequence(); + if (!good) return right; + const uint8_t split = emit(Split); + if (!good) return result; + states[split].out = result.start; + states[split].other = right.start; + result = append(result, right); + result.start = split; + } + return result; + } + + public: + Compiler(const char* text, size_t size, State* output) + : pattern(text), length(size), cursor(0), states(output), used(0), depth(0), good(true) {} + + bool compile(uint8_t& start) { + if (!pattern || length > MAX_TEXT_PATTERN_LENGTH) return false; + // NUL is data in the input text, but cannot appear inside a CLI pattern. + if (memchr(pattern, 0, length)) return false; + Fragment fragment = expression(); + if (!good || cursor != length) return false; + const uint8_t finish = emit(Accept); + if (!good) return false; + patch(fragment, finish); + start = fragment.start; + return true; + } +}; + +inline bool spend(uint32_t& budget) { + if (!budget) return false; + --budget; + return true; +} + +struct StateList { + uint8_t indices[MAX_STATES]; + uint8_t seen[(MAX_STATES + 7) / 8]; + uint8_t count; + + void clear() { memset(seen, 0, sizeof(seen)); count = 0; } + void push(uint8_t index) { + const uint8_t bit = uint8_t(1U << (index & 7)); + if (seen[index / 8] & bit) return; + seen[index / 8] |= bit; + indices[count++] = index; + } + + bool add(const State* states, uint8_t start, size_t position, size_t length, uint32_t& budget) { + const uint8_t first = count; + push(start); + for (uint8_t cursor = first; cursor < count; ++cursor) { + if (!spend(budget)) return false; + const State& state = states[indices[cursor]]; + switch (state.op) { + case Split: push(state.other); push(state.out); break; + case Jump: push(state.out); break; + case Begin: if (position == 0) push(state.out); break; + case End: if (position == length) push(state.out); break; + default: break; + } + } + return true; + } +}; + +inline uint8_t fold(uint8_t byte) { + return byte >= 'A' && byte <= 'Z' ? uint8_t(byte + ('a' - 'A')) : byte; +} + +inline uint8_t classChar(const char* pattern, uint8_t& cursor) { + uint8_t byte = uint8_t(pattern[cursor++]); + if (byte == '\\') byte = uint8_t(pattern[cursor++]); + return byte; +} + +inline int classMatches(const char* pattern, uint8_t cursor, uint8_t byte, bool ignoreCase, uint32_t& budget) { + const bool negate = pattern[cursor] == '^'; + if (negate) ++cursor; + bool matched = false; + while (pattern[cursor] != ']') { + if (!spend(budget)) return -1; + const uint8_t first = classChar(pattern, cursor); + uint8_t last = first; + if (pattern[cursor] == '-' && pattern[cursor + 1] != ']') { + ++cursor; + last = classChar(pattern, cursor); + } + // Test the original interval and the opposite ASCII case. This keeps + // mixed ranges such as [A-z] faithful when case folding is enabled. + matched |= byte >= first && byte <= last; + if (ignoreCase) { + const uint8_t folded = fold(byte); + matched |= folded >= first && folded <= last; + if (folded >= 'a' && folded <= 'z') { + const uint8_t upper = uint8_t(folded - ('a' - 'A')); + matched |= upper >= first && upper <= last; + } + } + } + return matched != negate; +} + +} // namespace pattern_detail + +inline bool textPatternValid(const char* pattern, size_t patternLength) { + pattern_detail::State states[pattern_detail::MAX_STATES]; + uint8_t start; + return pattern_detail::Compiler(pattern, patternLength, states).compile(start); +} + +inline PatternMatchResult matchTextPattern(const char* pattern, size_t patternLength, + const uint8_t* text, size_t textLength, + bool ignoreCase = false, uint32_t* remainingWork = nullptr) { + using namespace pattern_detail; + if (!text && textLength) return PatternMatchResult::Invalid; + State states[MAX_STATES]; + uint8_t start; + if (!Compiler(pattern, patternLength, states).compile(start)) return PatternMatchResult::Invalid; + uint32_t localBudget = TEXT_PATTERN_WORK_BUDGET; + uint32_t& budget = remainingWork ? *remainingWork : localBudget; + StateList lists[2]; + StateList* current = &lists[0]; + StateList* next = &lists[1]; + current->clear(); + for (size_t position = 0;; ++position) { + // Injecting the start state at each byte provides substring search without + // restarting the automaton or rescanning any input prefix. + if (!current->add(states, start, position, textLength, budget)) return PatternMatchResult::BudgetExceeded; + next->clear(); + for (uint8_t cursor = 0; cursor < current->count; ++cursor) { + if (!spend(budget)) return PatternMatchResult::BudgetExceeded; + const State& state = states[current->indices[cursor]]; + if (state.op == Accept) return PatternMatchResult::Match; + if (position == textLength) continue; + bool matches = state.op == Any; + if (state.op == Literal) matches = ignoreCase ? fold(text[position]) == fold(state.value) : text[position] == state.value; + else if (state.op == Class) { + const int result = classMatches(pattern, state.value, text[position], ignoreCase, budget); + if (result < 0) return PatternMatchResult::BudgetExceeded; + matches = result != 0; + } + if (matches && !next->add(states, state.out, position + 1, textLength, budget)) return PatternMatchResult::BudgetExceeded; + } + if (position == textLength) return PatternMatchResult::NoMatch; + StateList* old = current; current = next; next = old; + } +} + +} // namespace flood +} // namespace mesh diff --git a/test/test_client_acl_cli.py b/test/test_client_acl_cli.py index 5939fc9f7..bae101d0e 100644 --- a/test/test_client_acl_cli.py +++ b/test/test_client_acl_cli.py @@ -71,7 +71,8 @@ class ClientAclCliTest(unittest.TestCase): sources = {role: path.read_text() for role, path in ROLES.items()} utils = (ROOT / "src/Utils.cpp").read_text() hex_chars = re.search(r"static const char hex_chars\[\].*;", utils).group() - generated = "namespace mesh {\n" + hex_chars + "\n" + generated = f'#include "{(ROOT / "src/helpers/FloodCommandAliases.h").as_posix()}"\n' + generated += "namespace mesh {\n" + hex_chars + "\n" generated += extract_braced(utils, "void Utils::toHex(") + "\n" for signature in ("void Utils::printHex(", "static uint8_t hexVal(", "bool Utils::isHexChar(", "bool Utils::fromHex("): diff --git a/test/test_firmware_ram.py b/test/test_firmware_ram.py index 5c4ba97cb..6c9a0ea9b 100644 --- a/test/test_firmware_ram.py +++ b/test/test_firmware_ram.py @@ -357,7 +357,7 @@ int main() { "ENABLE_OTA": 1, "OTA_HEAP_CONTEXT": 1, }, "GEPRC_Linkflow_900_repeater") self.assertEqual(policy["components"]["client_table"], 32 * 320 + 16) - self.assertEqual(policy["components"]["flood_filter_table"], 63 * 192 + 16) + self.assertEqual(policy["components"]["flood_filter_table"], 63 * 336 + 16) self.assertEqual(policy["components"]["ota_context"], 16384 + 16) self.assertGreaterEqual(policy["required_contiguous_bytes"], 16384 + 16) reduced = ram.requirements("STM32_PLATFORM", { @@ -589,7 +589,7 @@ int main() { self.assertEqual(components["display_pixels_and_driver"], 2048) self.assertEqual(combined["required_heap_bytes"] - legacy["required_heap_bytes"], 3072 - 2048) self.assertEqual(components["loop_and_callback_stacks"], 8192 + 3072) - self.assertEqual(components["flood_filter_table"], 63 * 192 + 16) + self.assertEqual(components["flood_filter_table"], 63 * 336 + 16) # An alternate port selects the same bridge instead of allocating two. alternate = ram.requirements("NRF52_PLATFORM", { **defines, "RAK4631_COMBINED_ETHERNET": 1, @@ -700,7 +700,7 @@ int main() { alias = ram.requirements("ESP32_PLATFORM", {"MESH_CLIENT_REPEATER_ONLY": 1}, "custom_companion_alias") self.assertEqual(alias["components"]["client_table"], 32 * 284 + 16) - self.assertEqual(alias["components"]["flood_filter_table"], 63 * 192 + 16) + self.assertEqual(alias["components"]["flood_filter_table"], 63 * 336 + 16) self.assertEqual(alias["components"]["neighbor_history"], 2048 * 9 + 16) self.assertEqual(alias["components"]["radio_packet_pool"], 10240) @@ -732,13 +732,13 @@ int main() { } full = ram.requirements("ESP32_PLATFORM", defines, "Heltec_v3_repeater_observer_mqtt") parts = full["components"] - self.assertEqual(full["required_heap_bytes"], 209696) + self.assertEqual(full["required_heap_bytes"], 218768) self.assertEqual(parts["display_pixels_and_driver"], 2048) self.assertEqual(parts["uart_bridge_and_driver"], 3072 + 4096) self.assertEqual(parts["wireless_stacks"], 49152) self.assertEqual(parts["mqtt_startup_allowance"], 24576) self.assertEqual(parts["allocation_and_transient_margin"], 16384) - self.assertEqual(full["required_contiguous_bytes"], 18464) + self.assertEqual(full["required_contiguous_bytes"], 21184) for driver, expected in (("SH1106Display", 2048), ("SH1107Display", 4096), ("ST7735Display", 25602)): with self.subTest(driver=driver): @@ -749,7 +749,7 @@ int main() { self.assertEqual(other["components"], { **parts, "display_pixels_and_driver": expected, }) - self.assertEqual(other["required_contiguous_bytes"], max(18464, expected)) + self.assertEqual(other["required_contiguous_bytes"], max(21184, expected)) def test_sh1106_full_mqtt_release_profile_is_linked_in_ci(self): workflow = (ROOT / ".github/workflows/run-unit-tests.yml").read_text() diff --git a/test/test_fleet_command/test_fleet_command.cpp b/test/test_fleet_command/test_fleet_command.cpp index 917a13110..a0ee2aa0b 100644 --- a/test/test_fleet_command/test_fleet_command.cpp +++ b/test/test_fleet_command/test_fleet_command.cpp @@ -117,6 +117,8 @@ TEST_F(FleetCommandTest, ExplicitRadioAndFilterFamiliesAreAllowed) { "del flood.filter.blacklist.2", "del flood.filter all", "get flood.rule.2", "set flood.rule.2 type=any rate=10/min stop", "del flood.rule.2", "del flood.rule all", + "get fr", "get fr.2", "set fr.2 any c=public q=10 s", + "del fr.2", "del fr all", "get flood.moderation.2", "set flood.moderation public '*' rate=2/min", "del flood.moderation all", "get flood.channel.scope.3", "set flood.channel.scope public scope=usa", "del flood.channel.scope.3", @@ -246,6 +248,9 @@ TEST_F(FleetCommandTest, NoGenericAdminNamespaceOrPrefixEscapes) { "set tx.reply both", "get tempradio", "set tempradio 910.5,500,5,5,10", "get flood.filter.secret", "get flood.filtering", "get flood.rulebook", "get flood.rule.1.secret", "get flood.filter.blacklistx", + "get fridge", "get fr.secret", "get fr.1.secret", "get fr.0", + "get fr.01", "get fr.256", "get fr.9999999999", "get fr.2 all", + "set frwifi.password secret", "set fr.2", "del fr.2 extra", "get flood.channel.scope.required", "get flood.channel.data.secret", "get flood.max.secret", "get flood.max.unscoped.extra", "get flood.rule.0", "get flood.rule.01", "get flood.rule.256", "get flood.rule.9999999999", diff --git a/test/test_fleet_command_crypto.py b/test/test_fleet_command_crypto.py index 4ab6df52b..1e9e69cde 100644 --- a/test/test_fleet_command_crypto.py +++ b/test/test_fleet_command_crypto.py @@ -377,6 +377,51 @@ class FleetCommandCryptoTests(unittest.TestCase): with self.subTest(command=command): self.assertEqual(self.decode(envelope(command)), "reject") + def test_filter_shorthand_uses_the_same_signed_management_envelope(self): + for command in ["get fr", "get fr.2", "get fr.2 n", "get flood.rule.2 text", + "set fr.2 any c=public q=10 s", + "del fr.2", "del fr all", "get fm", "get fs.1", "get fsr.1", + "get fb", "set fm.2 public Alice drop", "set fs.2 public scope=local", + "set fsr.2 public", "set fb.2 AABBCC", "del fm.2", "del fs.2", + "del fsr.2", "del fb.2", "get fa", "get fa 2", "get fa key 1", + "clear fa all", "clear flood.advert " + "11" * 32]: + with self.subTest(command=command): + expected = envelope(command) + actual = self.run_tool("encode", CHANNEL.hex(), "all", NOW, NOW + 120, command) + self.assertEqual(actual, expected.hex()) + self.assertEqual(self.decode(expected), f"{NOW}\n{NOW + 120}\n{command}") + + for command in ["get fridge", "get fr.secret", "get fr.1.secret", "get fr.0", + "get fr.01", "get fr.256", "get fr.9999999999", "get fr.2 all", + "set frwifi.password secret", "set fr.2", "del fr.2 extra", + "get fmore", "get fsr.secret", "get fb.0", "set fa off", + "clear fa", "clear fa 0102", "clear fa ALL", "get fa key", + "get fa 0", "get fa 01", "get fa 65536", "get fa key 1 extra"]: + with self.subTest(command=command): + self.assertEqual(self.decode(envelope(command)), "reject") + + def test_filter_patterns_preserve_alternation_and_escapes_without_new_command_namespaces(self): + for family in ["fr.4", "flood.rule.4", "flood.filter.4"]: + for fields in [r'sender=^(Alice|Bob)$ text=^BEACON\.[0-9]+$', + r'n="^(Alice|Bob) Smith$" x="^RX in place\?"', + r'n=^A\|B$ x="a\\b"']: + command = f"set {family} any c=public {fields} d" + with self.subTest(command=command): + self.assertEqual(self.decode(envelope(command)), f"{NOW}\n{NOW + 120}\n{command}") + self.assertEqual(self.run_tool("encode", CHANNEL.hex(), "all", NOW, NOW + 120, command), + envelope(command).hex()) + + for command in [r'set flood.rule.1 any c=#a|reboot d', + r'set fr.1 any c=#a\reboot d', r'set fr.1 any x="a|b" c=#a|reboot d', + r'set fr.1 any n="a|b"\reboot d', r'set fr.1 any n="a|b d', + r'set fr.1 any n="a|b"reboot d', r'set fm.1 public a|b drop', + r'set flood.channel.scope public scope=a|b', + r'set fr.1 any text="a;reboot" d', r'set fr.1 any text="a&b" d', + r'set fr.1 any text="a`b" d', r'get radio2|reboot', + r'set flood.max a|b', r'get fr.1|reboot']: + with self.subTest(command=command): + self.assertEqual(self.decode(envelope(command)), "reject") + def test_scheduled_radio_commands_have_cross_language_real_signatures(self): for command in [ "set radioat 910.5,500,5,5,+1,auto", diff --git a/test/test_fleet_role_wiring.py b/test/test_fleet_role_wiring.py index 6202f645e..2e0d87c9d 100644 --- a/test/test_fleet_role_wiring.py +++ b/test/test_fleet_role_wiring.py @@ -363,6 +363,7 @@ class FleetRoleWiringTests(unittest.TestCase): common = body(management, "bool CommonCLI::handleFleetCommand(") boot = body(body(management, "void CommonCLI::beginManagement("), "if (_callbacks->supportsFleetControl()") + helpers = f'#include "{(ROOT / "src/helpers/FloodCommandAliases.h").as_posix()}"\n' + helpers harness = AUTHORIZATION.replace("@MANAGER_HELPERS@", helpers) harness = harness.replace("@COMMON_FLEET@", common).replace("@MANAGER_GUARD@", guard) harness = harness.replace("@BOOT_FLEET@", boot) diff --git a/test/test_flood_moderation.py b/test/test_flood_moderation.py new file mode 100644 index 000000000..3e3c8e645 --- /dev/null +++ b/test/test_flood_moderation.py @@ -0,0 +1,469 @@ +"""Execute real moderation decoding and FGM1 saves with reset and I/O faults.""" +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile +import unittest + +from test_radio_receive_contract import method +from test_serial_wifi_sessions import SANITIZER_FLAGS + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#include +#define PUB_KEY_SIZE 32 +#define CIPHER_KEY_SIZE 16 +#define CIPHER_BLOCK_SIZE 16 +#define CIPHER_MAC_SIZE 2 +#define PATH_HASH_SIZE 1 +#define MAX_PACKET_PAYLOAD 250 +#define TXT_TYPE_PLAIN 0 +#define PAYLOAD_TYPE_GRP_TXT 5 +#define FLOOD_GROUP_MODERATION_SLOTS 16 +#define FLOOD_CHANNEL_KEY_PREFIX_LEN 8 +#define FLOOD_GROUP_MODERATION_NAME_LEN 32 +#define FLOOD_GROUP_MODERATION_PATH_HOPS_MAX 3 +#define FLOOD_GROUP_MODERATION_PATH_BYTES_MAX 9 +#define FLOOD_GROUP_MODERATION_HOPS_ALL 255 +#define FLOOD_GROUP_MODERATION_RATE_UNLIMITED 65535 +#define FLOOD_PACKET_FILTER_MAX_HOPS 63 +#define FLOOD_CHANNEL_SCOPE_SLOTS 31 +#define FLOOD_RETRY_BRIDGE_BUCKETS 6 +#define FLOOD_GROUP_MODERATION_FILE "/flood_grp_mod" +#define FLOOD_GROUP_MODERATION_TEMP_FILE "/flood_grp_mod.tmp" +#define FLOOD_GROUP_MODERATION_BACKUP_FILE "/flood_grp_mod.bak" +#define FILE_O_WRITE 1 +#define MESH_DEBUG_PRINTLN(...) ((void)0) +struct PowerCut {}; +struct MockFS; +struct File { + MockFS* fs = nullptr; + std::string path; + size_t cursor = 0; + bool valid = false; + explicit operator bool() const { return valid; } + size_t size() const; + size_t read(uint8_t*, size_t); + size_t write(const uint8_t*, size_t); + void close(); + void flush(); +}; +struct MockFS { + std::map> files; + unsigned operations = 0, cut_after = 0; + bool short_write = false, corrupt_write = false; + std::string fail_open_path, fail_read_path, fail_rename_from; + std::string false_after_rename, noop_rename_from, corrupt_rename_to; + void checkpoint() { if (++operations == cut_after) throw PowerCut{}; } + bool exists(const char* path) { bool found = files.count(path); checkpoint(); return found; } + bool remove(const char* path) { bool removed = files.erase(path); checkpoint(); return removed; } + bool rename(const char* from, const char* to) { + if (fail_rename_from == from) { checkpoint(); return false; } + if (noop_rename_from == from) { checkpoint(); return true; } + if (!files.count(from) || files.count(to)) { checkpoint(); return false; } + files[to] = files[from]; files.erase(from); + if (corrupt_rename_to == to) files[to][0] ^= 0x40; + checkpoint(); return false_after_rename != from; + } + File open(const char* path, const char* mode, bool = false) { + if (fail_open_path == path) { checkpoint(); return {}; } + if (*mode == 'w') files[path].clear(); + File file{this, path, 0, files.count(path) != 0}; checkpoint(); return file; + } + File open(const char* path, int mode) { return open(path, mode ? "w" : "r"); } +}; +size_t File::size() const { return fs->files.at(path).size(); } +size_t File::read(uint8_t* out, size_t size) { + if (fs->fail_read_path == path) { fs->checkpoint(); return 0; } + size = std::min(size, fs->files[path].size() - cursor); + memcpy(out, fs->files[path].data() + cursor, size); cursor += size; + fs->checkpoint(); return size; +} +size_t File::write(const uint8_t* in, size_t size) { + if (fs->short_write) size /= 2; + auto& bytes = fs->files[path]; bytes.insert(bytes.end(), in, in + size); + if (fs->corrupt_write && size) bytes.back() ^= 0x80; + fs->checkpoint(); return size; +} +void File::close() { fs->checkpoint(); } +void File::flush() { fs->checkpoint(); } +#define FILESYSTEM MockFS +namespace mesh { +struct Packet { + uint8_t payload[MAX_PACKET_PAYLOAD] = {}, payload_len = 19; + uint8_t hops = 2, path[9] = {}, width = 1, type = PAYLOAD_TYPE_GRP_TXT; + bool flood = true; + bool isRouteFlood() const { return flood; } + uint8_t getPayloadType() const { return type; } + uint8_t getPathHashCount() const { return hops; } + uint8_t getPathHashSize() const { return width; } +}; +static std::vector plaintext; +static bool authenticated = true; +struct Utils { + static bool isHexChar(char c) { return (c>='0'&&c<='9')||(c>='A'&&c<='F')||(c>='a'&&c<='f'); } + static bool fromHex(uint8_t* dest, size_t count, const char* text) { + for(size_t i=0;i saved"); } +}; +namespace StrHelper { void strncpy(char* dest,const char* source,size_t size) { snprintf(dest,size,"%s",source); } } +const uint8_t FLOOD_PUBLIC_CHANNEL_SECRET[16]={}; +@SPACES@ +@ASCII@ +@UNSIGNED@ +@MOD_UNSIGNED@ +@TOKEN@ +@CHANNEL@ +@MOD_PATH@ +@NAME_VALID@ +@SCOPE_INDEX@ +static File openFloodSettingsRead(FILESYSTEM* fs, const char* path) { return fs->open(path,"r"); } +@WRITE@ +@HASH@ +@VERIFY@ +@PATH@ +@READ@ +@RECOVER@ +@LOAD@ +@SAVE@ +@DECODE@ +@SPAN@ +@BLOCK@ +@SET@ +@SET_SCOPE@ +static const char* target = FLOOD_GROUP_MODERATION_FILE; +static const char* scratch = FLOOD_GROUP_MODERATION_TEMP_FILE; +static const char* backup = FLOOD_GROUP_MODERATION_BACKUP_FILE; + +void configure(MyMesh& mesh, const char* name, uint16_t rate = 65535, uint8_t hops = 2) { + auto& entry = mesh.flood_group_moderation[0]; + entry = {}; entry.active = true; entry.key_len = 16; + memset(entry.hash_prefix, 0x11, sizeof(entry.hash_prefix)); + strcpy(entry.channel_name, "public"); strcpy(entry.sender, name); + entry.max_hops = hops; entry.rate_per_minute = rate; +} +std::vector plain(const std::string& text) { + std::vector bytes = {0x12,0x34,0x56,0x78,0}; + bytes.insert(bytes.end(),text.begin(),text.end()); return bytes; +} +mesh::Packet packet(const std::string& name) { + mesh::plaintext = plain(name + ": body"); + mesh::Packet packet; packet.payload[0] = 0x11; + packet.payload_len = uint8_t(3 + ((mesh::plaintext.size() + 15) / 16) * 16); + mesh::authenticated = true; return packet; +} +std::vector golden(MyMesh& mesh, unsigned count = 16) { + std::vector bytes = {'F','G','M','1',uint8_t(count)}; + auto append = [&bytes](const void* ptr, size_t size) { + const auto* in = static_cast(ptr); bytes.insert(bytes.end(),in,in+size); + }; + for (unsigned i=0;i> 8)); + } + return bytes; +} +void spans() { + using namespace mesh::FloodGroupPlainText; + View view; + for (size_t size : {size_t(1),size_t(31),size_t(32),size_t(200)}) { + auto bytes = plain(std::string(size,'A') + " : body: more"); + assert(parse(bytes.data(),bytes.size(),0,view) && view.has_sender); + assert(view.sender.size == size && view.text.size == 10); + assert(std::string((const char*)view.text.data,view.text.size) == "body: more"); + } + for (const std::string& text : std::vector{"no colon",": empty", " : empty", "Al\nice: body", std::string("A\0lice: body",12)}) { + auto bytes = plain(text); assert(parse(bytes.data(),bytes.size(),0,view) && !view.has_sender); + } + auto bytes = plain("Alice: "); assert(parse(bytes.data(),bytes.size(),0,view)); + assert(senderEquals(view,"ALICE") && !senderEquals(view,"AliceX") && view.text.size == 0); + bytes=plain("Alice: body");bytes.resize(32,0); + assert(parse(bytes.data(),bytes.size(),0,view) && view.text.size==4); + bytes[4] = 4; assert(!parse(bytes.data(),bytes.size(),0,view)); + for (size_t size=0;size<=5;++size) assert(!parse(bytes.data(),size,0,view)); + assert(!parse(nullptr,100,0,view)); +} +void moderation() { + MockFS fs; MyMesh mesh(fs); configure(mesh,"*"); + for (size_t size : {size_t(31),size_t(32),size_t(200)}) { + auto p = packet(std::string(size,'A')); assert(mesh.shouldBlockFloodGroupTextForward(&p)); + p.hops = 1; assert(!mesh.shouldBlockFloodGroupTextForward(&p)); + } + configure(mesh,"Alice",0,255); + auto p = packet("aLiCe "); assert(mesh.shouldBlockFloodGroupTextForward(&p)); + uint8_t key[32]={}; uint32_t timestamp=0; char sender[32]; + assert(mesh.decodeFloodGroupPlainText(&p,key,16,timestamp,sender,sizeof(sender))); + assert(!strcmp(sender,"aLiCe") && timestamp==0x78563412); + configure(mesh,std::string(31,'A').c_str(),0,255); + p=packet(std::string(32,'A')); assert(!mesh.shouldBlockFloodGroupTextForward(&p)); + assert(!mesh.decodeFloodGroupPlainText(&p,key,16,timestamp,sender,sizeof(sender)) && !sender[0]); + configure(mesh,"*"); p=packet("Alice"); mesh::authenticated=false; + assert(!mesh.shouldBlockFloodGroupTextForward(&p)); mesh::authenticated=true; + for (const std::string name : {"A\nlice", "", " "}) { + p=packet(name); assert(!mesh.shouldBlockFloodGroupTextForward(&p)); + } + p=packet("Alice"); mesh::plaintext=plain("no colon"); + assert(!mesh.shouldBlockFloodGroupTextForward(&p)); + p=packet("Alice"); p.flood=false; assert(!mesh.shouldBlockFloodGroupTextForward(&p)); + p.flood=true; p.type=6; assert(!mesh.shouldBlockFloodGroupTextForward(&p)); + p.type=5; p.payload[0]^=1; assert(!mesh.shouldBlockFloodGroupTextForward(&p)); + p.payload[0]^=1; p.payload_len-=1; assert(!mesh.shouldBlockFloodGroupTextForward(&p)); + p=packet("Alice"); uint8_t too_small[15]; mesh::FloodGroupPlainText::View view; + assert(!mesh.decodeFloodGroupPlainTextSpan(&p,key,16,too_small,sizeof(too_small),view)); +} +void formats() { + MockFS fs; MyMesh mesh(fs); configure(mesh,"Alice",3,5); + assert(mesh.saveFloodGroupModeration()); const auto bytes=fs.files[target]; + assert(bytes==golden(mesh) && bytes.size()==1797); + for(unsigned count=0;count<=16;++count) { + fs.files[target]=golden(mesh,count); MyMesh boot(fs); boot.loadFloodGroupModeration(); + assert(boot.flood_group_moderation[0].active==(count!=0)); + } + for(size_t size=0;size{ + {" public Alice rate=3/min hops=5 path=A1B2C3,D4E5F6"," public Alice q=3 h=5 p=A1B2C3,D4E5F6"}, + {" public Alice rate=7/min hops=all path=*"," public Alice q=7/min h=all p=*"}, + {" public Alice drop path=71"," public Alice d p=71"}, + {" public 'Field User' rate=8/m hops=0"," public 'Field User' q=8/m h=0"}, + {" public '*' hops=2"," public '*' h=2"}}) { + longform.setFloodGroupModeration(item.full,reply);assert(!strncmp(reply,"OK",2)); + shortform.setFloodGroupModeration(item.compact,reply);assert(!strncmp(reply,"OK",2)); + assert(first.files[target]==second.files[target]); + } + const auto original=second.files[target]; + for(const char* invalid : {" public Alice q=1 rate=2/min", " public Alice rate=1/min q=2", + " public Alice d q=2", " public Alice q=2 drop", " public Alice q=", " public Alice q=-1", + " public Alice q=65535", " public Alice q=7/sec", " public Alice q=1/min/min", + " public Alice h=64", " public Alice p=A1,B2C3 d", " public Alice p= d", + " public Alice d extra", " public Alice rate=7"}) { + shortform.setFloodGroupModeration(invalid,reply);assert(!strncmp(reply,"Err",3)); + assert(second.files[target]==original); + } + second.short_write=true;shortform.setFloodGroupModeration(" public Alice q=4",reply); + assert(strstr(reply,"save unconfirmed")&&strstr(reply,"reboot may differ")); + for(const Case& item : std::vector{ + {" public scope=local path=blacklist tx=slow"," public scope=local p=bl tx=s"}, + {" public scope=local path=bucket:1 tx=fast"," public scope=local p=b1 tx=f"}, + {" public scope=local path=bucket:6"," public scope=local p=b6"}}) { + longform.setFloodChannelScope(item.full,reply);assert(!strcmp(reply,"OK")); + shortform.setFloodChannelScope(item.compact,reply);assert(!strcmp(reply,"OK")); + assert(longform.scope_path==shortform.scope_path&&longform.scope_slow==shortform.scope_slow); + } + for(const char* invalid : {" public scope=local p=b0", " public scope=local p=b7", + " public scope=local p=b", " public scope=local p=b1junk", " public scope=local p=blacklist", + " public scope=local p=bl path=bucket:1", " public scope=local tx=s tx=fast", + " public scope=local tx=x", " public scope=local tx=f path=bucket:01x"}) { + shortform.setFloodChannelScope(invalid,reply);assert(!strncmp(reply,"Err",3)); + } +} +int main(int argc,char** argv) { + assert(argc==2);std::string which=argv[1]; + if(which=="spans")spans();else if(which=="moderation")moderation(); + else if(which=="formats")formats();else if(which=="failures")failures(); + else if(which=="recovery")recovery();else if(which=="cli")cli();else assert(false); +} +''' + + +class FloodModerationTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = os.environ.get('CXX') or shutil.which('g++') or shutil.which('clang++') + if not compiler: + raise unittest.SkipTest('native C++ compiler unavailable') + source = (ROOT / 'examples/simple_repeater/MyMesh.cpp').read_text() + header = (ROOT / 'examples/simple_repeater/MyMesh.h').read_text() + entry = re.search(r' struct FloodGroupModerationEntry \{.*?\n \};', header, re.S).group() + harness = HARNESS.replace('@ENTRY@', entry) + methods = { + 'WRITE': 'static File openFloodSettingsWrite(', + 'HASH': 'static uint32_t updateFloodSettingsHash(', + 'VERIFY': 'static bool verifyFloodSettingsWrite(', + 'PATH': 'static bool floodModerationPathMatches(', + 'READ': 'bool MyMesh::readFloodGroupModerationFile(', + 'RECOVER': 'bool MyMesh::recoverFloodGroupModeration(', + 'LOAD': 'void MyMesh::loadFloodGroupModeration(', + 'SAVE': 'bool MyMesh::saveFloodGroupModeration(', + 'DECODE': 'bool MyMesh::decodeFloodGroupPlainText(', + 'SPAN': 'bool MyMesh::decodeFloodGroupPlainTextSpan(', + 'BLOCK': 'bool MyMesh::shouldBlockFloodGroupTextForward(', + 'SPACES': 'static const char* skipFloodFilterSpaces(', + 'ASCII': 'static bool floodFilterAsciiEqual(', + 'UNSIGNED': 'static bool parseFloodFilterUnsigned(', + 'MOD_UNSIGNED': 'static bool parseFloodModerationUnsigned(', + 'TOKEN': 'static int takeFloodModerationToken(', + 'CHANNEL': 'static bool parseFloodModerationChannel(', + 'MOD_PATH': 'static bool parseFloodModerationPath(', + 'NAME_VALID': 'static bool floodModerationSenderNameValid(', + 'SET': 'void MyMesh::setFloodGroupModeration(', + 'SCOPE_INDEX': 'static bool parseFloodChannelScopeIndex(', + } + for marker, signature in methods.items(): + harness = harness.replace('@' + marker + '@', method(source[source.rfind(signature):], signature)) + # Execute the complete production scope option parser before its + # unrelated region lookup and filesystem mutation phase. + setter = method(source, 'void MyMesh::setFloodChannelScope(') + setter = setter[:setter.index(' bool direct_target =')] + setter += ' scope_path=path_selector;scope_slow=slow_timing;strcpy(reply,"OK");\n}\n' + harness = harness.replace('@SET_SCOPE@', setter) + cls.temp = tempfile.TemporaryDirectory(prefix='meshcore-flood-moderation-') + cls.addClassCleanup(cls.temp.cleanup) + work = Path(cls.temp.name) + test = work / 'moderation.cpp' + test.write_text(harness) + cls.binaries = {} + for platform in ('NRF52', 'STM32', 'ESP32', 'RP2040'): + binary = work / platform + result = subprocess.run([compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror', + *SANITIZER_FLAGS, '-D' + platform + '_PLATFORM=1', '-I', str(ROOT / 'src'), + str(test), '-o', str(binary)], capture_output=True, text=True, timeout=60) + if result.returncode: + raise AssertionError(result.stdout + result.stderr) + cls.binaries[platform] = binary + + def scenario(self, name): + for platform, binary in self.binaries.items(): + with self.subTest(platform=platform): + result = subprocess.run([str(binary), name], capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_full_length_sender_and_body_views(self): + self.scenario('spans') + + def test_wildcard_moderation_cannot_be_bypassed_by_long_sender(self): + self.scenario('moderation') + + def test_fgm1_compatibility_and_malformed_images(self): + self.scenario('formats') + + def test_failed_saves_preserve_previous_rules_after_reboot(self): + self.scenario('failures') + + def test_transaction_recovery_and_every_power_cut_boundary(self): + self.scenario('recovery') + + def test_cli_shortforms_match_full_commands_and_preserve_validation(self): + self.scenario('cli') + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_flood_policy_capacity.py b/test/test_flood_policy_capacity.py index fc3a3bfa4..1b14bcf0e 100644 --- a/test/test_flood_policy_capacity.py +++ b/test/test_flood_policy_capacity.py @@ -35,20 +35,21 @@ PRELUDE = r''' namespace mesh { File openFileRead(FILESYSTEM* fs, const char* path) { return fs->open(path); } struct Utils { - static void sha256(void*, size_t, const uint8_t*, size_t) { - assert(false && "crypto is outside these capacity fixtures"); + static void sha256(void* out, size_t size, const uint8_t*, size_t key_size) { + // Cryptography has separate tests. Only the derived one-byte channel hash + // is required by these real storage paths; preserve the complete key. + assert(size == 1 && (key_size == 16 || key_size == 32)); + std::memset(out,0,size); } }; } struct RegionMap { static bool is_name_char(uint8_t c) { return std::isalnum(c) || c=='_' || c=='-'; } }; -struct StrHelper { - static void strzcpy(char* out, const char* in, size_t size) { +void StrHelper::strzcpy(char* out, const char* in, size_t size) { if (out!=in) std::memmove(out,in,std::min(size,std::strlen(in)+1)); if (size) out[size-1]=0; - } -}; +} ''' CLASS = r''' class MyMesh { @@ -101,6 +102,21 @@ template void canonicalRule(Entry& row) { row.priority=7;row.stop_on_match=true;row.retry_on_match=true; row.rate_window_started=0xfedcba98;row.rate_window_count=17;row.rate_window_active=true; } +template void extendedRule(Entry& row) { + row={};row.active=true;row.payload_type=PAYLOAD_TYPE_GRP_TXT;row.max_hops=63; + row.channel_key_len=16;row.channel_secret[0]=0x42; + std::strcpy(row.channel_name,"Weather"); + row.path_hash_size=3;row.path_hops=4; + for(unsigned index=0;index<9;++index)row.path[index]=uint8_t(index+1); + row.drop_on_match=true; + row.extra.min_payload=20;row.extra.max_payload=80;row.extra.payload_set=1; + row.extra.route=FloodRuleExtensions::FloodRoute; + row.extra.probability=17;row.extra.path_position=FloodRuleExtensions::Suffix; + row.extra.disabled=1; + row.extra.path_tail[0]=10;row.extra.path_tail[1]=11;row.extra.path_tail[2]=12; + std::strcpy(row.extra.sender,"^(Alice|Bob) [0-9]+$"); + std::strcpy(row.extra.text,"weather (rain|sun)"); +} std::vector fixture(uint8_t slots, uint8_t highest) { MemoryFS fs;MyMesh writer(fs,slots); for (uint8_t index : {uint8_t(0),highest}) { @@ -120,7 +136,51 @@ std::map> snapshot(const MemoryFS& fs) { for (const auto& row:fs.files) result[row.first]=*row.second; return result; } +void extendedStorage() { + // Disabled high slots remain allocated and durable. They must neither be + // trimmed from the file nor silently discarded by smaller-capacity builds. + MemoryFS fs;MyMesh writer(fs,63);extendedRule(writer.storage[62]); + assert(writer.saveFloodPacketFilters()); + const auto image=fs.get(FLOOD_PACKET_FILTER_FILE); + assert(!std::memcmp(image.data(),"FPF8",4) && image[4]==63); + MyMesh reader(fs,63);assert(reader.loadFloodPacketFilters()); + assert(FloodFilterPolicy::sameRuleConfiguration(writer.storage[62],reader.storage[62])); + assert(reader.storage[62].active && reader.storage[62].extra.disabled); + assert(reader.saveFloodPacketFilters() && fs.get(FLOOD_PACKET_FILTER_FILE)==image); + MyMesh smaller(fs,4);assert(!smaller.loadFloodPacketFilters()); + assert(smaller.flood_policy_capacity_limited && !smaller.saveFloodPacketFilters()); + assert(fs.get(FLOOD_PACKET_FILTER_FILE)==image); + // A room server serializes the same extension and the repeater consumes it. + MemoryFS room_fs;FloodRuleEngine room(room_fs);extendedRule(room.storage[3]); + assert(room.save());assert(!std::memcmp(room_fs.get(FLOOD_PACKET_FILTER_FILE).data(),"FPF8",4)); + MyMesh from_room(room_fs,63);assert(from_room.loadFloodPacketFilters()); + assert(FloodRuleExtensions::sameFields(room.storage[3].extra,from_room.storage[3].extra)); + assert(from_room.storage[3].path_hops==4 && from_room.storage[3].path_hash_size==3); + assert(!std::memcmp(room.storage[3].path,from_room.storage[3].path,9)); + // No truncated FPF8 forwarding row may activate a partial policy. The + // optional FPS1 extension follows the rows and has separate legacy rules. + for(size_t length=0;length +#include +#include +#include +#include +#include +#include +#include +#include +#include +#define MESH_ENABLE_FLOOD_RULE_ENGINE 1 +#define FLOOD_PACKET_FILTER_SCOPE_NAME_LEN 32 +#define FLOOD_GROUP_MODERATION_NAME_LEN 32 +#define FLOOD_CHANNEL_KEY_PREFIX_LEN 8 +#define FLOOD_PACKET_FILTER_PATH_PREFIX_BYTES_MAX 9 +#define FLOOD_PACKET_FILTER_ANY_TYPE 255 +#define FLOOD_PACKET_FILTER_MAX_HOPS 63 +#define FLOOD_PACKET_FILTER_SLOTS 63 +#define FLOOD_GROUP_MODERATION_RATE_UNLIMITED 65535 +#define REGION_DENY_FLOOD 1 +static const char FLOOD_PACKET_FILTER_USAGE[]="Err - usage"; +static const char FLOOD_PACKET_FILTER_DUPLICATE[]="Err - duplicate"; +mesh::Packet::Packet() : header(0), payload_len(0), path_len(0), transport_codes{}, path{}, payload{}, + _rssi(0), _snr(0), tx_cr(0), flood_retry_policy(0) {} +static std::vector decrypted; +static bool authentic = true; +int mesh::Utils::MACThenDecrypt(const uint8_t* secret, uint8_t* out, const uint8_t*, int) { + if (!authentic || secret[0]!=0x42)return 0; + assert(decrypted.size()<=MAX_PACKET_PAYLOAD); + memcpy(out,decrypted.data(),decrypted.size());return int(decrypted.size()); +} +struct RegionEntry { const char* name="Town"; uint8_t flags=0; bool isWildcard()const{return false;} }; +struct TransportKey { bool isNull()const{return true;} uint16_t calcTransportCode(const mesh::Packet*)const{return 0;} }; +struct RegionMap { + static bool is_name_char(uint8_t c){return std::isalnum(c)||c=='_'||c=='-';} + RegionEntry* findByNamePrefix(const char*){return nullptr;} + int getTransportKeysFor(const RegionEntry&,TransportKey*,unsigned){return 0;} +}; +void StrHelper::strzcpy(char* out,const char* in,size_t size){if(size)snprintf(out,size,"%s",in);} +void StrHelper::strncpy(char* out,const char* in,size_t size){if(size)snprintf(out,size,"%s",in);} +static void deriveFloodFilterScopeKey(const char*,TransportKey&){} +// Channel cryptography/resolution is separately exercised. The setter still +// performs every production key-vs-hash and content/routing compatibility gate. +static bool parseFloodRuleChannel(const char* text,uint8_t* secret,uint8_t& key_len, + uint8_t* prefix,char* name,size_t capacity) { + memset(secret,0,PUB_KEY_SIZE);memset(prefix,0,FLOOD_CHANNEL_KEY_PREFIX_LEN); + uint8_t hash; + if(FloodFilterPolicy::parseChannelHashMatcher(text,hash)){ + key_len=1;secret[0]=hash;prefix[0]=hash;snprintf(name,capacity,"hash:%02X",hash);return true; + } + if(strcmp(text,"#weather")&&strcmp(text,"public"))return false; + key_len=16;secret[0]=0x42;snprintf(name,capacity,"%s",text);return true; +} +class MyMesh { +public: +@ENTRY@; + FloodPacketFilterEntry rows[63]{}; + FloodPacketFilterEntry* flood_packet_filters=rows; + uint8_t flood_packet_filter_slots=63,flood_channel_data_rule_slot=255; + uint32_t flood_filter_probability_seed=0x12345678; + RegionMap region_map; + bool save_ok=true; + unsigned saves=0; + void setFloodPacketFilter(const char*,char*,bool require_explicit_action=false); + bool saveFloodPacketFilters(){++saves;return save_ok;} + void formatFloodPacketFilterDetail(int,char* out,size_t size,bool=false)const{snprintf(out,size,"> saved");} + bool isAnyTempRadioActive()const{return false;} + bool floodPacketFilterBlacklistMatches(const mesh::Packet*)const{return false;} + bool floodPacketFilterFieldsMatch(const FloodPacketFilterEntry&,const mesh::Packet*,bool,uint16_t,bool,const RegionEntry*,uint8_t)const; + bool authenticateFloodPacketFilterChannel(const FloodPacketFilterEntry&,const mesh::Packet*)const; + bool resolveFloodPacketFilterTargetRegion(const char*,TransportKey&,const char*&){return false;} + uint64_t applyFloodPacketFilterStop(uint64_t); + uint64_t evaluateFloodPacketFilterMatches(const mesh::Packet*,bool,const RegionEntry*,uint8_t); +}; +''' +SCENARIOS = r''' +static void set(MyMesh& mesh,const char* args,bool expected=true,bool strict=true){ + struct Reply {uint8_t before[8];char text[160];uint8_t after[8];} reply; + memset(&reply,0xa5,sizeof(reply)); + mesh.setFloodPacketFilter(args,reply.text,strict); + assert(!strncmp(reply.text,expected?"OK":"Err",expected?2:3)); + for(uint8_t byte:reply.before)assert(byte==0xa5); + for(uint8_t byte:reply.after)assert(byte==0xa5); +} +static void plain(const std::string& text){ + decrypted.assign(5,0);decrypted.insert(decrypted.end(),text.begin(),text.end());decrypted.push_back(0); +} +static void parserAndRollback(){ + MyMesh mesh; + set(mesh,R"(.1 grp_txt channel=#weather sender="^(Alice|Bob) [0-9]+$" text='weather (rain|sun)' len=20-80 route=flood prob=25 path=AA>BB$ drop)"); + const auto original=mesh.rows[0]; + assert(original.active&&original.drop_on_match&&original.path_hops==2&&original.extra.path_position==FloodRuleExtensions::Suffix); + assert(original.extra.probability==25&&original.extra.payload_set&&original.extra.min_payload==20&&original.extra.max_payload==80); + set(mesh,R"(.2 t=grp_txt c=#weather n="^(Alice|Bob) [0-9]+$" x='weather (rain|sun)' l=20-80 rt=f pr=25 pa=AA>BB$ d)"); + assert(FloodFilterPolicy::sameRuleConfiguration(mesh.rows[0],mesh.rows[1])); + set(mesh,".1 enabled=off");assert(mesh.rows[0].active&&mesh.rows[0].extra.disabled); + auto disabled=original;disabled.extra.disabled=1; + assert(FloodFilterPolicy::sameRuleConfiguration(mesh.rows[0],disabled)); + const auto saves=mesh.saves; + set(mesh,".1 off");assert(mesh.saves==saves); + mesh.flood_channel_data_rule_slot=0; + mesh.save_ok=false; + set(mesh,".1 e=on",false);assert(mesh.rows[0].extra.disabled); + assert(mesh.flood_channel_data_rule_slot==0); + mesh.save_ok=true; + set(mesh,".1 e=on");assert(mesh.flood_channel_data_rule_slot==255); + mesh.save_ok=false; + set(mesh,".1 any route=direct drop",false); + assert(FloodFilterPolicy::sameRuleConfiguration(mesh.rows[0],original)); + mesh.save_ok=true;set(mesh,".1 on");assert(!mesh.rows[0].extra.disabled); + assert(FloodFilterPolicy::sameRuleConfiguration(mesh.rows[0],original)); + for(const char* invalid:{".3 grp_txt c=hash:00 n=Alice drop",".3 grp_txt n=Alice drop", + ".3 advert c=#weather n=Alice drop",".3 grp_txt c=#weather n=[ drop", + ".3 any route=direct scope=Town", ".3 any route=any retry", + ".3 any l=1-2 len=2-3 drop", ".3 any pa=AA>BB>CC>DD>EE drop", + ".3 any", ".3 off", ".0 any drop", ".64 any drop"}) { + unsigned previous=mesh.saves;set(mesh,invalid,false);assert(mesh.saves==previous&&!mesh.rows[2].active); + } + set(mesh,".3 any route=direct drop");assert(mesh.rows[2].extra.route==FloodRuleExtensions::DirectRoute); + set(mesh,".4 any",true,false);assert(mesh.rows[3].drop_on_match); +} +static void allowAndDirect(){ + MyMesh mesh; + set(mesh,".1 grp_txt c=#weather n=^Alice$ a pri=2 rt=a"); + set(mesh,".2 grp_txt c=#weather d pri=1 rt=a"); + mesh::Packet packet;packet.header=ROUTE_TYPE_FLOOD|(PAYLOAD_TYPE_GRP_TXT< +#include +namespace mesh { struct Utils { + static int MACThenDecrypt(const uint8_t*, uint8_t*, const uint8_t*, int); +}; } +''' +PROGRAM = r''' +#include +#include +#include +#include +#include +#include +#include +#include +#include +using namespace FloodRuleExtensions; +mesh::Packet::Packet() : header(0), payload_len(0), path_len(0), transport_codes{}, path{}, payload{}, + _rssi(0), _snr(0), tx_cr(0), flood_retry_policy(0) {} +static std::vector plaintext; +static unsigned decrypts; +static bool decrypt_ok = true; +int mesh::Utils::MACThenDecrypt(const uint8_t* secret, uint8_t* out, const uint8_t*, int) { + ++decrypts; + if (!decrypt_ok || secret[0] != 0x42) return 0; + assert(plaintext.size() <= MAX_PACKET_PAYLOAD); + memcpy(out, plaintext.data(), plaintext.size()); + return int(plaintext.size()); +} +struct StringWriter { + std::string text; + void append(const char* format, ...) { + char buffer[512]; + va_list args; va_start(args, format); + int n = vsnprintf(buffer, sizeof(buffer), format, args); va_end(args); + assert(n >= 0 && n < int(sizeof(buffer))); + text.append(buffer, size_t(n)); + } +}; +static Fields parse(const char* command) { + char buffer[512], reply[160] = {}; + assert(strlen(command) < sizeof(buffer)); + strcpy(buffer, command); + char* tokens[24]; + int count = tokenize(buffer, tokens, 24); + assert(count >= 0); + Fields fields{}; uint8_t seen = 0; + for (int i = 0; i < count; ++i) assert(parseToken(tokens[i], fields, seen, reply) == 1); + assert(validFields(fields)); + return fields; +} +static void parsing() { + Fields normal = parse(R"(sender="^(Alice|Bob) [0-9]+$" text='[Ww]eather: rain?' len=0-60 route=any prob=25 enabled=off)"); + Fields compact = parse(R"(n="^(Alice|Bob) [0-9]+$" x='[Ww]eather: rain?' l=0-60 rt=a pr=25 e=0)"); + assert(sameFields(normal, compact)); + assert(normal.payload_set && normal.min_payload == 0 && normal.max_payload == 60); + assert(normal.route == AnyRoute && normal.probability == 25 && normal.disabled); + assert(contentRequested(normal) && hasFields(normal)); + Fields empty{}; + assert(validFields(empty) && !hasFields(empty) && !contentRequested(empty)); + assert(sameFields(parse("prob=100"), empty)); + Fields terminator = empty; + memset(terminator.sender, 'a', sizeof(terminator.sender)); + assert(!validFields(terminator)); + terminator = empty; memset(terminator.text, 'a', sizeof(terminator.text)); + assert(!validFields(terminator)); + for (const char* token : {"sender=", "text=", "sender=[", "text=a**", "len=", "len=-1", "len=3-2", + "len=185", "len=184-185", "len=1+2", "len=1+-2", "len=1-2+", "len=1--2", "len=99999999999999999999", + "route=bad", "prob=0", "prob=101", "prob=-1", "prob=50%", "enabled=yes"}) { + Fields row{}; uint8_t seen = 0; char reply[160] = {}; + assert(parseToken(token, row, seen, reply) == -1 && strncmp(reply, "Err -", 5) == 0); + } + const std::string oversized = "sender=" + std::string(64, 'a'); + uint8_t seen = 0; char reply[160] = {}; Fields row{}; + assert(parseToken(oversized.c_str(), row, seen, reply) == -1); + seen = 0; + assert(parseToken("sender=Alice", row, seen, reply) == 1); + assert(parseToken("n=Bob", row, seen, reply) == -1 && !strcmp(row.sender, "Alice")); + assert(parseToken("mode=radio", row, seen, reply) == 0); + assert(parseToken("drop", row, seen, reply) == 0); + char malformed[] = "text=\"unterminated"; char* tokens[2]; + assert(tokenize(malformed, tokens, 2) == -1); + char excess[] = "a b c"; assert(tokenize(excess, tokens, 2) == -1); + char whitespace[] = "\t "; assert(tokenize(whitespace, tokens, 2) == 0); + for (const char* syntax : {"len=0", "l=0-0"}) { + const auto zero = parse(syntax); + mesh::Packet packet; packet.header = ROUTE_TYPE_FLOOD; + assert(matchesEnvelope(zero, &packet, 0)); + packet.payload_len = 1; assert(!matchesEnvelope(zero, &packet, 0)); + } + for (bool abbreviated : {false, true}) { + for (const char* syntax : {R"(text='has "quote"')", R"(text='has \"quote\"')", + R"(text='\\server\ folder')", R"(text="it\'s both")"}) { + Fields source = parse(syntax); + StringWriter writer; appendFields(writer, source, abbreviated); + Fields restored = parse(writer.text.c_str()); + for (const char* sample : {"has \"quote\"", "has quote", "\\server folder", "it's both", "unrelated"}) { + using mesh::flood::matchTextPattern; + const auto* bytes = reinterpret_cast(sample); + assert(matchTextPattern(source.text, strlen(source.text), bytes, strlen(sample)) + == matchTextPattern(restored.text, strlen(restored.text), bytes, strlen(sample))); + } + } + StringWriter writer; appendFields(writer, normal, abbreviated); + assert(sameFields(normal, parse(writer.text.c_str()))); + } +} +static void envelopesAndProbability() { + Fields row{}; + assert(!matchesEnvelope(row, nullptr, 0)); + for (uint8_t route = 0; route < 4; ++route) { + mesh::Packet packet; packet.header = route; packet.payload_len = 44; + const bool flood = packet.isRouteFlood(); + assert(matchesEnvelope(row, &packet, 0) == flood); + assert(matchesEnvelope(row, &packet, 1)); + row.route = FloodRoute; assert(matchesEnvelope(row, &packet, 0) == flood); + row.route = DirectRoute; assert(matchesEnvelope(row, &packet, 0) == !flood); + row.route = AnyRoute; assert(matchesEnvelope(row, &packet, 0)); + row.payload_set = 1; row.min_payload = 44; row.max_payload = 44; + assert(matchesEnvelope(row, &packet, 0)); + packet.payload_len = 43; assert(!matchesEnvelope(row, &packet, 0)); + packet.payload_len = 45; assert(!matchesEnvelope(row, &packet, 0)); + packet.payload_len = 44; row.disabled = 1; assert(!matchesEnvelope(row, &packet, 0)); + row = {}; + } + for (unsigned probability = 0; probability <= 100; ++probability) { + row.probability = uint8_t(probability); + unsigned selected = 0; + for (uint32_t roll = 0; roll < 10000; ++roll) selected += matchesProbability(row, roll); + assert(selected == (probability ? probability * 100 : 10000)); + } +} +static void paths() { + mesh::Packet packet; packet.header = ROUTE_TYPE_FLOOD; + packet.setPathHashSizeAndCount(1, 5); + const uint8_t bytes[] = {0x11, 0x22, 0x33, 0x44, 0x55}; memcpy(packet.path, bytes, sizeof(bytes)); + for (const char* syntax : {"22>33", "22,33"}) { + Fields row{}; uint8_t width, hops, first[9]; + assert(parsePath(syntax, width, hops, first, row, false)); + assert(width == 1 && hops == 2 && row.path_position == Anywhere); + assert(pathMatches(row, &packet, width, hops, first)); + row.path_position = Prefix; assert(!pathMatches(row, &packet, width, hops, first)); + row.path_position = Suffix; assert(!pathMatches(row, &packet, width, hops, first)); + } + for (const char* syntax : {"^11>22", "44>55$", "^11>22>33>44$"}) { + Fields row{}; uint8_t width, hops, first[9]; + assert(parsePath(syntax, width, hops, first, row, false)); + assert(pathMatches(row, &packet, width, hops, first) == (row.path_position != Exact)); + } + for (unsigned width = 1; width <= 3; ++width) { + Fields row{}; uint8_t parsedWidth, hops, first[9]; + StringWriter wanted; + for (unsigned hop = 0; hop < 4; ++hop) { + if (hop) wanted.append(">"); + for (unsigned byte = 0; byte < width; ++byte) wanted.append("%02X", 0x11 + hop * width + byte); + } + assert(parsePath(wanted.text.c_str(), parsedWidth, hops, first, row, false)); + assert(parsedWidth == width && hops == 4); + packet.setPathHashSizeAndCount(uint8_t(width), 6); + memset(packet.path, 0x99, sizeof(packet.path)); + memcpy(packet.path + width, first, std::min(9U, 4 * width)); + if (width == 3) memcpy(packet.path + width + 9, row.path_tail, 3); + assert(pathMatches(row, &packet, parsedWidth, hops, first)); + for (bool compact : {false, true}) { + StringWriter writer; appendPath(writer, row, parsedWidth, hops, first, compact); + const char* syntax = strchr(writer.text.c_str(), '=') + 1; + Fields copy{}; uint8_t copiedWidth, copiedHops, copiedFirst[9]; + assert(parsePath(syntax, copiedWidth, copiedHops, copiedFirst, copy, false)); + assert(copiedWidth == parsedWidth && copiedHops == hops && sameFields(row, copy)); + assert(!memcmp(first, copiedFirst, 9)); + } + packet.header = ROUTE_TYPE_DIRECT; assert(!pathMatches(row, &packet, parsedWidth, hops, first)); + packet.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_TRACE << PH_TYPE_SHIFT); + assert(!pathMatches(row, &packet, parsedWidth, hops, first)); + packet.header = ROUTE_TYPE_FLOOD; + packet.setPathHashSizeAndCount(uint8_t(width), 63); + if (width > 1) assert(!pathMatches(row, &packet, parsedWidth, hops, first)); + } + for (const char* invalid : {"", "^", "$", "^$", "AA>", ">AA", "AA>>BB", "GG", "A", "AAAAA", "AA>BBBB", "AA>BB>CC>DD>EE"}) { + Fields row{}; uint8_t width, hops, first[9]; + assert(!parsePath(invalid, width, hops, first, row, false)); + } + Fields row{}; uint8_t width, hops, first[9]; + assert(parsePath("*", width, hops, first, row, false) && !width && !hops); + assert(pathMatches(row, &packet, width, hops, first)); +} +static void content() { + uint8_t key[32] = {0x42}; + mesh::Packet packet; packet.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT); packet.payload_len = 35; + Fields row = parse(R"(sender=^Alice$ text='weather (sun|rain)')"); + const auto setPlain = [](const std::string& text) { + plaintext.assign(5, 0); plaintext.insert(plaintext.end(), text.begin(), text.end()); plaintext.push_back(0); + }; + setPlain("Alice: weather rain"); + assert(matchesContent(row, &packet, key, 16)); + assert(matchesContent(row, &packet, key, 32)); + uint32_t work = 1000; + assert(matchesContent(row, &packet, key, 16, &work)); + assert(work > 0 && work < 1000); + const uint32_t afterFirst = work; + assert(matchesContent(row, &packet, key, 16, &work) && work < afterFirst); + work = 0; + assert(!matchesContent(row, &packet, key, 16, &work, false)); + assert(matchesContent(row, &packet, key, 16, &work, true)); + bool exhausted = false; + assert(!matchesContent(row, &packet, key, 16, &work, false, &exhausted) && exhausted); + // An allow rule cannot become an unconditional exemption when work runs + // out. A deny rule may conservatively suppress uncertain authenticated data. + Fields textOnly = row; textOnly.sender[0] = 0; + assert(!matchesContent(textOnly, &packet, key, 16, &work, false)); + assert(matchesContent(textOnly, &packet, key, 16, &work, true)); + exhausted = false; + assert(!matchesContent(textOnly, &packet, key, 16, &work, false, &exhausted) && exhausted); + work = 1000; + assert(matchesContent(textOnly, &packet, key, 16, &work, false, &exhausted) && !exhausted); + assert(!matchesContent(row, &packet, key, 1)); + assert(!matchesContent(row, &packet, nullptr, 16)); + assert(!matchesContent(row, static_cast(nullptr), key, 16)); + decrypt_ok = false; + assert(!matchesContent(row, &packet, key, 16)); + assert(!matchesContent(row, &packet, key, 16, &work, true)); + exhausted = true; + assert(!matchesContent(row, &packet, key, 16, &work, false, &exhausted) && !exhausted); + decrypt_ok = true; + setPlain("alice: weather rain"); assert(!matchesContent(row, &packet, key, 16)); + setPlain("Alice: dry"); assert(!matchesContent(row, &packet, key, 16)); + setPlain("weather rain"); assert(!matchesContent(row, &packet, key, 16)); + row.sender[0] = 0; assert(matchesContent(row, &packet, key, 16)); + setPlain(std::string(140, 'x') + "Alice: weather sun"); + strcpy(row.sender, "Alice$"); assert(matchesContent(row, &packet, key, 16)); + plaintext[4] = TXT_TYPE_CLI_COMMAND << 2; assert(!matchesContent(row, &packet, key, 16)); + packet.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT); + assert(!matchesContent(row, &packet, key, 16)); + row = {}; decrypts = 0; + assert(matchesContent(row, static_cast(nullptr), nullptr, 0) && decrypts == 0); +} +struct ByteReader { + std::vector bytes; size_t position = 0; + size_t read(uint8_t* out, size_t size) { + size = std::min(size, bytes.size() - position); + if (size) memcpy(out, bytes.data() + position, size); + position += size; return size; + } +}; +static void storage() { + Fields source = parse(R"(sender=Alice text='rain storm' len=0-80 route=direct prob=33 enabled=off)"); + source.path_position = Suffix; source.path_tail[2] = 0x99; + std::vector bytes; + auto writer = [&bytes](const void* data, size_t size) { + const auto* first = static_cast(data); bytes.insert(bytes.end(), first, first + size); return true; + }; + assert(writeFields(writer, source)); + const std::vector expected = {0, 0, 80, 0, DirectRoute, 33, Suffix, 1, 1, 0, 0, 0x99, + 5, 'A', 'l', 'i', 'c', 'e', 10, 'r', 'a', 'i', 'n', ' ', 's', 't', 'o', 'r', 'm'}; + assert(bytes == expected); + ByteReader full{bytes}; Fields loaded{}; + assert(readFields(full, loaded) && full.position == bytes.size() && sameFields(source, loaded)); + for (size_t length = 0; length < bytes.size(); ++length) { + ByteReader shortRead{{bytes.begin(), bytes.begin() + length}}; + assert(!readFields(shortRead, loaded)); + } + const size_t senderLength = 12; // Four uint16 bytes, five control bytes, three tail bytes. + assert(bytes[senderLength] == strlen(source.sender)); + for (const auto& change : std::vector>{{4, 4}, {5, 101}, {6, 4}, {7, 2}, {8, 2}, {senderLength, 64}, {senderLength + 1, '*'}}) { + ByteReader corrupt{bytes}; corrupt.bytes[change.first] = change.second; + assert(!readFields(corrupt, loaded)); + } + unsigned successfulCalls = 0; + auto counter = [&successfulCalls](const void*, size_t) { ++successfulCalls; return true; }; + assert(writeFields(counter, source)); + for (unsigned failure = 0; failure < successfulCalls; ++failure) { + unsigned calls = 0; + auto broken = [&calls, failure](const void*, size_t) { return calls++ != failure; }; + assert(!writeFields(broken, source)); + } +} +int main() { parsing(); envelopesAndProbability(); paths(); content(); storage(); } +''' + + +class FloodRuleExtensionsTests(unittest.TestCase): + def test_real_parser_matching_and_storage(self): + with tempfile.TemporaryDirectory(prefix="flood-rule-extensions-") as folder: + folder = Path(folder) + (folder / "Utils.h").write_text(UTILS) + source = folder / "extensions.cpp" + source.write_text(PROGRAM) + binary = folder / "extensions" + flags = [] + if os.name != "nt": + flags = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] + subprocess.run(["g++", "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", *flags, + "-I" + str(folder), "-I" + str(ROOT / "src"), str(source), "-o", str(binary)], check=True) + subprocess.run([str(binary)], check=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_flood_rule_layout.py b/test/test_flood_rule_layout.py index af616a2e9..24890789a 100644 --- a/test/test_flood_rule_layout.py +++ b/test/test_flood_rule_layout.py @@ -24,8 +24,8 @@ static constexpr uint8_t NAME_LEN = 32, PATH_PREFIX_BYTES_MAX = 9; ''' SCENARIOS = r''' -static_assert(sizeof(RepeaterEntry) == 192 && sizeof(RoomEntry) == 192, - "63 repeater rows / 31 room rows must retain the 192-byte bound"); +static_assert(sizeof(RepeaterEntry) == 332 && sizeof(RoomEntry) == 332, + "extended rows retain the reviewed 332-byte RAM bound"); static_assert(sizeof(LegacyEntry) == 40, "retain legacy STM32 rule layout"); static_assert(alignof(RepeaterEntry) >= alignof(uint32_t)); static_assert(alignof(RoomEntry) >= alignof(uint32_t)); @@ -95,7 +95,7 @@ template void exerciseConfigurationEquality() { assert(FloodFilterPolicy::sameRuleConfiguration(original, live)); // Bytewise struct comparisons are forbidden: neither alignment padding nor // live counters identify a rule. Poison trailing padding to enforce this. - for (size_t offset = offsetof(Entry, transport_modes) + 1; offset < sizeof(Entry); ++offset) + for (size_t offset = offsetof(Entry, extra) + sizeof(live.extra); offset < sizeof(Entry); ++offset) reinterpret_cast(&live)[offset] = 0xa5; assert(FloodFilterPolicy::sameRuleConfiguration(original, live)); // Every configured field must still distinguish two rows. Arrays include @@ -133,6 +133,24 @@ template void exerciseConfigurationEquality() { assert(!FloodFilterPolicy::sameRuleConfiguration(original, changed)); assert(!FloodFilterPolicy::sameRuleConfiguration(changed, original)); } + for (unsigned field = 0; field < 11; ++field) { + Entry changed = original; + switch (field) { + case 0: changed.extra.min_payload = 1; break; + case 1: changed.extra.max_payload = 1; break; + case 2: changed.extra.payload_set = 1; break; + case 3: changed.extra.route = FloodRuleExtensions::DirectRoute; break; + case 4: changed.extra.probability = 30; break; + case 5: changed.extra.path_position = FloodRuleExtensions::Anywhere; break; + case 6: changed.extra.disabled = 1; break; + case 7: changed.extra.path_tail[2] = 1; break; + case 8: std::strcpy(changed.extra.sender, "Alice"); break; + case 9: std::strcpy(changed.extra.text, "weather"); break; + case 10: changed.extra.path_tail[0] = 1; break; + } + assert(!FloodFilterPolicy::sameRuleConfiguration(original, changed)); + assert(!FloodFilterPolicy::sameRuleConfiguration(changed, original)); + } } template void exerciseProductionSlotSelection() { Rules rules; diff --git a/test/test_flood_text_pattern.py b/test/test_flood_text_pattern.py new file mode 100644 index 000000000..8ba704634 --- /dev/null +++ b/test/test_flood_text_pattern.py @@ -0,0 +1,221 @@ +"""Exercise the production bounded matcher, including hostile regex-like input.""" +from pathlib import Path +import os +import random +import re +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +PROGRAM = r''' +#include +#include +#include +#include +#include +#include +#include "FloodTextPattern.h" +using namespace mesh::flood; +static bool forbidAllocation = false; +__attribute__((noinline)) void* operator new(size_t size) { + assert(!forbidAllocation); + void* result = malloc(size ? size : 1); + if (!result) std::abort(); + return result; +} +__attribute__((noinline)) void* operator new[](size_t size) { return ::operator new(size); } +__attribute__((noinline)) void operator delete(void* pointer) noexcept { free(pointer); } +__attribute__((noinline)) void operator delete[](void* pointer) noexcept { free(pointer); } +static_assert(sizeof(pattern_detail::State) == 4, "small NFA instructions"); +static_assert(sizeof(pattern_detail::State) * pattern_detail::MAX_STATES + + 2 * sizeof(pattern_detail::StateList) <= 640, "bounded matching scratch"); +static PatternMatchResult match(const char* pattern, const char* text, bool fold = false) { + forbidAllocation = true; + const auto result = matchTextPattern(pattern, strlen(pattern), reinterpret_cast(text), strlen(text), fold); + forbidAllocation = false; + return result; +} +static void checks() { + using R = PatternMatchResult; + assert(match("", "") == R::Match); + assert(match("Alice", "a message from Alice today") == R::Match); + assert(match("^Alice$", "Alice") == R::Match); + assert(match("^Alice$", "alice") == R::NoMatch); + assert(match("^Alice$", "alice", true) == R::Match); + assert(match("^Alice$", "Alice2") == R::NoMatch); + assert(match("^Alice$", "2Alice") == R::NoMatch); + assert(match("Alice$", "Alice\n") == R::NoMatch); + assert(match("A.*e", "A\ne") == R::Match); + assert(match("^a\\.b\\*\\[\\]\\(\\)\\|\\+\\?\\^\\$\\\\$", "a.b*[]()|+?^$\\") == R::Match); + assert(match("^[A-F0-9]+$", "aBcF09", true) == R::Match); + assert(match("^[A-F0-9]+$", "aBcF09") == R::NoMatch); + assert(match("^[^A-F]+$", "xyz", true) == R::Match); + assert(match("^[^A-F]+$", "xyzA", true) == R::NoMatch); + assert(match("^[A-z]+$", "[_Zaz", true) == R::Match); + assert(match("^[\\]\\-\\[]+$", "]-[") == R::Match); + assert(match("^[a-]+$", "a--") == R::Match); + assert(match("^[-a]+$", "a--") == R::Match); + assert(match("^(Alice|Bob)[0-9]?$", "Bob3") == R::Match); + assert(match("^(Alice|Bob)[0-9]?$", "Bob33") == R::NoMatch); + assert(match("^(ab)*c+$", "ababc") == R::Match); + assert(match("^(ab)+c?$", "") == R::NoMatch); + assert(match("^(ab)*c?$", "") == R::Match); + assert(match("^(a|ab)*b$", "aabab") == R::Match); + assert(match("^(()*)*$", "") == R::Match); // epsilon cycles terminate + assert(match("^(a|)*$", "aaaa") == R::Match); + for (const char* invalid : {"[", "[]", "[^]", "[z-a]", "[a-\\]]", "\\", "(", ")", "a)", + "a**", "a+?", "a??", "*a", "^*", "$+", "a]"}) { + assert(!textPatternValid(invalid, strlen(invalid))); + assert(match(invalid, "anything") == R::Invalid); + } + assert(!textPatternValid(nullptr, 0)); + assert(matchTextPattern("a", 1, nullptr, 1) == R::Invalid); + assert(matchTextPattern("", 0, nullptr, 0) == R::Match); + const char nulPattern[] = {'a', 0, 'b'}; + assert(!textPatternValid(nulPattern, sizeof(nulPattern))); + const uint8_t nulText[] = {'a', 0, 'b'}; + assert(matchTextPattern("^a.b$", 5, nulText, 3) == R::Match); + assert(matchTextPattern("^a$", 3, nulText, 3) == R::NoMatch); + const std::string maximum(MAX_TEXT_PATTERN_LENGTH, 'a'); + assert(textPatternValid(maximum.data(), maximum.size())); + assert(!textPatternValid(maximum.data(), maximum.size() + 1)); + assert(!textPatternValid("(((((((((a)))))))))", 19)); + assert(textPatternValid("((((((((a))))))))", 17)); + const std::string tooManyStates(MAX_TEXT_PATTERN_LENGTH, '|'); + assert(!textPatternValid(tooManyStates.data(), tooManyStates.size())); + const std::string longSender = std::string(200, 'x') + "Alice"; + assert(match("Alice$", longSender.c_str()) == R::Match); + assert(match("^x+$", longSender.c_str()) == R::NoMatch); + uint32_t budget = 0; + assert(matchTextPattern("a", 1, reinterpret_cast("a"), 1, false, &budget) == R::BudgetExceeded); + assert(budget == 0); + budget = 100; + assert(matchTextPattern("a", 1, reinterpret_cast("a"), 1, false, &budget) == R::Match); + assert(budget < 100 && budget > 0); + const uint32_t afterFirst = budget; + assert(matchTextPattern("b", 1, reinterpret_cast("b"), 1, false, &budget) == R::Match); + assert(budget < afterFirst); + const std::string hostile(100000, 'a'); + budget = 500; + assert(matchTextPattern("(a|aa)*b", 8, reinterpret_cast(hostile.data()), hostile.size(), false, &budget) == R::BudgetExceeded); + assert(budget == 0); + // This pattern is exponential with a backtracking engine. The NFA finishes + // within the default fixed budget for the largest packet-sized payload. + const std::string packet(255, 'a'); + assert(match("^(a|aa)*b$", packet.c_str()) == R::NoMatch); +} +static std::vector unhex(const std::string& text) { + std::vector result; + if (text == "-") return result; + for (size_t index = 0; index < text.size(); index += 2) { + result.push_back(uint8_t(std::stoul(text.substr(index, 2), nullptr, 16))); + } + return result; +} +int main() { + checks(); + std::string patternHex, textHex; + bool ignoreCase; + while (std::cin >> patternHex >> textHex >> ignoreCase) { + const auto pattern = unhex(patternHex); + const auto text = unhex(textHex); + // Exact-sized allocations let ASan detect an accidental C-string read. + char* rawPattern = new char[pattern.size() ? pattern.size() : 1]; + uint8_t* rawText = new uint8_t[text.size() ? text.size() : 1]; + if (!pattern.empty()) memcpy(rawPattern, pattern.data(), pattern.size()); + if (!text.empty()) memcpy(rawText, text.data(), text.size()); + const auto result = matchTextPattern(rawPattern, pattern.size(), rawText, text.size(), ignoreCase); + std::cout << int(result) << '\n'; + delete[] rawPattern; + delete[] rawText; + } +} +''' + +ARM_PROGRAM = r''' +#include "FloodTextPattern.h" +extern "C" __attribute__((noinline)) mesh::flood::PatternMatchResult packet_match( + const char* pattern, size_t patternLength, const uint8_t* text, size_t textLength, uint32_t* budget) { + return mesh::flood::matchTextPattern(pattern, patternLength, text, textLength, false, budget); +} +''' + + +class FloodTextPatternTests(unittest.TestCase): + def test_packet_patterns_bounds_and_reference_agreement(self): + rng = random.Random(436) + patterns = ["", "a", "^a$", "a|b", "^(ab|a)*b$", "a?b+c*", "[ab]+", "[^a]*b$", "[A-z]+"] + atoms = ["a", "b", "c", ".", "[ab]", "[^c]", "[a-c]", "[0-2]", r"\.", "(ab|c)", "(a|)"] + for _ in range(300): + pattern = "^" if rng.randrange(2) else "" + for _ in range(rng.randrange(1, 5)): + pattern += rng.choice(atoms) + rng.choice(["", "", "?", "*", "+"]) + if rng.randrange(2): + pattern += "$" + patterns.append(pattern) + cases = [] + for pattern in patterns: + for _ in range(15): + text = "".join(rng.choice("abcABC012.") for _ in range(rng.randrange(21))) + fold = bool(rng.randrange(2)) + expected = bool(re.search(pattern, text, re.DOTALL | (re.IGNORECASE | re.ASCII if fold else 0))) + cases.append((pattern, text, fold, expected)) + encoded = "".join(f"{pattern.encode().hex() or '-'} {text.encode().hex() or '-'} {int(fold)}\n" + for pattern, text, fold, _ in cases) + # Deterministic malformed-input fuzzing exercises validation and patch + # list bounds without assuming Python supports exactly our byte syntax. + fuzz = ["".join(rng.choice("abc^$.*+?()[\\]|-") for _ in range(rng.randrange(1, 64))) + for _ in range(4000)] + encoded += "".join(f"{pattern.encode().hex()} 616263 0\n" for pattern in fuzz) + with tempfile.TemporaryDirectory() as folder: + folder = Path(folder) + source = folder / "patterns.cpp" + source.write_text(PROGRAM) + binary = folder / "patterns" + flags = [] + if os.name != "nt": + flags = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] + subprocess.run(["g++", "-std=c++11", "-O1", "-Wall", "-Wextra", "-Werror", *flags, + "-I" + str(ROOT / "src/helpers"), str(source), "-o", str(binary)], check=True) + result = subprocess.run([str(binary)], input=encoded, text=True, capture_output=True, check=True, timeout=30) + results = result.stdout.splitlines() + self.assertEqual(len(results), len(cases) + len(fuzz)) + for value, case in zip(results, cases): + self.assertEqual(int(value), int(case[3]), case) + self.assertTrue(all(value in ("0", "1", "2") for value in results[len(cases):])) + + def test_arm_stack_is_bounded(self): + compiler = shutil.which("arm-none-eabi-g++") + if not compiler: + packages = Path.home() / ".platformio/packages" + candidates = sorted(packages.glob("toolchain-gccarmnoneeabi*/bin/arm-none-eabi-g++*")) + candidates = [path for path in candidates if path.suffix in ("", ".exe")] + if candidates: + compiler = str(candidates[0]) + if not compiler: + self.skipTest("ARM compiler unavailable") + with tempfile.TemporaryDirectory() as folder: + folder = Path(folder) + source = folder / "patterns.cpp" + source.write_text(ARM_PROGRAM) + subprocess.run([compiler, "-std=c++11", "-mcpu=cortex-m4", "-mthumb", "-Os", "-fstack-usage", + "-I" + str(ROOT / "src/helpers"), "-c", str(source), "-o", str(folder / "patterns.o")], check=True) + frames = {} + for line in (folder / "patterns.su").read_text().splitlines(): + name, size, kind = line.rsplit("\t", 2) + self.assertEqual(kind, "static", line) + frames[name] = int(size) + self.assertTrue(frames) + # Compiler recursion is limited to eight groups. Conservatively + # count each parser frame at every level, plus runtime scratch. + recursive = sum(size for name, size in frames.items() + if any(token in name for token in ("::atom(", "::sequence(", "::expression("))) + other = max(size for name, size in frames.items() + if not any(token in name for token in ("::atom(", "::sequence(", "::expression("))) + self.assertLessEqual(recursive * 9 + other + 64, 2048, frames) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_morse_auth.py b/test/test_morse_auth.py index cc77dc45b..15f4e28fa 100644 --- a/test/test_morse_auth.py +++ b/test/test_morse_auth.py @@ -133,6 +133,7 @@ class MorseAuthorizationTests(unittest.TestCase): "static bool commandFamilyMatches(", "static bool isCommonManagerReadOnlyAllowed(", "static bool isRegionMgrAllowed(", "static bool isFilterMgrAllowed(", )) + helpers = f'#include "{(ROOT / "src/helpers/FloodCommandAliases.h").as_posix()}"\n' + helpers code = HARNESS.replace("@MANAGER_HELPERS@", helpers).replace("@PORTABLE_HANDOFF@", handoff) for extended_filters in (0, 1): with self.subTest(extended_filters=extended_filters): diff --git a/test/test_room_filter_extensions.py b/test/test_room_filter_extensions.py new file mode 100644 index 000000000..6376110d0 --- /dev/null +++ b/test/test_room_filter_extensions.py @@ -0,0 +1,266 @@ +"""Run the complete production FULL-room rule engine over host I/O boundaries.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +UTILS = r'''#pragma once +#include +#include +#include +#include +namespace mesh { +inline std::vector test_plaintext; +inline bool test_decrypt_ok=false; +struct RNG { virtual ~RNG() = default; virtual void random(uint8_t*, size_t) = 0; }; +struct Utils { + static void sha256(uint8_t* out, size_t size, const uint8_t*, size_t) { memset(out, 0, size); } + static bool isHexChar(char c) { return (c>='0'&&c<='9')||(c>='a'&&c<='f')||(c>='A'&&c<='F'); } + static unsigned nibble(char c) { return c<='9' ? c-'0' : (c|32)-'a'+10; } + static bool fromHex(uint8_t* out, int size, const char* text) { + if (strlen(text)!=size_t(size)*2) return false; + for (int i=0;i>4];out[i*2+1]=digits[bytes[i]&15];} + out[size*2]=0; + } + // Explicit crypto boundary: only the budget test supplies authenticated + // plaintext. These tests do not claim to verify ciphertext authentication. + static int MACThenDecrypt(const uint8_t*,uint8_t* out,const uint8_t*,int) { + if(!test_decrypt_ok)return 0; + memcpy(out,test_plaintext.data(),test_plaintext.size());return int(test_plaintext.size()); + } +}; +} +''' + +BOUNDARIES = r'''#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#define TXT_TYPE_PLAIN 0 +class MemoryFS; +class File { + MemoryFS* owner=nullptr; + std::shared_ptr> bytes; + size_t cursor=0; +public: + File()=default; + File(MemoryFS* fs,std::shared_ptr> data):owner(fs),bytes(data){} + operator bool() const {return bool(bytes);} + bool isDirectory() const {return false;} + size_t size() const {return bytes ? bytes->size() : 0;} + size_t available() const {return size()-cursor;} + size_t read(uint8_t* out,size_t count) { + count=std::min(count,available()); + if(count) memcpy(out,bytes->data()+cursor,count); + cursor+=count;return count; + } + size_t write(const uint8_t*,size_t); + void close() {bytes.reset();} +}; +class MemoryFS { +public: + std::map>> files; + std::set faults; + bool exists(const char* path) const {return files.count(path)!=0;} + bool remove(const char* path) {return files.erase(path)!=0;} + bool rename(const char* from,const char* to) { + if(faults.count(std::string("rename:")+from+":"+to)||!exists(from)||exists(to))return false; + files[to]=files[from];files.erase(from);return true; + } + File open(const char* path,const char* mode="r",bool=false) { + if(faults.count(std::string("open:")+mode+":"+path))return {}; + if(mode[0]=='w')files[path]=std::make_shared>(); + return exists(path)?File(this,files[path]):File(); + } +}; +inline size_t File::write(const uint8_t* data,size_t count) { + if(!bytes)return 0; + bytes->insert(bytes->end(),data,data+count);return count; +} +using FILESYSTEM=MemoryFS; +struct TransportKey { + uint8_t key[16]{}; + bool isNull() const {for(auto byte:key)if(byte)return false;return true;} + uint16_t calcTransportCode(const mesh::Packet*) const {return 0;} +}; +#define REGION_DENY_FLOOD 1 +struct RegionEntry {uint8_t flags=0;char name[32]{};bool isWildcard()const{return false;}}; +struct RegionMap { + static bool is_name_char(uint8_t c) {return std::isalnum(c)||c=='_'||c=='-';} + RegionEntry* findByName(const char*) {return nullptr;} + RegionEntry* findByNamePrefix(const char*) {return nullptr;} + int getTransportKeysFor(const RegionEntry&,TransportKey*,int) {return 0;} +}; +''' + +PROGRAM = r''' +#include "FloodRuleEngine.h" +#include +mesh::Packet::Packet():header(ROUTE_TYPE_FLOOD),payload_len(0),path_len(0), + transport_codes{},path{},payload{},_rssi(0),_snr(0),tx_cr(0),flood_retry_policy(0){} +struct TestRng:mesh::RNG { + uint32_t value=0;unsigned calls=0; + void random(uint8_t* out,size_t size) override {assert(size==4);memcpy(out,&value,4);++calls;} +}; +static MemoryFS disk; +static RegionMap regions; +static TestRng rng; +static FloodRuleEngine rules; +static char reply[160]; +void command(const char* text,bool success=true) { + assert(rules.handleCommand(text,reply)); + assert((strncmp(reply,"Err",3)!=0)==success); +} +bool blocked(mesh::Packet& packet,uint32_t now=0,bool commit=false) { + auto matches=rules.evaluate(&packet,false,true,nullptr); + bool result=rules.shouldBlock(&packet,matches,now); + if(!result&&commit)rules.commitRates(&packet,matches,now); + return result; +} +void reboot() {rules.begin(&disk,®ions,&rng);} +uint32_t seedFor(const mesh::Packet& packet,unsigned slot,unsigned wanted) { + for(uint32_t seed=0;;++seed) + if(FloodRuleExtensions::probabilityRoll(&packet,seed,slot,0)%100==wanted)return seed; +} +int main() { + reboot();command("del fr all"); + assert(!memcmp(disk.files.at("/flood_filter")->data(),"FPF7",4)); + mesh::Packet packet;packet.header=uint8_t(PAYLOAD_TYPE_ADVERT<<2)|ROUTE_TYPE_FLOOD; + packet.payload_len=40; + const uint32_t stable=FloodRuleExtensions::probabilityRoll(&packet,12345,3,0); + packet.path[0]=0xAB;packet.setPathHashSizeAndCount(3,1);packet.transport_codes[0]=0x1234; + packet.header=uint8_t(PAYLOAD_TYPE_ADVERT<<2)|ROUTE_TYPE_TRANSPORT_FLOOD; + assert(stable==FloodRuleExtensions::probabilityRoll(&packet,12345,3,0)); + packet.header=uint8_t(PAYLOAD_TYPE_ADVERT<<2)|ROUTE_TYPE_FLOOD; + assert(stable!=FloodRuleExtensions::probabilityRoll(&packet,12346,3,0)); + assert(stable!=FloodRuleExtensions::probabilityRoll(&packet,12345,4,0)); + assert(stable!=FloodRuleExtensions::probabilityRoll(&packet,12345,3,1)); + packet.payload[0]=1;assert(stable!=FloodRuleExtensions::probabilityRoll(&packet,12345,3,0)); + packet.payload[0]=0; + command("set flood.rule.4 type=advert len=30-50 prob=25 drop"); + assert(!memcmp(disk.files.at("/flood_filter")->data(),"FPF8",4)); + rng.value=seedFor(packet,3,24);reboot();assert(blocked(packet)); + const unsigned draws=rng.calls; + for(unsigned i=0;i<20;++i)assert(blocked(packet)); + assert(rng.calls==draws); + rng.value=seedFor(packet,3,25);reboot();assert(!blocked(packet)); + packet.payload_len=29;assert(!blocked(packet));packet.payload_len=51;assert(!blocked(packet)); + packet.payload_len=40;rng.value=seedFor(packet,3,0);reboot();assert(blocked(packet)); + command("set fr.4 off");assert(!blocked(packet));reboot();assert(!blocked(packet)); + command("set fr.4 e=on");assert(blocked(packet)); + command("set fr.4 enabled=off");assert(!blocked(packet)); + command("get fr.4");assert(strstr(reply,"e=off")); + command("set fr.4 enabled=on");assert(blocked(packet)); + command("set fr.5 any rt=d d");packet.header=uint8_t(PAYLOAD_TYPE_ACK<<2)|ROUTE_TYPE_DIRECT; + assert(blocked(packet));packet.header=uint8_t(PAYLOAD_TYPE_ACK<<2)|ROUTE_TYPE_FLOOD; + assert(!blocked(packet)); + command("set fr.5 any rt=d h=2+ d");packet.setPathHashSizeAndCount(1,3); + packet.header=uint8_t(PAYLOAD_TYPE_ACK<<2)|ROUTE_TYPE_DIRECT;assert(!blocked(packet)); + command("set fr.5 any rt=d s=test",false); + command("del fr all"); + packet.header=uint8_t(PAYLOAD_TYPE_ACK<<2)|ROUTE_TYPE_FLOOD; + command("set fr.4 any pa=AABB>CCDD$ d"); + packet.setPathHashSizeAndCount(2,3); + const uint8_t path[]={0,1,0xAA,0xBB,0xCC,0xDD};memcpy(packet.path,path,sizeof(path)); + assert(blocked(packet));packet.path[0]=0xAA;packet.path[1]=0xBB; + packet.path[2]=0xCC;packet.path[3]=0xDD;packet.path[4]=0;packet.path[5]=1; + assert(!blocked(packet)); + command("set fr.4 any pa=^AA>BB>CC>DD d");packet.setPathHashSizeAndCount(1,4); + const uint8_t four[]={0xAA,0xBB,0xCC,0xDD};memcpy(packet.path,four,4); + assert(blocked(packet));reboot();assert(blocked(packet)); + assert(!memcmp(disk.files.at("/flood_filter")->data(),"FPF8",4)); + command("get fr.4");assert(strstr(reply,"pa=^AA>BB>CC>DD")); + command("set fr.4 any c=public n=\"^(Alice|Bob) Smith$\" x=\"^RX in place\" a"); + command("get fr.4 n");assert(!strcmp(reply,"> ^(Alice|Bob) Smith$")); + command("get flood.rule.4 text");assert(!strcmp(reply,"> ^RX in place")); + reboot();command("get fr.4 x");assert(!strcmp(reply,"> ^RX in place")); + command("set fr.4 any c=hash:11 n=Alice d",false); + command("set fr.4 any n=Alice d",false); + command("set fr.4 any c=public n=\"unterminated d",false); + command("get fr.4 n");assert(!strcmp(reply,"> ^(Alice|Bob) Smith$")); + command("set fr.31 advert d"); + disk.faults.insert("open:w:/flood_filter.tmp");command("del fr all",false); + command("get flood.rule.31");assert(strstr(reply,"advert")); + command("set fr.31 off",false);command("get fr.31");assert(!strstr(reply,"e=off")); + disk.faults.clear();command("del fr all"); + command("set fr.4 advert q=1"); + packet.header=uint8_t(PAYLOAD_TYPE_ADVERT<<2)|ROUTE_TYPE_FLOOD; + assert(!blocked(packet,0,true));assert(blocked(packet,1)); + command("set fr.4 off");assert(!blocked(packet,2,true)); + command("set fr.4 on");assert(blocked(packet,3)); + assert(!blocked(packet,60000)); + command("del fr all");command("set fr.4 any l=0 d"); + packet.payload_len=0;assert(blocked(packet));packet.payload_len=1;assert(!blocked(packet)); + command("del fr all"); + for(unsigned slot=1;slot<=31;++slot) { + char text[256]; + snprintf(text,sizeof(text),"set fr.%u any c=public x=(a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a|a)*z q=1 s=test stop",slot); + command(text); + } + packet.header=uint8_t(PAYLOAD_TYPE_GRP_TXT<<2)|ROUTE_TYPE_FLOOD; + packet.payload_len=179;packet.payload[0]=0; + mesh::test_plaintext.assign(176,'a'); + memset(mesh::test_plaintext.data(),0,5); + memcpy(mesh::test_plaintext.data()+5,"U: ",3); + mesh::test_decrypt_ok=true; + const uint32_t exhausted=rules.evaluate(&packet,false,true,nullptr); + assert(exhausted==(uint32_t(1)<<31)); + assert(rules.shouldBlock(&packet,exhausted,0)); + bool scope_set=false,fast_track=false; + assert(!rules.applyScope(&packet,exhausted,scope_set,fast_track)); + assert(!scope_set&&!fast_track); + rules.commitRates(&packet,exhausted,0); + mesh::test_decrypt_ok=false; + assert(!rules.handleCommand("get fridge",reply)); + command("get fb",false); + puts("FULL-room filter parser, persistence, route, path, probability and rollback passed"); +} +''' + + +class RoomFilterExtensionTests(unittest.TestCase): + def test_complete_production_engine(self): + with tempfile.TemporaryDirectory(prefix="room-filter-extensions-") as directory: + work = Path(directory) + (work / "Utils.h").write_text(UTILS) + (work / "boundaries.h").write_text(BOUNDARIES) + (work / "RoomServerFeatures.h").write_text("#define MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE 1\n") + header = (ROOT / "examples/simple_room_server/FloodRuleEngine.h").read_text() + for include in ("Arduino.h", "Mesh.h", "helpers/IdentityStore.h", "helpers/RegionMap.h"): + header = header.replace(f"#include <{include}>", "") + (work / "FloodRuleEngine.h").write_text('#include "boundaries.h"\n' + header) + (work / "FloodRuleEngine.cpp").write_text( + (ROOT / "examples/simple_room_server/FloodRuleEngine.cpp").read_text()) + (work / "test.cpp").write_text(PROGRAM) + executable = work / ("test.exe" if os.name == "nt" else "test") + sanitizer = [] if os.name == "nt" else [ + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + built = subprocess.run(["g++", "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", + "-Wno-unused-variable", *sanitizer, "-I", str(work), "-I", str(ROOT / "src"), + str(work / "test.cpp"), str(work / "FloodRuleEngine.cpp"), "-o", str(executable)], + capture_output=True, text=True, timeout=90) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + run = subprocess.run([str(executable)], capture_output=True, text=True, timeout=20) + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_transport_filter_wiring.py b/test/test_transport_filter_wiring.py index ede8771f0..27bde3e71 100644 --- a/test/test_transport_filter_wiring.py +++ b/test/test_transport_filter_wiring.py @@ -206,6 +206,76 @@ int main() { ''' class TransportFilterWiring(unittest.TestCase): + def test_aliases_dispatch_the_original_suffix_without_expanding_a_command_buffer(self): + source = (ROOT/'examples/simple_repeater/MyMesh.cpp').read_text() + start = source.index(' if (commandFamilyMatches(command, "get flood.channel.scope.require"))') + end = source.index('#if MESH_ENABLE_CLOCK_SYNC', start) + dispatch = source[start:end] + helper = method(source, 'static bool commandFamilyMatches(') + code = (f'#include "{(ROOT/"src/helpers/FloodCommandAliases.h").as_posix()}"\n' + '#include \n#include \n#include \n') + helper + r''' +static const char* actual_args; +static const char* actual_action; +#define STUB(name) static void name(const char* args,char* reply) {actual_args=args;actual_action=#name;strcpy(reply,"OK");} +STUB(formatFloodChannelScopeRequirements) +STUB(setFloodChannelScopeRequirement) +STUB(deleteFloodChannelScopeRequirement) +STUB(formatFloodChannelScopes) +STUB(setFloodChannelScope) +STUB(deleteFloodChannelScope) +STUB(formatFloodPacketFilterBlacklist) +STUB(setFloodPacketFilterBlacklist) +STUB(deleteFloodPacketFilterBlacklist) +STUB(deleteFloodPacketFilter) +STUB(formatFloodGroupModeration) +STUB(setFloodGroupModeration) +STUB(deleteFloodGroupModeration) +static void formatFloodPacketFilters(const char* args,char* reply,bool=false) {actual_args=args;actual_action="formatFloodPacketFilters";strcpy(reply,"OK");} +static void setFloodPacketFilter(const char* args,char* reply,bool=false) {actual_args=args;actual_action="setFloodPacketFilter";strcpy(reply,"OK");} +void run(const char* command,char* reply) { +@DISPATCH@ +else strcpy(reply,"unhandled"); +} +int main() { + struct Test {const char* command;const char* suffix;const char* action;}; + for(const auto& row : { + Test{"get fm.2",".2","formatFloodGroupModeration"}, + Test{"set fm.2 public Alice drop",".2 public Alice drop","setFloodGroupModeration"}, + Test{"del fm.2",".2","deleteFloodGroupModeration"}, + Test{"get fs.2",".2","formatFloodChannelScopes"}, + Test{"set fs.2 public scope=local",".2 public scope=local","setFloodChannelScope"}, + Test{"del fs.2",".2","deleteFloodChannelScope"}, + Test{"get fsr.2",".2","formatFloodChannelScopeRequirements"}, + Test{"set fsr.2 public",".2 public","setFloodChannelScopeRequirement"}, + Test{"del fsr.2",".2","deleteFloodChannelScopeRequirement"}, + Test{"get fb.2",".2","formatFloodPacketFilterBlacklist"}, + Test{"set fb.2 AABBCC",".2 AABBCC","setFloodPacketFilterBlacklist"}, + Test{"del fb.2",".2","deleteFloodPacketFilterBlacklist"}, + Test{"get fr.2 n",".2 n","formatFloodPacketFilters"}, + Test{"set fr.2 any n=Alice d",".2 any n=Alice d","setFloodPacketFilter"}, + Test{"del fr.2",".2","deleteFloodPacketFilter"}, + Test{"set flood.rule.2 any n=Alice d",".2 any n=Alice d","setFloodPacketFilter"}}) { + char reply[160]{}; + actual_args=nullptr;actual_action=nullptr; + run(row.command,reply); + assert(!strcmp(reply,"OK") && actual_args && actual_action); + assert(!strcmp(actual_args,row.suffix) && !strcmp(actual_action,row.action)); + assert(actual_args>=row.command && actual_args<=row.command+strlen(row.command)); + } + for(const char* command : {"get fmore","get fsrs","get fbwifi.password","get fridge"}) { + char reply[160]{};run(command,reply);assert(!strcmp(reply,"unhandled")); + } +} +''' + code = code.replace('@DISPATCH@', dispatch) + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + (work/'test.cpp').write_text(code) + subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror', + '-DMESH_ENABLE_FLOOD_RULE_ENGINE=1', '-DMESH_ENABLE_FLOOD_GROUP_MODERATION=1', + str(work/'test.cpp'), '-o', str(work/'test')], check=True) + subprocess.run([str(work/'test')], check=True) + def test_temporary_filter_suspension_checks_both_profiles_in_all_role_paths(self): repeater = (ROOT/'examples/simple_repeater/MyMesh.cpp').read_text() suspension = re.findall(r'entry\.suspend_on_temp_radio && (\w+)\(\)', repeater) @@ -219,14 +289,34 @@ class TransportFilterWiring(unittest.TestCase): helpers = '\n'.join(method(source, signature) for signature in [ 'static bool commandFamilyMatches(', 'static bool isCommonManagerReadOnlyAllowed(', 'static bool isRegionMgrAllowed(', 'static bool isFilterMgrAllowed(']) - code = '#include \n#include \n#include \n' + helpers + r''' + code = (f'#include "{(ROOT/"src/helpers/FloodCommandAliases.h").as_posix()}"\n' + '#include \n#include \n#include \n') + helpers + r''' int main() { - for (const char* cmd : {"get fr", "get fr.3", "set fr.3 any m=bc d", "del fr.3", "del fr all"}) { + for (const char* cmd : {"get fr", "get fr.3", "set fr.3 any m=bc d", "del fr.3", "del fr all", + "set fr.3 any c=public sender=^Alice$ text=BEACON len=1-80 prob=50 drop", + "set flood.rule.3 type=grp_txt channel=public sender=^Alice$ text=BEACON drop", + "set flood.rule.3 type=any route=direct path=^AABB>CCDD$ enabled=off stop"}) { assert(isFilterMgrAllowed(cmd) == (MESH_ENABLE_FLOOD_RULE_ENGINE != 0)); assert(!isRegionMgrAllowed(cmd)); } - for (const char* cmd : {"get fridge", "set frwifi.password secret", "get wifi.password", "del frx"}) + for (const char* cmd : {"get fridge", "set frwifi.password secret", "get wifi.password", "del frx", + "set frwifi.password sender=^Alice$", "set flood.rulebook type=any drop", + "get fleet.channel", "set fleet.controller off"}) assert(!isFilterMgrAllowed(cmd)); + for (const char* cmd : {"get fm", "set fm.2 public Alice drop", "del fm.2", + "get fb", "set fb.2 AABBCC", "del fb.2", "get fa", "get fa 2", "clear fa all"}) { + assert(isFilterMgrAllowed(cmd)); + assert(!isRegionMgrAllowed(cmd)); + } + for (const char* cmd : {"get fs", "set fs.2 public scope=local", "del fs.2", + "get fsr", "set fsr.2 public", "del fsr.2"}) { + assert(isRegionMgrAllowed(cmd)); + assert(!isFilterMgrAllowed(cmd)); + } + for (const char* cmd : {"get fmore", "set fss.password secret", "get fsrs", "clear failure all"}) { + assert(!isFilterMgrAllowed(cmd)); + assert(!isRegionMgrAllowed(cmd)); + } } ''' with tempfile.TemporaryDirectory() as directory: