From 09c8aaba9c34121235e966fcdf886440b95e3515 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 17 Jul 2026 00:35:51 -0700 Subject: [PATCH] Add repeater flood filters and transport scopes --- arch/stm32/build_hex.py | 10 +- docs/cli_commands.md | 184 +++++- docs/flood_filtering.md | 192 +++++- docs/halo_keymind_settings.md | 3 +- examples/simple_repeater/MyMesh.cpp | 590 ++++++++++++++++-- examples/simple_repeater/MyMesh.h | 36 +- examples/simple_room_server/MyMesh.h | 2 - examples/simple_sensor/SensorMesh.h | 2 - src/Mesh.h | 4 +- src/helpers/RegionMap.cpp | 2 +- src/helpers/RegionMap.h | 2 +- src/helpers/StaticPoolPacketManager.cpp | 58 +- src/helpers/StaticPoolPacketManager.h | 18 +- .../test_simple_mesh_tables.cpp | 12 + .../test_rx_reserve_packet_manager.cpp | 192 +++++- variants/generic-e22/platformio.ini | 2 + variants/heltec_v2/platformio.ini | 1 + variants/lilygo_tbeam_SX1262/platformio.ini | 2 + variants/lilygo_tbeam_SX1276/platformio.ini | 2 + variants/lilygo_tlora_v2_1/platformio.ini | 2 + variants/meshadventurer/platformio.ini | 2 + variants/rak3x72/platformio.ini | 3 + variants/tiny_relay/platformio.ini | 3 + variants/wio-e5-dev/platformio.ini | 6 + variants/wio-e5-mini/platformio.ini | 3 + 25 files changed, 1222 insertions(+), 111 deletions(-) diff --git a/arch/stm32/build_hex.py b/arch/stm32/build_hex.py index ffdb5d2f..db44ed66 100644 --- a/arch/stm32/build_hex.py +++ b/arch/stm32/build_hex.py @@ -1,5 +1,13 @@ Import("env") +# Run the size-oriented interprocedural passes during the final STM32 LTO link. +# PlatformIO's ordinary build flags do not forward these options to this link. +env.Append(LINKFLAGS=[ + "-flto-partition=one", + "-fipa-pta", + "-fno-semantic-interposition", +]) + # Make custom HEX from ELF env.AddPostAction( "$BUILD_DIR/${PROGNAME}.elf", @@ -7,4 +15,4 @@ env.AddPostAction( "$OBJCOPY", "-O", "ihex", "-R", ".eeprom", '"$BUILD_DIR/${PROGNAME}.elf"', '"$BUILD_DIR/${PROGNAME}.hex"' ]), "Building $BUILD_DIR/${PROGNAME}.hex") -) \ No newline at end of file +) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 78e32fde..d56b0b1b 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -982,6 +982,14 @@ writes that slot. `set flood.channel.block #wardriving h=` or removed with `del flood.channel.block #wardriving`. Once the block list has been saved, the firmware uses the saved list and does not recreate the default after deletion. +Restore the exact seeded row with: + +```text +set flood.channel.block.1 #wardriving h=4 +``` + +This explicitly writes slot 1. Omit `.1` to update an existing wardriving row +or use the first empty slot instead. **Forwarding behavior:** Repeater firmware only. This only affects received flood `GRP_TXT` and `GRP_DATA` channel packets. The repeater still receives and @@ -1032,6 +1040,132 @@ del flood.channel.block.2 --- +#### Force a transport scope onto unscoped floods +**Usage:** +- `get flood.channel.scope` +- `get flood.channel.scope.` +- `set flood.channel.scope ` +- `set flood.channel.scope. ` +- `del flood.channel.scope.` +- `del flood.channel.scope all` + +**Parameters:** +- `n`: Slot number within the table compiled for the target. Roomy ESP32 builds + provide `1-255`; DRAM-tight classic ESP32 LoRa-OTA repeaters and nRF52/other + normal constrained builds provide `1-31`; very-tight STM32WL repeaters provide + `1-15`; the no-PSRAM LilyGo T-LoRa V2.1 repeater/observer provides `1-4`. +- `channel`: `public`, a public `#channel`, or a 128/256-bit channel key in hex. +- `txt:*`: Unauthenticated fallback for otherwise-unmatched `GRP_TXT` and + `GRP_DATA`. Plain `*` is an alias for `txt:*`. +- `login:*`: Type-based wildcard for the remote-login/admin family: `REQ`, + `RESPONSE`, `TXT_MSG`, `ANON_REQ`, and `PATH` (`0x00`, `0x01`, `0x02`, + `0x07`, and `0x08`). It classifies the outer type; a transit repeater cannot + authenticate whether a packet is actually part of a login session. +- `other:*`: Type-based wildcard for every remaining flood payload type except + TRACE, including ACK, advert, multipart, control, OTA, reserved types, and + raw custom. TRACE is deliberately exempt from forced-scope wildcards. +- `region`: Existing named region with a usable transport key. A unique region + name prefix is accepted; wildcard region `*` is not a scope target. + +**Default:** No forced scopes. + +Remote ACL permission `4` (region/scope manager) can use all `get`, `set`, and +`del flood.channel.scope` forms. Filter managers and other non-admin roles +cannot change this table. + +Without `.n`, `set` updates the row for the same exact channel key or wildcard +class, otherwise it uses the first empty slot. With `.n`, it replaces that +slot. The three wildcard classes are independent and consume one slot each. +`get flood.channel.scope` reports active/total slot counts; use the numbered +form for row detail. Keyed rows are displayed by the first four bytes of their +derived channel hash because channel secrets are never returned. + +This acts on received, unscoped `ROUTE_TYPE_FLOOD` packets. For `GRP_TXT` and +`GRP_DATA`, all exact channel-key rows are tried first and must validate the +packet MAC/decryption. A row whose target region is missing or unusable is +skipped; later exact rows and then `txt:*` are tried. Exact keyed rows with a +usable target therefore beat `txt:*` regardless of slot number. `login:*` and +`other:*` select their non-overlapping outer-type families without decrypting +the payload. Duplicate rows within the same class are permitted with numbered +slots; the lowest usable slot wins. + +Standard traceroute is direct-routed and is therefore outside this flood-only +table. A custom flood-form `TRACE` is also left unchanged: no wildcard adds a +scope, an existing transport code is preserved, and region/unknown-code gates +do not block it. + +On a match, the repeater changes the route to +`ROUTE_TYPE_TRANSPORT_FLOOD`, computes transport code 0 from the selected +region key and packet payload, and leaves transport code 1 as zero. This occurs +before region enforcement, forwarding filters, and the seen-packet lookup. +Already-scoped packets and direct routes are never rewritten. The rewritten +packet is no longer subject to `flood.max.unscoped`, but remains subject to +normal payload handling, `flood.max`, region allow/deny, `flood.filter`, +`flood.channel.block`, loop detection, and moderation. Assigning a scope does +not make a packet type forwardable if the core would otherwise reject it. + +If a row's target region has been removed or has no usable key, the repeater +tries the next applicable row. For group packets this means later authenticated +exact rows followed by `txt:*`; wildcard duplicates likewise fall through to +the next usable slot. The packet remains unscoped only when no usable mapping +exists. + +LoRa OTA remains functional when `other:*` is configured. OTA packets are +given that region's transport code, but the OTA handler still accepts and +re-floods them during the temporary-radio window. The target region must allow +flooding. The OTA core itself is dormant outside that window; no default flood +filter row is needed for that behavior. Forced scope does not make OTA operate +outside the window. + +**Capacity cost:** Each slot uses 36 bytes of runtime RAM and persistent +storage, plus a 5-byte file header. The four-slot minimum uses 144 bytes RAM and +a 149-byte file; it has room for the three wildcard classes plus one exact +channel mapping. Very-tight 15-slot builds use 540 bytes RAM and a 545-byte +file. The 31-slot table uses 1,116 bytes RAM and a 1,121-byte file. Roomy ESP32 +builds use a 255-slot table: 9,180 bytes RAM and a 9,185-byte file. Classic +ESP32 LoRa-OTA builds that cannot afford that RAM use 31 slots instead. The +existing region table still permits 32 named regions, so a 255-slot channel +table may reuse targets but cannot name more than 32 distinct configured region +scopes. + +**Duplicate behavior:** Mesh dedup hashes payload type and payload bytes; it +does not hash route type, transport codes, or the ordinary flood path. Adding +a transport scope therefore does not create a new duplicate identity. If the +same payload later arrives scoped, unscoped, or through a different region, it +is still the same seen packet. `TRACE` is the exception only in that its +encoded `path_len` byte is also hashed. + +While equivalent non-TRACE flood copies are waiting in `rxdelay`, the normal receive-quality +timing still selects the packet to process, but that winner receives a scope +from the queued scoped copies with the same dedupe identity. If the copies +carry different locally allowed scopes, the scope from the shortest received +path wins. Unknown and denied transport codes are not candidates and therefore +cannot overwrite an unscoped winner. With equal path lengths, the deeper child +region wins because it is narrower. A remaining tie keeps queue order. The +winner's own path, SNR reading, and scheduled time are not changed, and an +already-scoped winner may have its code replaced by the better queued scope. + +The comparison is deferred until dequeue so each copy retains its original +scope and path for arbitration. It can only use copies still present in +`rxdelay`; it cannot replace a packet that already won the dedupe race. TRACE +is excluded from scope arbitration entirely, so rxdelay never adds or replaces +a trace transport code. + +**Examples:** +```text +region put west +region save +set flood.channel.scope #local west +set flood.channel.scope.2 txt:* west +set flood.channel.scope.3 login:* west +set flood.channel.scope.4 other:* west +get flood.channel.scope +get flood.channel.scope.1 +del flood.channel.scope.2 +``` + +--- + #### Filter flood packets by payload type and hop For setup guidance, interactions with the existing forwarding controls, and @@ -1040,19 +1174,24 @@ worked moderation examples, see [Repeater Flood Filtering and Moderation](flood_ **Usage:** - `get flood.filter` - `get flood.filter.` -- `set flood.filter ` -- `set flood.filter. ` +- `set flood.filter [hops] [suspend=tempradio]` +- `set flood.filter. [hops] [suspend=tempradio]` - `del flood.filter.` - `del flood.filter all` **Parameters:** - `n`: Rule slot from `1` to `16`. -- `type`: Payload type name, full `PAYLOAD_TYPE_*` name, numeric value `0-15`, or `any`. -- `hops`: +- `type`: Payload type name, full `PAYLOAD_TYPE_*` name, decimal value `0-15`, + hexadecimal value `0x00-0x0F`, or `any`. +- `hops`: Optional; omitted means `all`. - `N`: Block only at received hop count `N`. - `N+`: Block at received hop count `N` and higher. - `N-M`: Block the inclusive received-hop range. - `all`: Block at every received hop count (`0-63`). + - `0+`, `all`, and an omitted hop expression are equivalent. The CLI displays + the saved range as `all`. +- `suspend=tempradio`: Optional. Skip this row only while the temporary radio + is actually active. The payload names follow the [MeshCore packet-format allocation](https://docs.meshcore.io/packet_format/): @@ -1081,19 +1220,39 @@ The payload names follow the [MeshCore packet-format allocation](https://docs.me are never affected. **Behavior:** A match prevents retransmission by this repeater. The packet is -still received and can still be logged. Rules are persistent and default to an -empty table, so upgrading does not change forwarding. A malformed persisted -table fails open (no general rules are applied). +still received and can still be logged. Rules are persistent and the table is +empty by default. While the temporary radio is active, only rows explicitly +marked `suspend=tempradio` are skipped. `tempradio` is a radio state, not an OTA +mode; normal payload types can also use the temporary channel. Other rows +remain in force. A malformed persisted table fails open (no general rules are +applied). -Without `.n`, `set` returns an identical existing rule or uses the first empty -slot. With `.n`, it replaces that slot. `get flood.filter` gives a compact list; -use `get flood.filter.` for full details. +**Remote-admin protection:** `flood.filter` cannot block `anon_req`, `path`, or +`response` at received hop counts `0-6`; those login-capable types become +filterable at hop `7`. Flood `txt_msg` cannot be blocked at hops `0-4` and +becomes filterable at hop `5`. `req`, `ack`, and multipart ACK have no special +protection. Transit repeaters cannot decrypt these packets to distinguish an +admin exchange from ordinary peer traffic, so each floor necessarily covers +all flood packets of that outer type. These exceptions apply only to +`flood.filter`; `repeat`, `flood.max*`, region, loop-detection, and other +forwarding gates remain authoritative. + +Without `.n`, `set` returns an identical existing rule, including its suspension +setting, or uses the first empty slot. With `.n`, it replaces that slot. +`get flood.filter` gives a compact list. Use `get flood.filter.` for full +details, including `suspend=tempradio`. + +Standard traceroute is direct-routed and therefore outside `flood.filter` +entirely. For a custom flood-form trace, catch-all `any` rows are deliberately +ignored; only a deliberately explicit `set flood.filter trace ...` row can +match it. **Examples:** ```text set flood.filter grp_data 4+ set flood.filter.2 PAYLOAD_TYPE_ADVERT 6+ set flood.filter ota 2-4 +set flood.filter grp_data all suspend=tempradio set flood.filter any 12+ get flood.filter get flood.filter.2 @@ -1174,7 +1333,7 @@ del flood.moderation.3 - `1`: Read-only - `2`: Read-write - `3`: Admin - - `4`: Region manager (repeater delegated region management) + - `4`: Region/scope manager (repeater delegated region and forced-scope management) - `5`: Filter manager (repeater delegated forwarding-filter management) **Filter manager scope:** Permission `5` can use an explicit allowlist of @@ -1183,7 +1342,8 @@ controls `repeat`, `loop.detect`, `flood.max*`, `flood.channel.data*`, `flood.channel.block*`, `flood.filter*`, and `flood.moderation*`. It cannot read guest, WiFi, MQTT, bridge, or other credentials, and it cannot change regions, ACL entries, radio settings, or other admin configuration. Permission `4` -remains limited to region commands and the same non-secret status allowlist. +is limited to region commands, `flood.channel.scope*`, and the same non-secret +status allowlist. Both delegated manager roles are protected from least-recently-active ACL eviction like administrators. diff --git a/docs/flood_filtering.md b/docs/flood_filtering.md index f0109926..09814366 100644 --- a/docs/flood_filtering.md +++ b/docs/flood_filtering.md @@ -27,13 +27,109 @@ get flood.max.advert get flood.channel.data get flood.channel.data.hops get flood.channel.block +get flood.channel.scope get flood.filter get flood.moderation ``` -The `flood.filter` and `flood.moderation` tables each have 16 persistent slots. -They are empty by default. A corrupt or truncated table fails open, so corrupt -storage does not silently enable blocking. +The `flood.filter` and `flood.moderation` tables each have 16 persistent slots +and start empty. A row can opt into `suspend=tempradio`; temporary radio is not +synonymous with OTA and can carry normal packet types too. A corrupt or +truncated table fails open, so corrupt storage does not silently enable +blocking. + +## Force unscoped floods into a transport scope + +`flood.channel.scope` can convert a received unscoped flood into a +transport-scoped flood before this repeater forwards it: + +```text +set flood.channel.scope +set flood.channel.scope. +get flood.channel.scope +get flood.channel.scope. +del flood.channel.scope. +del flood.channel.scope all +``` + +The channel may be `public`, `#channel`, or a 128/256-bit hex key. The region +must already exist and provide a usable transport key. Keyed rules first check +the one-byte channel hash carried in the packet, then validate the MAC by +decrypting with the configured channel key. A hash collision alone cannot +force a scope. + +There are three independent wildcard classes: + +- `txt:*` handles otherwise-unmatched `GRP_TXT` and `GRP_DATA`; plain `*` is + its alias. +- `login:*` handles `REQ`, `RESPONSE`, `TXT_MSG`, `ANON_REQ`, and `PATH`. +- `other:*` handles every remaining flood payload type except TRACE, including + OTA. TRACE is deliberately exempt from forced-scope wildcards. + +`login:*` and `other:*` classify only the visible outer payload type; they do +not authenticate its contents. Exact channel rows with usable target regions +always take precedence over `txt:*`, even if that wildcard has a lower slot +number. A missing or unusable target is skipped, so later exact rows and then +the applicable wildcard are tried. Within a wildcard class, the lowest usable +duplicate row wins. + +On a successful match, the route changes from `ROUTE_TYPE_FLOOD` to +`ROUTE_TYPE_TRANSPORT_FLOOD`. Transport code 0 is calculated with the target +region key over the payload type and payload; code 1 remains zero. The change +happens before region enforcement, forwarding filters, and deduplication. +Consequently `flood.max.unscoped` no longer applies to the rewritten packet, +but `flood.max`, target-region permissions, `flood.filter`, channel blocking, +loop detection, and moderation still do. + +Already-scoped floods and direct routes are never rewritten. TRACE is never +rewritten even in flood form; its existing code, if any, is preserved and it +bypasses region/unknown-code enforcement. Scope assignment also does not +override normal payload validation or make an otherwise non-forwardable packet +type forwardable. + +LoRa OTA (`0x0C`) falls under `other:*`. A matching row adds the selected +transport code, but OTA still operates normally during the temporary-radio +window because the OTA handler accepts both unscoped and transport-scoped flood +routes. The target region must allow flooding. The OTA core is already dormant +outside the temporary-radio window, so no default OTA filter row is installed. + +Capacity is selected at build time: + +- Roomy ESP32 builds: 255 slots, 9,180 bytes RAM, 9,185-byte file. +- DRAM-tight classic ESP32 LoRa-OTA repeaters, nRF52, and other normal + constrained builds: 31 slots, 1,116 bytes RAM, 1,121-byte file. +- Very-tight STM32WL builds: 15 slots, 540 bytes RAM, 545-byte file. +- The no-PSRAM LilyGo T-LoRa V2.1 repeater/observer: 4 slots, 144 bytes RAM, + 149-byte file. This minimum holds the three wildcard classes and one exact + channel mapping. + +The region map still has 32 named-region entries. Large ESP32 tables can map +many channels to the same targets, but cannot reference more than 32 distinct +configured region names. + +### Interaction with duplicate detection + +The seen-packet hash contains the payload type and exact payload bytes. It does +not contain the route type, either transport code, or the ordinary flood path. +For `TRACE` only, the encoded `path_len` byte is also included. Therefore an +unscoped packet and the same packet after this repeater adds a transport code +are the same duplicate. A later copy with a different scope is also the same +duplicate; changing or adding scope cannot evade the seen table. + +When equivalent non-TRACE flood copies overlap in `rxdelay`, the normal receive-quality timing +still chooses the packet to process, but that winner takes a scope from the +queued copies whose transport code matches an allowed region in this repeater. +Unknown and denied scopes are ignored. If eligible copies have different +scopes, the shortest received path supplies the scope. Equal path lengths +prefer the deepest matching child region (the narrowest configured scope). A +remaining tie keeps queue order. The winner keeps its own path, SNR, and delay +schedule; only its route and transport codes can change, including replacement +of a less-preferred scope it already carried. + +Scope selection happens at dequeue so the original scopes remain available for +comparison. It applies only while copies are queued and cannot alter a copy +already processed into the seen table. TRACE is excluded from scope arbitration +entirely, so rxdelay never adds or replaces a trace transport code. ## Filter by payload type and received hop count @@ -41,16 +137,57 @@ Use `flood.filter` when the packet type and its current path length are enough to make the decision: ```text -set flood.filter -set flood.filter. +set flood.filter [hops] [suspend=tempradio] +set flood.filter. [hops] [suspend=tempradio] get flood.filter get flood.filter. del flood.filter. del flood.filter all ``` -Without a slot number, `set` reuses an identical rule or selects the first empty -slot. With a slot number, it replaces that slot. +Without a slot number, `set` reuses an identical rule, including its suspension +setting, or selects the first empty slot. With a slot number, it replaces that +slot. Omitting the hop expression means `all` (`0-63`). + +No flood-filter row is created automatically. Operators may add +`suspend=tempradio` to any row that should be skipped while the radio is on a +temporary channel. + +Suspension does not approve a packet or bypass the rest of the filter table. It +skips that row, then evaluation continues with the next row and the remaining +forwarding gates. An ordinary `any` row therefore still applies during the +temporary-radio window, subject to the short-path remote-admin protection +below. `repeat`, `flood.max*`, region, loop-detection, and the OTA subsystem's +own hop limit also remain in force. + +Standard traceroute uses direct routing and never enters `flood.filter`. For a +custom flood-form trace, catch-all `any` rows are deliberately ignored; only an +explicit `trace` row can match it. The stock core does not normally +flood-forward TRACE packets. + +### Remote administration cannot be type-filtered on short paths + +`flood.filter` uses two minimum filterable hop counts: + +- `anon_req`, `path`, and `response` cannot be blocked at received hops `0-6`; + configured rules begin applying at hop `7`. +- Flood `txt_msg` cannot be blocked at received hops `0-4`; configured rules + begin applying at hop `5`. + +`req`, `ack`, and multipart ACK have no special floor and remain filterable from +hop `0`. + +A flooded login starts as `ANON_REQ`; its reply is commonly a `PATH` packet +carrying an encrypted `RESPONSE`. Before a direct return path is established, +administrative replies and CLI text can also be flooded. Transit repeaters do +not have the session key and cannot distinguish those encrypted admin exchanges +from ordinary peer packets with the same outer type. Each hop floor therefore +covers the complete outer packet class, not only packets that ultimately +authenticate as administrators. + +This protects only against the configurable `flood.filter` table. It does not +override `repeat`, `flood.max*`, region enforcement, loop detection, or other +forwarding gates. Hop expressions are based on the path count when this repeater receives the packet: @@ -59,6 +196,8 @@ packet: - `N+` matches `N` or more received hops. - `N-M` matches the inclusive range. - `all` matches `0` through `63` hops. +- `0+`, `all`, and omitting the hop expression are equivalent. `get` reports + the stored range using the canonical spelling `all`. Examples: @@ -72,7 +211,7 @@ set flood.filter.2 advert 6+ # Keep LoRa OTA floods from crossing this repeater at path counts 2 through 4. set flood.filter.3 ota 2-4 -# Apply a hard ceiling to every flood payload type. +# Apply a hard ceiling to flood payload types at 12 or more received hops. set flood.filter any 12+ ``` @@ -97,9 +236,10 @@ Accepted payload names are: | `0x0E` | `14` | reserved | | `0x0F` | `raw_custom` | `PAYLOAD_TYPE_RAW_CUSTOM` | -The numeric values `0` through `15`, the full `PAYLOAD_TYPE_*` names, and -`any` are also accepted. Upstream currently reserves `0x0C`; this fork assigns -it to LoRa OTA. +Decimal values `0` through `15`, hexadecimal values `0x00` through `0x0F`, the +full `PAYLOAD_TYPE_*` names, and `any` are also accepted. Upstream currently +reserves `0x0C`; this fork assigns it to LoRa OTA. Rows are suspended during +temporary-radio operation only when explicitly configured that way. ## Moderate group text by channel and username @@ -174,11 +314,13 @@ the packet contains all path entries listed by the rule. A flood packet is retransmitted only if it passes every applicable control. In other words, the controls combine as deny rules: -1. `repeat`, `flood.max*`, and the channel-data gate are checked. -2. `flood.filter` checks payload type and hop range. -3. `flood.channel.block` checks keyed channels. -4. Region and loop-detection rules are checked. -5. `flood.moderation` checks decrypted group text, username, rate, hops, and path. +1. `flood.channel.scope` first rewrites a matching unscoped flood packet. +2. `repeat`, `flood.max*`, and the channel-data gate are checked. +3. `flood.filter` checks payload type and hop range, subject to the login floor + of `7` and flood-text floor of `5` described above. +4. `flood.channel.block` checks keyed channels. +5. Region and loop-detection rules are checked. +6. `flood.moderation` checks decrypted group text, username, rate, hops, and path. The first denial is enough to prevent retransmission. A packet that is denied can still appear in local logs or MQTT output. Moderation runs last because its @@ -200,6 +342,10 @@ 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. +ACL permission `4`, the region/scope-manager role, can read, add, replace, and +delete `flood.channel.scope` rows and manage regions. This lets the same +delegate create target regions and assign them to forced-scope rows. + ## Security limitations Public and hashtag channels use shared, well-known keys. A valid channel MAC @@ -212,9 +358,21 @@ Use username and path rules as traffic moderation, not as an authorization boundary. For a strict network boundary, combine these tools with region ACLs, private transport/channel keys, and controlled device access. +## Restore the factory-seeded channel block + +The repeater's factory-seeded forwarding row can be restored through the CLI: + +```text +set flood.channel.block.1 #wardriving h=4 +``` + +This explicitly replaces slot 1. Inspect the slot first if it may now contain +another rule. To preserve existing slot assignments, omit `.1`; the command +then updates an identical channel row or uses the first empty slot. + ## Remove the custom rules -To return the two new tables to their default empty state: +To save both tables in an empty state: ```text del flood.filter all diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index 228383bb..151e9802 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -83,7 +83,8 @@ set flood.retry.ignore none | `flood.channel.data.hops` | Separate hop gate used only when `flood.channel.data` is `off`; `all` blocks `GRP_DATA` at any hop count, `1`-`7` repeats at that hop count or lower and blocks longer paths. | `get flood.channel.data.hops`, `set flood.channel.data.hops ` | `set flood.channel.data.hops 7` | | `flood.channel.block` | Blocks selected flood `GRP_TXT`/`GRP_DATA` channels when the key validates the packet. New repeater block lists start with editable/deletable `#wardriving h=4`. Add `h=` for a per-channel hop override. | `get flood.channel.block`, `set flood.channel.block[.n] [name] [h=...]`, `del flood.channel.block[.n]` | `set flood.channel.block #wardriving h=4` | | `flood.channel.block.hops` | Limits keyed channel-block matches to short flood paths. `all` blocks matching packets at any hop count; `1`-`7` repeats packets at that hop count or lower and blocks longer matches. This does not restrict unkeyed `GRP_DATA`; use `flood.channel.data.hops` for that. | `get flood.channel.block.hops`, `set flood.channel.block.hops ` | `set flood.channel.block.hops 3` | -| `flood.filter` | Persistent repeater-only forwarding rules for flood routes `0x00`/`0x01`, selected by payload type and received hop count/range. Direct routing and local receive/logging are unchanged. | `get flood.filter[.n]`, `set flood.filter[.n] `, `del flood.filter.|all` | `set flood.filter grp_data 4+` | +| `flood.channel.scope` | Assigns a transport-region scope to received unscoped floods. Exact channel keys beat `txt:*`; `login:*` covers the remote-login family, and `other:*` covers every remaining flood type except TRACE, including OTA. TRACE remains unchanged across scope boundaries. ACL permission `4` can manage the table. | `get flood.channel.scope[.n]`, `set flood.channel.scope[.n] `, `del flood.channel.scope.|all` | `set flood.channel.scope login:* west` | +| `flood.filter` | Persistent repeater-only forwarding rules for flood routes `0x00`/`0x01`, selected by payload type and optional received hop count/range (omitted means `all`). The table starts empty; only rows marked `suspend=tempradio` are skipped during temporary-radio operation. Login-capable `anon_req`/`path`/`response` types become filterable at hop `7`; flood `txt_msg` becomes filterable at hop `5`. Standard direct traceroute, other direct routing, and local receive/logging are unchanged. | `get flood.filter[.n]`, `set flood.filter[.n] [N|N+|N-M|all] [suspend=tempradio]`, `del flood.filter.|all` | `set flood.filter grp_data 4+` | | `flood.moderation` | Decrypts keyed `GRP_TXT` channels and applies drop, per-username messages/minute, and maximum-hop controls, optionally matched against the first 1-3 path hashes. Supports `public`, `#channel`, and 128/256-bit channel keys. Sender names and truncated path hashes are moderation hints, not authenticated identities. | `get flood.moderation[.n]`, `set flood.moderation[.n] [path=...]`, `del flood.moderation.|all` | `set flood.moderation public "Noisy User" rate=5/min hops=4` | | `clock.sync.mesh` | After 30 minutes of uptime, estimates UTC from a configurable consensus of fresh signed-advert or valid Public-channel sources. Only timestamps from firmware build time through build time plus ten years are recorded. Successful CLI, GPS, or WiFi/NTP clock updates suppress LoRa time collection until reboot; after reboot LoRa is the fallback if NTP cannot sync. | `get clock.sync.mesh`, `set clock.sync.mesh `, `get clock.sync.status` | `set clock.sync.mesh on` | | `clock.sync.internet` | Adds a read-only internet/NTP estimate at the same delayed check on WiFi MQTT repeater-observer builds. Other builds retain the setting but report internet unavailable. | `get clock.sync.internet`, `set clock.sync.internet ` | `set clock.sync.internet on` | diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 2c2e5c4b..9a6403f9 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -87,8 +87,12 @@ #define FLOOD_CHANNEL_BLOCK_FILE "/flood_ch_block" #define FLOOD_PACKET_FILTER_FILE "/flood_filter" +#define FLOOD_CHANNEL_SCOPE_FILE "/flood_ch_scope" +#define FLOOD_CHANNEL_SCOPE_TEMP_FILE "/flood_ch_scope.tmp" #define FLOOD_GROUP_MODERATION_FILE "/flood_grp_mod" #define CLOCK_SYNC_PREFS_FILE "/clock_sync" +#define FLOOD_PACKET_FILTER_LOGIN_PROTECTED_HOPS 7 +#define FLOOD_PACKET_FILTER_TXT_MSG_PROTECTED_HOPS 5 #define DEFAULT_FLOOD_CHANNEL_BLOCK_NAME "#wardriving" #ifndef DEFAULT_FLOOD_CHANNEL_BLOCK_HOPS #define DEFAULT_FLOOD_CHANNEL_BLOCK_HOPS 4 @@ -835,8 +839,10 @@ bool MyMesh::shouldBlockFloodChannelForward(const mesh::Packet* packet) const { bool MyMesh::allowPacketForward(const mesh::Packet *packet) { if (_prefs.disable_fwd) return false; if (packet->isRouteFlood()) { + const bool trace = packet->getPayloadType() == PAYLOAD_TYPE_TRACE; if (packet->getPathHashCount() >= _prefs.flood_max) return false; - if (packet->getRouteType() == ROUTE_TYPE_FLOOD && packet->getPathHashCount() >= _prefs.flood_max_unscoped) return false; + if (!trace && packet->getRouteType() == ROUTE_TYPE_FLOOD + && packet->getPathHashCount() >= _prefs.flood_max_unscoped) return false; if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && packet->getPathHashCount() >= _prefs.flood_max_advert) return false; if (!_prefs.flood_channel_data_enabled && packet->getPayloadType() == PAYLOAD_TYPE_GRP_DATA @@ -848,7 +854,8 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { if (shouldBlockFloodPacketForward(packet)) return false; if (shouldBlockFloodChannelForward(packet)) return false; } - if (packet->isRouteFlood() && recv_pkt_region == NULL) { + if (packet->isRouteFlood() && packet->getPayloadType() != PAYLOAD_TYPE_TRACE + && recv_pkt_region == NULL) { MESH_DEBUG_PRINTLN("allowPacketForward: unknown transport code, or wildcard not allowed for FLOOD packet"); return false; } @@ -2096,6 +2103,9 @@ void MyMesh::expireRecentRepeatersIfDue() { } mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { + if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { + applyFloodChannelScope(pkt); + } if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { @@ -2388,6 +2398,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc , bridge(&_prefs, _mgr, &rtc) #endif { + static_cast(_mgr)->setFloodScopePreference( + scoreFloodTransportScope, this); last_millis = 0; uptime_millis = 0; next_local_advert = next_flood_advert = 0; @@ -2403,6 +2415,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc active_cr = 0; saved_radio_apply_pending = false; temp_radio_handoff_pending = false; + temp_radio_applied = false; scheduled_temp_radio_started = false; next_scheduled_radio_time = 0; next_scheduled_radio_check_at = 0; @@ -2419,6 +2432,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc recv_pkt_region = NULL; memset(flood_channel_blocks, 0, sizeof(flood_channel_blocks)); memset(flood_packet_filters, 0, sizeof(flood_packet_filters)); + memset(flood_channel_scopes, 0, sizeof(flood_channel_scopes)); memset(flood_group_moderation, 0, sizeof(flood_group_moderation)); memset(clock_sync_samples, 0, sizeof(clock_sync_samples)); clock_sync_mesh_enabled = false; @@ -2547,6 +2561,7 @@ void MyMesh::begin(FILESYSTEM *fs) { region_map.load(_fs); loadFloodChannelBlocks(); loadFloodPacketFilters(); + loadFloodChannelScopes(); loadFloodGroupModeration(); loadClockSyncPrefs(); @@ -2680,6 +2695,21 @@ bool MyMesh::resolveAlertScope(TransportKey& dest) { return false; } +uint8_t MyMesh::getRegionDepth(const RegionEntry* region) { + if (region == NULL || region->isWildcard()) return 0; + uint8_t depth = 1; + uint16_t parent_id = region->parent; + const int region_count = region_map.getCount(); + for (int hops = 0; parent_id != 0; hops++) { + if (hops >= region_count) return 0; + const RegionEntry* parent = region_map.findById(parent_id); + if (parent == NULL || parent->isWildcard()) return 0; + depth++; + parent_id = parent->parent; + } + return depth; +} + const RegionEntry* MyMesh::findNarrowestBatteryAlertRegion(bool& ambiguous) { ambiguous = false; const RegionEntry* narrowest = NULL; @@ -2688,27 +2718,10 @@ const RegionEntry* MyMesh::findNarrowestBatteryAlertRegion(bool& ambiguous) { for (int i = 0; i < region_count; i++) { const RegionEntry* candidate = region_map.getByIdx(i); - uint8_t depth = 1; - uint16_t parent_id = candidate->parent; - bool valid = true; - - // Region files are validated when loaded, but the live map can be edited - // before it is saved. Bound the walk so a temporary cycle cannot hang the - // repeater while selecting the default battery-alert scope. - for (int hops = 0; parent_id != 0; hops++) { - if (hops >= region_count) { - valid = false; - break; - } - const RegionEntry* parent = region_map.findById(parent_id); - if (parent == NULL || parent->isWildcard()) { - valid = false; - break; - } - depth++; - parent_id = parent->parent; - } - if (!valid) continue; + // Bound the shared hierarchy walk so an unsaved temporary cycle cannot + // hang either alert selection or rxdelay scope arbitration. + uint8_t depth = getRegionDepth(candidate); + if (depth == 0) continue; // A named region is not necessarily a usable transport scope. In // particular, private regions need a stored key; do not select one merely @@ -2998,14 +3011,9 @@ bool MyMesh::hasStartedScheduledTempRadio() const { return scheduled_temp_radio_started; } -#if defined(ENABLE_OTA) bool MyMesh::isTempRadioActive() const { - return scheduled_temp_radio_started - && (!scheduled_temp_radio_end_check_final - || scheduled_temp_radio_end_check_at == 0 - || !millisHasNowPassed(scheduled_temp_radio_end_check_at)); + return temp_radio_applied; } -#endif int MyMesh::findFreeScheduledRadioSlot() const { for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { @@ -3409,6 +3417,7 @@ void MyMesh::processScheduledRadioSettings() { } } else if (applyRadioParams(setting.freq, setting.bw, setting.sf, setting.cr)) { setting.started = true; + temp_radio_applied = true; temp_radio_handoff_pending = false; } else { // setParams() can fail after changing only part of the modulation @@ -3432,6 +3441,7 @@ void MyMesh::processScheduledRadioSettings() { if (applySavedRadioParams()) { radio_driver.setTxPower(_prefs.tx_power_dbm); saved_radio_apply_pending = false; + temp_radio_applied = false; } else { apply_failed = true; } @@ -3886,6 +3896,20 @@ static bool parseFloodFilterPayloadType(const char* text, uint8_t& type) { type = numeric; return true; } + if (text != NULL && text[0] == '0' && (text[1] == 'x' || text[1] == 'X') && text[2] != 0) { + uint8_t parsed = 0; + for (const char* p = text + 2; *p; p++) { + uint8_t digit; + if (*p >= '0' && *p <= '9') digit = (uint8_t)(*p - '0'); + else if (*p >= 'a' && *p <= 'f') digit = (uint8_t)(*p - 'a' + 10); + else if (*p >= 'A' && *p <= 'F') digit = (uint8_t)(*p - 'A' + 10); + else return false; + if (parsed > (PH_TYPE_MASK - digit) / 16U) return false; + parsed = (uint8_t)(parsed * 16U + digit); + } + type = parsed; + return true; + } if (floodFilterAsciiStartsWith(text, "payload_type_")) text += strlen("payload_type_"); if (floodFilterAsciiEqual(text, "any")) type = FLOOD_PACKET_FILTER_ANY_TYPE; else if (floodFilterAsciiEqual(text, "req")) type = PAYLOAD_TYPE_REQ; @@ -3952,6 +3976,20 @@ static void formatFloodFilterHopSpec(char* dest, size_t dest_len, uint8_t min_ho } } +static uint8_t floodPacketFilterProtectedHops(const mesh::Packet* packet) { + if (packet == NULL) return 0; + switch (packet->getPayloadType()) { + case PAYLOAD_TYPE_RESPONSE: + case PAYLOAD_TYPE_ANON_REQ: + case PAYLOAD_TYPE_PATH: + return FLOOD_PACKET_FILTER_LOGIN_PROTECTED_HOPS; + case PAYLOAD_TYPE_TXT_MSG: + return FLOOD_PACKET_FILTER_TXT_MSG_PROTECTED_HOPS; + default: + return 0; + } +} + void MyMesh::loadFloodPacketFilters() { memset(flood_packet_filters, 0, sizeof(flood_packet_filters)); if (_fs == NULL || !_fs->exists(FLOOD_PACKET_FILTER_FILE)) return; @@ -3963,18 +4001,29 @@ void MyMesh::loadFloodPacketFilters() { memset(loaded, 0, sizeof(loaded)); uint8_t magic[4]; uint8_t count = 0; - bool success = file.read(magic, sizeof(magic)) == sizeof(magic) - && memcmp(magic, "FPF1", sizeof(magic)) == 0 + bool success = file.read(magic, sizeof(magic)) == sizeof(magic); + bool version_1 = success && memcmp(magic, "FPF1", sizeof(magic)) == 0; + bool version_2 = success && memcmp(magic, "FPF2", sizeof(magic)) == 0; + success = (version_1 || version_2) && file.read(&count, sizeof(count)) == sizeof(count) && count <= FLOOD_PACKET_FILTER_SLOTS; for (int i = 0; success && i < count; i++) { uint8_t active = 0; + uint8_t suspend_on_temp_radio = 0; success = file.read(&active, sizeof(active)) == sizeof(active); success = success && file.read(&loaded[i].payload_type, sizeof(loaded[i].payload_type)) == sizeof(loaded[i].payload_type); success = success && file.read(&loaded[i].min_hops, sizeof(loaded[i].min_hops)) == sizeof(loaded[i].min_hops); success = success && file.read(&loaded[i].max_hops, sizeof(loaded[i].max_hops)) == sizeof(loaded[i].max_hops); + if (success && version_2) { + success = file.read(&suspend_on_temp_radio, sizeof(suspend_on_temp_radio)) == sizeof(suspend_on_temp_radio); + } loaded[i].active = active != 0; + loaded[i].suspend_on_temp_radio = suspend_on_temp_radio != 0; + if (success && (active > 1 || suspend_on_temp_radio > 1 + || (loaded[i].suspend_on_temp_radio && !loaded[i].active))) { + success = false; + } if (success && loaded[i].active && !((loaded[i].payload_type <= PH_TYPE_MASK || loaded[i].payload_type == FLOOD_PACKET_FILTER_ANY_TYPE) @@ -3994,17 +4043,19 @@ bool MyMesh::saveFloodPacketFilters() { File file = openFloodChannelBlockWrite(_fs, FLOOD_PACKET_FILTER_FILE); if (!file) return false; - const uint8_t magic[4] = {'F', 'P', 'F', '1'}; + const uint8_t magic[4] = {'F', 'P', 'F', '2'}; uint8_t count = FLOOD_PACKET_FILTER_SLOTS; bool success = file.write(magic, sizeof(magic)) == sizeof(magic) && file.write(&count, sizeof(count)) == sizeof(count); for (int i = 0; success && i < FLOOD_PACKET_FILTER_SLOTS; i++) { const auto& entry = flood_packet_filters[i]; uint8_t active = entry.active ? 1 : 0; + uint8_t suspend_on_temp_radio = entry.suspend_on_temp_radio ? 1 : 0; success = file.write(&active, sizeof(active)) == sizeof(active); success = success && file.write(&entry.payload_type, sizeof(entry.payload_type)) == sizeof(entry.payload_type); success = success && file.write(&entry.min_hops, sizeof(entry.min_hops)) == sizeof(entry.min_hops); success = success && file.write(&entry.max_hops, sizeof(entry.max_hops)) == sizeof(entry.max_hops); + success = success && file.write(&suspend_on_temp_radio, sizeof(suspend_on_temp_radio)) == sizeof(suspend_on_temp_radio); } file.close(); return success; @@ -4014,10 +4065,31 @@ bool MyMesh::shouldBlockFloodPacketForward(const mesh::Packet* packet) const { if (packet == NULL || !packet->isRouteFlood()) return false; uint8_t type = packet->getPayloadType(); uint8_t hops = packet->getPathHashCount(); + + // A transit repeater cannot decrypt enough to distinguish remote-login/CLI + // packets from ordinary peer traffic of the same outer types. + // Keep the short-path management transport reachable despite `flood.filter`; + // the existing repeat, flood.max, region, and loop gates still apply. + uint8_t protected_hops = floodPacketFilterProtectedHops(packet); + if (hops < protected_hops) { + MESH_DEBUG_PRINTLN("allowPacketForward: flood.filter remote-admin protection type=%d hops=%d", + type, hops); + return false; + } + for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { const auto& entry = flood_packet_filters[i]; - if (entry.active - && (entry.payload_type == FLOOD_PACKET_FILTER_ANY_TYPE || entry.payload_type == type) + if (!entry.active) continue; + + // When the temporary radio is active, skip a row explicitly marked with + // `suspend=tempradio`. Other rows remain in force. + if (entry.suspend_on_temp_radio && isTempRadioActive()) continue; + + // TRACE deliberately ignores catch-all rows so traceroute cannot be + // disabled accidentally. An explicit `trace` row remains available. + if (type == PAYLOAD_TYPE_TRACE && entry.payload_type == FLOOD_PACKET_FILTER_ANY_TYPE) continue; + + if ((entry.payload_type == FLOOD_PACKET_FILTER_ANY_TYPE || entry.payload_type == type) && hops >= entry.min_hops && hops <= entry.max_hops) { MESH_DEBUG_PRINTLN("allowPacketForward: flood.filter matched slot=%d type=%d hops=%d range=%d-%d", i + 1, type, hops, entry.min_hops, entry.max_hops); @@ -4035,11 +4107,12 @@ void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_ const auto& entry = flood_packet_filters[index]; char hops[12]; formatFloodFilterHopSpec(hops, sizeof(hops), entry.min_hops, entry.max_hops); + const char* suspension = entry.suspend_on_temp_radio ? " suspend=tempradio" : ""; if (entry.payload_type == FLOOD_PACKET_FILTER_ANY_TYPE) { - snprintf(reply, reply_len, "> %d type=any hops=%s route=flood", index + 1, hops); + snprintf(reply, reply_len, "> %d type=any hops=%s route=flood%s", index + 1, hops, suspension); } else { - snprintf(reply, reply_len, "> %d type=%s(%u) hops=%s route=flood", index + 1, - floodFilterPayloadTypeName(entry.payload_type), (uint32_t)entry.payload_type, hops); + snprintf(reply, reply_len, "> %d type=%s(%u) hops=%s route=flood%s", index + 1, + floodFilterPayloadTypeName(entry.payload_type), (uint32_t)entry.payload_type, hops, suspension); } } @@ -4066,8 +4139,8 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply) const { char hops[12]; char item[40]; formatFloodFilterHopSpec(hops, sizeof(hops), entry.min_hops, entry.max_hops); - snprintf(item, sizeof(item), " %d=%s@%s", i + 1, - floodFilterPayloadTypeName(entry.payload_type), hops); + snprintf(item, sizeof(item), entry.suspend_on_temp_radio ? " %d=%s@%s~tempradio" : " %d=%s@%s", + i + 1, floodFilterPayloadTypeName(entry.payload_type), hops); size_t item_len = strlen(item); if (used + item_len >= 156) { truncated = true; @@ -4105,7 +4178,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply) { } requested_slot = slot - 1; if (*cursor != ' ') { - strcpy(reply, "Err - expected packet type and hops"); + strcpy(reply, "Err - expected packet type"); return; } } @@ -4117,35 +4190,49 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply) { char params[64]; strcpy(params, cursor); - char* type_text = params; - char* separator = strchr(type_text, ' '); - if (separator == NULL) { - strcpy(reply, "Err - use: set flood.filter[.n] "); - return; - } - *separator++ = 0; - while (*separator == ' ') separator++; - char* hops_text = separator; - char* extra = strchr(hops_text, ' '); - if (extra != NULL) { - *extra++ = 0; - while (*extra == ' ') extra++; - if (*extra != 0) { - strcpy(reply, "Err - use one packet type and one hop expression"); + char* tokens[4]; + int token_count = 0; + char* token = params; + while (*token != 0) { + if (token_count >= 4) { + strcpy(reply, "Err - too many flood filter parameters"); return; } + tokens[token_count++] = token; + char* separator = strchr(token, ' '); + if (separator == NULL) break; + *separator++ = 0; + while (*separator == ' ') separator++; + token = separator; + } + if (token_count == 0) { + strcpy(reply, "Err - use: set flood.filter[.n] [hops] [suspend=tempradio]"); + return; } uint8_t payload_type; - uint8_t min_hops; - uint8_t max_hops; - if (!parseFloodFilterPayloadType(type_text, payload_type)) { - strcpy(reply, "Err - packet type must be name, any, or 0-15"); + if (!parseFloodFilterPayloadType(tokens[0], payload_type)) { + strcpy(reply, "Err - packet type must be name, any, 0-15, or 0x00-0x0F"); return; } - if (!parseFloodFilterHopSpec(hops_text, min_hops, max_hops)) { - strcpy(reply, "Err - hops must be N, N+, N-M, or all (0-63)"); - return; + + uint8_t min_hops = 0; + uint8_t max_hops = FLOOD_PACKET_FILTER_MAX_HOPS; + bool hops_set = false; + bool suspend_on_temp_radio = false; + for (int i = 1; i < token_count; i++) { + if (floodFilterAsciiEqual(tokens[i], "suspend=tempradio")) { + if (suspend_on_temp_radio) { + strcpy(reply, "Err - duplicate suspend=tempradio"); + return; + } + suspend_on_temp_radio = true; + } else if (!hops_set && parseFloodFilterHopSpec(tokens[i], min_hops, max_hops)) { + hops_set = true; + } else { + strcpy(reply, "Err - use one hop expression and optional suspend=tempradio"); + return; + } } int slot = requested_slot; @@ -4153,7 +4240,8 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply) { for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { const auto& entry = flood_packet_filters[i]; if (entry.active && entry.payload_type == payload_type - && entry.min_hops == min_hops && entry.max_hops == max_hops) { + && entry.min_hops == min_hops && entry.max_hops == max_hops + && entry.suspend_on_temp_radio == suspend_on_temp_radio) { slot = i; break; } @@ -4178,6 +4266,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply) { entry.payload_type = payload_type; entry.min_hops = min_hops; entry.max_hops = max_hops; + entry.suspend_on_temp_radio = suspend_on_temp_radio; if (!saveFloodPacketFilters()) { entry = previous; strcpy(reply, "Err - unable to save flood filter"); @@ -4225,6 +4314,178 @@ void MyMesh::deleteFloodPacketFilter(const char* args, char* reply) { } } +static bool isExactFloodChannelScopeSelector(uint8_t selector) { + return selector == CIPHER_KEY_SIZE || selector == PUB_KEY_SIZE; +} + +static bool isLoginFloodPayloadType(uint8_t type) { + switch (type) { + case PAYLOAD_TYPE_REQ: + case PAYLOAD_TYPE_RESPONSE: + case PAYLOAD_TYPE_TXT_MSG: + case PAYLOAD_TYPE_ANON_REQ: + case PAYLOAD_TYPE_PATH: + return true; + default: + return false; + } +} + +static uint8_t wildcardFloodChannelScopeSelector(uint8_t type) { + if (type == PAYLOAD_TYPE_GRP_TXT || type == PAYLOAD_TYPE_GRP_DATA) { + return FLOOD_CHANNEL_SCOPE_TXT_ANY; + } + if (isLoginFloodPayloadType(type)) return FLOOD_CHANNEL_SCOPE_LOGIN_ANY; + return FLOOD_CHANNEL_SCOPE_OTHER_ANY; +} + +static const char* wildcardFloodChannelScopeName(uint8_t selector) { + if (selector == FLOOD_CHANNEL_SCOPE_TXT_ANY) return "txt:*"; + if (selector == FLOOD_CHANNEL_SCOPE_LOGIN_ANY) return "login:*"; + if (selector == FLOOD_CHANNEL_SCOPE_OTHER_ANY) return "other:*"; + return NULL; +} + +void MyMesh::loadFloodChannelScopes() { + memset(flood_channel_scopes, 0, sizeof(flood_channel_scopes)); + if (_fs == NULL || !_fs->exists(FLOOD_CHANNEL_SCOPE_FILE)) return; + File file = openFloodChannelBlockRead(_fs, FLOOD_CHANNEL_SCOPE_FILE); + if (!file) return; + + uint8_t magic[4]; + uint8_t count = 0; + bool success = file.read(magic, sizeof(magic)) == sizeof(magic) + && memcmp(magic, "FCS1", sizeof(magic)) == 0 + && file.read(&count, sizeof(count)) == sizeof(count); + uint8_t retained = count < FLOOD_CHANNEL_SCOPE_SLOTS ? count : FLOOD_CHANNEL_SCOPE_SLOTS; + size_t retained_bytes = (size_t)retained * sizeof(FloodChannelScopeEntry); + success = success && file.read((uint8_t*)flood_channel_scopes, retained_bytes) == retained_bytes; + FloodChannelScopeEntry discarded; + for (int i = retained; success && i < count; i++) { + success = file.read((uint8_t*)&discarded, sizeof(discarded)) == sizeof(discarded); + } + for (int i = 0; success && i < retained; i++) { + auto& entry = flood_channel_scopes[i]; + if (entry.region_id == 0) { + memset(&entry, 0, sizeof(entry)); + } else if (entry.selector <= FLOOD_CHANNEL_SCOPE_OTHER_ANY) { + entry.channel_hash = 0; + memset(entry.secret, 0, sizeof(entry.secret)); + } else if (isExactFloodChannelScopeSelector(entry.selector)) { + mesh::Utils::sha256(&entry.channel_hash, sizeof(entry.channel_hash), entry.secret, entry.selector); + if (entry.selector == CIPHER_KEY_SIZE) { + memset(&entry.secret[CIPHER_KEY_SIZE], 0, PUB_KEY_SIZE - CIPHER_KEY_SIZE); + } + } else { + success = false; + } + } + file.close(); + + // A malformed table is inert; corrupt storage must never assign a scope. + if (!success) memset(flood_channel_scopes, 0, sizeof(flood_channel_scopes)); +} + +bool MyMesh::saveFloodChannelScopes(bool empty_table) { + if (_fs == NULL) return false; + File file = openFloodChannelBlockWrite(_fs, FLOOD_CHANNEL_SCOPE_TEMP_FILE); + if (!file) return false; + + const uint8_t magic[4] = {'F', 'C', 'S', '1'}; + uint8_t count = FLOOD_CHANNEL_SCOPE_SLOTS; + bool success = file.write(magic, sizeof(magic)) == sizeof(magic) + && file.write(&count, sizeof(count)) == sizeof(count); + if (success && empty_table) { + FloodChannelScopeEntry empty; + memset(&empty, 0, sizeof(empty)); + for (int i = 0; success && i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) { + success = file.write((const uint8_t*)&empty, sizeof(empty)) == sizeof(empty); + } + } else if (success) { + success = file.write((const uint8_t*)flood_channel_scopes, + sizeof(flood_channel_scopes)) == sizeof(flood_channel_scopes); + } + file.close(); + if (!success || !_fs->rename(FLOOD_CHANNEL_SCOPE_TEMP_FILE, FLOOD_CHANNEL_SCOPE_FILE)) { + _fs->remove(FLOOD_CHANNEL_SCOPE_TEMP_FILE); + return false; + } + return true; +} + +bool MyMesh::applyFloodChannelScopeTarget(mesh::Packet* packet, + const FloodChannelScopeEntry& entry) { + RegionEntry* region = region_map.findById(entry.region_id); + TransportKey scope; + if (region == NULL || region->isWildcard() || (region->flags & REGION_DENY_FLOOD) != 0 + || region_map.getTransportKeysFor(*region, &scope, 1) <= 0 || scope.isNull()) { + return false; + } + + packet->header = (packet->header & (uint8_t)~PH_ROUTE_MASK) | ROUTE_TYPE_TRANSPORT_FLOOD; + packet->transport_codes[0] = scope.calcTransportCode(packet); + packet->transport_codes[1] = 0; + MESH_DEBUG_PRINTLN("force-scoped unscoped flood type=%u to region=%s", + (unsigned int)packet->getPayloadType(), region->name); + return true; +} + +bool MyMesh::applyFloodChannelScope(mesh::Packet* packet) { + if (packet == NULL || packet->getRouteType() != ROUTE_TYPE_FLOOD) return false; + uint8_t type = packet->getPayloadType(); + // TRACE must cross region boundaries unchanged. Normal traceroute is direct, + // but preserve the same rule for any flood-form trace received over the air. + if (type == PAYLOAD_TYPE_TRACE) return false; + bool valid_channel_layout = (type == PAYLOAD_TYPE_GRP_TXT || type == PAYLOAD_TYPE_GRP_DATA) + && packet->payload_len >= PATH_HASH_SIZE + CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE + && ((packet->payload_len - PATH_HASH_SIZE - CIPHER_MAC_SIZE) % CIPHER_BLOCK_SIZE) == 0; + + if (valid_channel_layout) { + uint8_t data[MAX_PACKET_PAYLOAD]; + for (int i = 0; i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) { + const auto& entry = flood_channel_scopes[i]; + if (entry.region_id == 0 || !isExactFloodChannelScopeSelector(entry.selector) + || packet->payload[0] != entry.channel_hash) continue; + if (mesh::Utils::MACThenDecrypt(entry.secret, data, &packet->payload[PATH_HASH_SIZE], + packet->payload_len - PATH_HASH_SIZE) > 0 + && applyFloodChannelScopeTarget(packet, entry)) return true; + } + } + uint8_t wildcard = wildcardFloodChannelScopeSelector(type); + for (int i = 0; i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) { + const auto& entry = flood_channel_scopes[i]; + if (entry.region_id != 0 && entry.selector == wildcard + && applyFloodChannelScopeTarget(packet, entry)) return true; + } + return false; +} + +uint8_t MyMesh::scoreFloodTransportScope(const mesh::Packet* packet, void* context) { + return static_cast(context)->getFloodTransportScopeDepth(packet); +} + +uint8_t MyMesh::getFloodTransportScopeDepth(const mesh::Packet* packet) { + if (packet == NULL || packet->getRouteType() != ROUTE_TYPE_TRANSPORT_FLOOD) return 0; + uint8_t best_depth = 0; + const int region_count = region_map.getCount(); + for (int i = 0; i < region_count; i++) { + const RegionEntry* region = region_map.getByIdx(i); + if ((region->flags & REGION_DENY_FLOOD) != 0) continue; + uint8_t depth = getRegionDepth(region); + if (depth <= best_depth) continue; + + TransportKey keys[4]; + int key_count = region_map.getTransportKeysFor(*region, keys, 4); + for (int key_idx = 0; key_idx < key_count; key_idx++) { + if (keys[key_idx].calcTransportCode(packet) == packet->transport_codes[0]) { + best_depth = depth; + break; + } + } + } + return best_depth; +} + static bool parseFloodModerationUnsigned(const char* text, uint32_t maximum, uint32_t& value) { if (text == NULL || *text == 0) return false; uint32_t parsed = 0; @@ -4292,6 +4553,195 @@ static bool parseFloodModerationChannel(const char* text, uint8_t secret[PUB_KEY return true; } +static bool parseFloodChannelScopeIndex(const char*& cursor, int& index) { + cursor = skipFloodFilterSpaces(cursor); + index = -1; + if (*cursor != '.') return true; + cursor++; + const char* start = cursor; + while (*cursor >= '0' && *cursor <= '9') cursor++; + size_t len = (size_t)(cursor - start); + char text[4]; + if (len == 0 || len >= sizeof(text)) return false; + memcpy(text, start, len); + text[len] = 0; + uint8_t slot = 0; + if (!parseFloodFilterUnsigned(text, FLOOD_CHANNEL_SCOPE_SLOTS, slot) || slot == 0) return false; + if (*cursor != 0 && *cursor != ' ') return false; + cursor = skipFloodFilterSpaces(cursor); + index = slot - 1; + return true; +} + +void MyMesh::formatFloodChannelScopeDetail(int index, char* reply, size_t reply_len) { + if (index < 0 || index >= FLOOD_CHANNEL_SCOPE_SLOTS) { + snprintf(reply, reply_len, "Err - scope slot must be 1-%d", FLOOD_CHANNEL_SCOPE_SLOTS); + return; + } + const auto& entry = flood_channel_scopes[index]; + if (entry.region_id == 0) { + snprintf(reply, reply_len, "> %d empty", index + 1); + return; + } + + char channel[12]; + const char* wildcard = wildcardFloodChannelScopeName(entry.selector); + if (wildcard != NULL) { + strcpy(channel, wildcard); + } else { + uint8_t prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]; + mesh::Utils::sha256(prefix, sizeof(prefix), entry.secret, entry.selector); + mesh::Utils::toHex(channel, prefix, sizeof(prefix)); + } + RegionEntry* region = region_map.findById(entry.region_id); + if (region != NULL) { + snprintf(reply, reply_len, "> %d match=%s scope=%s", index + 1, channel, region->name); + } else { + snprintf(reply, reply_len, "> %d match=%s scope=id:%u?", index + 1, channel, + (unsigned int)entry.region_id); + } +} + +void MyMesh::formatFloodChannelScopes(const char* args, char* reply) { + const char* cursor = args; + int index = -1; + if (!parseFloodChannelScopeIndex(cursor, index)) { + snprintf(reply, 160, "Err - scope slot must be 1-%d", FLOOD_CHANNEL_SCOPE_SLOTS); + return; + } + if (*cursor != 0) { + strcpy(reply, "Err - use get flood.channel.scope[.n]"); + return; + } + if (index >= 0) { + formatFloodChannelScopeDetail(index, reply, 160); + return; + } + + int active = 0; + for (int i = 0; i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) { + if (flood_channel_scopes[i].region_id != 0) active++; + } + snprintf(reply, 160, "> %d/%d active; use get flood.channel.scope.", + active, FLOOD_CHANNEL_SCOPE_SLOTS); +} + +void MyMesh::setFloodChannelScope(const char* args, char* reply) { + const char* cursor = args; + int requested_slot = -1; + if (!parseFloodChannelScopeIndex(cursor, requested_slot)) { + strcpy(reply, "Err - bad scope slot"); + return; + } + + char channel_text[80]; + char region_text[32]; + char extra[2]; + if (takeFloodModerationToken(cursor, channel_text, sizeof(channel_text)) != 1 + || takeFloodModerationToken(cursor, region_text, sizeof(region_text)) != 1 + || takeFloodModerationToken(cursor, extra, sizeof(extra)) != 0) { + strcpy(reply, "Err - use: set flood.channel.scope[.n] "); + return; + } + + RegionEntry* region = region_map.findByNamePrefix(region_text); + TransportKey scope; + if (region == NULL || region->isWildcard() || (region->flags & REGION_DENY_FLOOD) != 0 + || region_map.getTransportKeysFor(*region, &scope, 1) <= 0 || scope.isNull()) { + strcpy(reply, "Err - bad scope region"); + return; + } + + uint8_t secret[PUB_KEY_SIZE]; + memset(secret, 0, sizeof(secret)); + uint8_t selector = FLOOD_CHANNEL_SCOPE_TXT_ANY; + uint8_t key_len = 0; + uint8_t hash_prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]; + memset(hash_prefix, 0, sizeof(hash_prefix)); + if (strcmp(channel_text, "*") == 0 || floodFilterAsciiEqual(channel_text, "txt:*")) { + selector = FLOOD_CHANNEL_SCOPE_TXT_ANY; + } else if (floodFilterAsciiEqual(channel_text, "login:*")) { + selector = FLOOD_CHANNEL_SCOPE_LOGIN_ANY; + } else if (floodFilterAsciiEqual(channel_text, "other:*")) { + selector = FLOOD_CHANNEL_SCOPE_OTHER_ANY; + } else { + char ignored_name[FLOOD_GROUP_MODERATION_NAME_LEN]; + if (!parseFloodModerationChannel(channel_text, secret, key_len, hash_prefix, + ignored_name, sizeof(ignored_name))) { + strcpy(reply, "Err - bad scope matcher"); + return; + } + selector = key_len; + } + + int matching_slot = -1; + int free_slot = -1; + for (int i = 0; i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) { + const auto& entry = flood_channel_scopes[i]; + if (entry.region_id == 0) { + if (free_slot < 0) free_slot = i; + } else if (entry.selector == selector + && (!isExactFloodChannelScopeSelector(selector) + || memcmp(entry.secret, secret, PUB_KEY_SIZE) == 0)) { + matching_slot = i; + break; + } + } + int slot = requested_slot >= 0 ? requested_slot : (matching_slot >= 0 ? matching_slot : free_slot); + if (slot < 0) { + strcpy(reply, "Err - scope table full"); + return; + } + + FloodChannelScopeEntry previous = flood_channel_scopes[slot]; + auto& entry = flood_channel_scopes[slot]; + memset(&entry, 0, sizeof(entry)); + entry.region_id = region->id; + entry.selector = selector; + if (isExactFloodChannelScopeSelector(selector)) { + entry.channel_hash = hash_prefix[0]; + memcpy(entry.secret, secret, sizeof(entry.secret)); + } + if (!saveFloodChannelScopes()) { + entry = previous; + strcpy(reply, "Err - save failed"); + return; + } + formatFloodChannelScopeDetail(slot, reply, 160); +} + +void MyMesh::deleteFloodChannelScope(const char* args, char* reply) { + const char* cursor = skipFloodFilterSpaces(args); + if (floodFilterAsciiEqual(cursor, "all")) { + if (!saveFloodChannelScopes(true)) { + strcpy(reply, "Err - save failed"); + } else { + memset(flood_channel_scopes, 0, sizeof(flood_channel_scopes)); + strcpy(reply, "OK"); + } + return; + } + + int index = -1; + if (!parseFloodChannelScopeIndex(cursor, index)) index = -1; + if (index < 0 || *cursor != 0) { + strcpy(reply, "Err - use: del flood.channel.scope.|all"); + return; + } + if (flood_channel_scopes[index].region_id == 0) { + strcpy(reply, "Err - empty scope slot"); + return; + } + FloodChannelScopeEntry previous = flood_channel_scopes[index]; + memset(&flood_channel_scopes[index], 0, sizeof(flood_channel_scopes[index])); + if (!saveFloodChannelScopes()) { + flood_channel_scopes[index] = previous; + strcpy(reply, "Err - save failed"); + } else { + strcpy(reply, "OK"); + } +} + static bool parseFloodModerationPath(const char* text, uint8_t& hash_size, uint8_t& path_hops, uint8_t path[FLOOD_GROUP_MODERATION_PATH_BYTES_MAX]) { memset(path, 0, FLOOD_GROUP_MODERATION_PATH_BYTES_MAX); @@ -5676,7 +6126,11 @@ static bool isCommonManagerReadOnlyAllowed(const char* cmd) { // Whitelist helpers for delegated manager roles. Admins bypass both lists. static bool isRegionMgrAllowed(const char* cmd) { while (*cmd == ' ') cmd++; - return commandFamilyMatches(cmd, "region") || isCommonManagerReadOnlyAllowed(cmd); + return commandFamilyMatches(cmd, "region") + || commandFamilyMatches(cmd, "get flood.channel.scope") + || commandFamilyMatches(cmd, "set flood.channel.scope") + || commandFamilyMatches(cmd, "del flood.channel.scope") + || isCommonManagerReadOnlyAllowed(cmd); } static bool isFilterMgrAllowed(const char* cmd) { @@ -5783,7 +6237,13 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * } // handle ACL related commands - if (commandFamilyMatches(command, "get flood.filter")) { + if (commandFamilyMatches(command, "get flood.channel.scope")) { + formatFloodChannelScopes(command + strlen("get flood.channel.scope"), reply); + } else if (commandFamilyMatches(command, "set flood.channel.scope")) { + setFloodChannelScope(command + strlen("set flood.channel.scope"), reply); + } else if (commandFamilyMatches(command, "del flood.channel.scope")) { + deleteFloodChannelScope(command + strlen("del flood.channel.scope"), reply); + } else if (commandFamilyMatches(command, "get flood.filter")) { formatFloodPacketFilters(command + strlen("get flood.filter"), reply); } else if (commandFamilyMatches(command, "set flood.filter")) { setFloodPacketFilter(command + strlen("set flood.filter"), reply); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 0631ffe7..7fae78ba 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -122,6 +122,19 @@ struct NeighbourInfo { #define FLOOD_PACKET_FILTER_ANY_TYPE 0xFF #define FLOOD_PACKET_FILTER_MAX_HOPS 63 +#ifndef FLOOD_CHANNEL_SCOPE_SLOTS + #if defined(ESP32) + #define FLOOD_CHANNEL_SCOPE_SLOTS 255 + #elif defined(STM32_PLATFORM) + #define FLOOD_CHANNEL_SCOPE_SLOTS 15 + #else + #define FLOOD_CHANNEL_SCOPE_SLOTS 31 + #endif +#endif +#define FLOOD_CHANNEL_SCOPE_TXT_ANY 0 +#define FLOOD_CHANNEL_SCOPE_LOGIN_ANY 1 +#define FLOOD_CHANNEL_SCOPE_OTHER_ANY 2 + #ifndef FLOOD_GROUP_MODERATION_SLOTS #define FLOOD_GROUP_MODERATION_SLOTS 16 #endif @@ -218,6 +231,14 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t payload_type; uint8_t min_hops; uint8_t max_hops; + bool suspend_on_temp_radio; + }; + struct FloodChannelScopeEntry { + uint16_t region_id; // zero means unused + // 0/1/2 are txt:*/login:*/other:*; 16/32 are exact channel-key lengths. + uint8_t selector; + uint8_t channel_hash; + uint8_t secret[PUB_KEY_SIZE]; }; struct FloodGroupModerationEntry { bool active; @@ -247,6 +268,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { FloodRetryBridgeReachability flood_retry_bridge_reachability[FLOOD_RETRY_BRIDGE_BUCKETS + 1]; FloodChannelBlockEntry flood_channel_blocks[FLOOD_CHANNEL_BLOCK_SLOTS]; FloodPacketFilterEntry flood_packet_filters[FLOOD_PACKET_FILTER_SLOTS]; + FloodChannelScopeEntry flood_channel_scopes[FLOOD_CHANNEL_SCOPE_SLOTS]; FloodGroupModerationEntry flood_group_moderation[FLOOD_GROUP_MODERATION_SLOTS]; ClockSyncSample clock_sync_samples[CLOCK_SYNC_SAMPLE_SLOTS]; bool clock_sync_mesh_enabled; @@ -279,6 +301,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t active_cr; // live CR, including temporary radio overrides bool saved_radio_apply_pending; bool temp_radio_handoff_pending; + bool temp_radio_applied; bool scheduled_temp_radio_started; uint32_t next_scheduled_radio_time; unsigned long next_scheduled_radio_check_at; @@ -353,6 +376,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { mesh::Packet* createSelfAdvert(); bool sendRepeatersFloodText(const char* text, const TransportKey* scope = nullptr, mesh::Packet** queued_packet = nullptr); + uint8_t getRegionDepth(const RegionEntry* region); const RegionEntry* findNarrowestBatteryAlertRegion(bool& ambiguous); bool getBatteryAlertScopeForRegion(const RegionEntry& region, TransportKey& scope); bool resolveBatteryAlertScope(TransportKey& scope); @@ -387,6 +411,16 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len) const; void setFloodPacketFilter(const char* args, char* reply); void deleteFloodPacketFilter(const char* args, char* reply); + void loadFloodChannelScopes(); + bool saveFloodChannelScopes(bool empty_table = false); + bool applyFloodChannelScopeTarget(mesh::Packet* packet, const FloodChannelScopeEntry& entry); + bool applyFloodChannelScope(mesh::Packet* packet); + static uint8_t scoreFloodTransportScope(const mesh::Packet* packet, void* context); + uint8_t getFloodTransportScopeDepth(const mesh::Packet* packet); + void formatFloodChannelScopes(const char* args, char* reply); + void formatFloodChannelScopeDetail(int index, char* reply, size_t reply_len); + void setFloodChannelScope(const char* args, char* reply); + void deleteFloodChannelScope(const char* args, char* reply); void loadFloodGroupModeration(); bool saveFloodGroupModeration(); bool shouldBlockFloodGroupTextForward(const mesh::Packet* packet); @@ -429,9 +463,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void formatScheduledRadioSetting(char* reply, int setting_idx, int display_idx) const; protected: -#if defined(ENABLE_OTA) bool isTempRadioActive() const override; -#endif float getAirtimeBudgetFactor() const override { return _prefs.airtime_factor; } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 09692a62..884c7bdf 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -145,11 +145,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); protected: -#if defined(ENABLE_OTA) bool isTempRadioActive() const override { return temp_radio_applied && revert_radio_at != 0 && !millisHasNowPassed(revert_radio_at); } -#endif float getAirtimeBudgetFactor() const override { return _prefs.airtime_factor; } diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 6c5f9360..778c4206 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -84,11 +84,9 @@ public: float getTelemValue(uint8_t channel, uint8_t type); protected: -#if defined(ENABLE_OTA) bool isTempRadioActive() const override { return temp_radio_applied && revert_radio_at != 0 && !millisHasNowPassed(revert_radio_at); } -#endif // current telemetry data queries float getVoltage(uint8_t channel) { return getTelemValue(channel, LPP_VOLTAGE); } float getCurrent(uint8_t channel) { return getTelemValue(channel, LPP_CURRENT); } diff --git a/src/Mesh.h b/src/Mesh.h index cc1dfbe0..42324fb1 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -380,10 +380,10 @@ protected: */ virtual void onRawDataRecv(Packet* packet) { } -#if defined(ENABLE_OTA) - /** OTA transport is dormant unless the role has actually switched into a temporary-radio window. */ + /** True only while the role has actually switched into a temporary-radio window. */ virtual bool isTempRadioActive() const { return false; } +#if defined(ENABLE_OTA) /** * \brief An OTA-over-LoRa packet (PAYLOAD_TYPE_OTA) has been received. Subclasses forward the * payload bytes to their OtaManager. See docs/ota_protocol.md. diff --git a/src/helpers/RegionMap.cpp b/src/helpers/RegionMap.cpp index 8b4f4e2c..23b11fb6 100644 --- a/src/helpers/RegionMap.cpp +++ b/src/helpers/RegionMap.cpp @@ -272,7 +272,7 @@ int RegionMap::getTransportKeysFor(const RegionEntry& src, TransportKey dest[], return num; } -RegionEntry* RegionMap::findMatch(mesh::Packet* packet, uint8_t mask) { +RegionEntry* RegionMap::findMatch(const mesh::Packet* packet, uint8_t mask) { for (int i = 0; i < num_regions; i++) { auto region = ®ions[i]; if ((region->flags & mask) == 0) { // does region allow this? (per 'mask' param) diff --git a/src/helpers/RegionMap.h b/src/helpers/RegionMap.h index d920091e..de44f8bd 100644 --- a/src/helpers/RegionMap.h +++ b/src/helpers/RegionMap.h @@ -39,7 +39,7 @@ public: bool save(FILESYSTEM* _fs, const char* path=NULL); RegionEntry* putRegion(const char* name, uint16_t parent_id, uint16_t id = 0); - RegionEntry* findMatch(mesh::Packet* packet, uint8_t mask); + RegionEntry* findMatch(const mesh::Packet* packet, uint8_t mask); RegionEntry& getWildcard() { return wildcard; } RegionEntry* findByName(const char* name); RegionEntry* findByNamePrefix(const char* prefix); diff --git a/src/helpers/StaticPoolPacketManager.cpp b/src/helpers/StaticPoolPacketManager.cpp index a896eb77..3d854464 100644 --- a/src/helpers/StaticPoolPacketManager.cpp +++ b/src/helpers/StaticPoolPacketManager.cpp @@ -39,7 +39,9 @@ void PacketQueue::rebuildNextTime() { } } -mesh::Packet* PacketQueue::get(uint32_t now) { +mesh::Packet* PacketQueue::get(uint32_t now, bool resolve_flood_scope, + FloodScopePreference scope_preference, + void* scope_preference_context) { uint8_t min_pri = 0xFF; int best_idx = -1; for (int j = 0; j < _num; j++) { @@ -51,6 +53,10 @@ mesh::Packet* PacketQueue::get(uint32_t now) { } if (best_idx < 0) return NULL; // empty, or all items are still in the future + if (resolve_flood_scope) { + applyBestFloodTransportScope(_table[best_idx], scope_preference, + scope_preference_context); + } return removeByIdx(best_idx); } @@ -97,7 +103,52 @@ bool PacketQueue::add(mesh::Packet* packet, uint8_t priority, uint32_t scheduled return true; } -StaticPoolPacketManager::StaticPoolPacketManager(int pool_size): unused(pool_size), send_queue(pool_size), rx_queue(pool_size) { +void PacketQueue::applyBestFloodTransportScope(mesh::Packet* packet, + FloodScopePreference scope_preference, + void* scope_preference_context) const { + // Scope validity is application-specific. Without a validator there is no + // safe way to distinguish a locally-allowed scope from arbitrary codes. + if (packet == NULL || !packet->isRouteFlood() + || packet->getPayloadType() == PAYLOAD_TYPE_TRACE + || scope_preference == NULL) return; + + uint8_t packet_hash[MAX_HASH_SIZE]; + packet->calculatePacketHash(packet_hash); + + const mesh::Packet* best = NULL; + uint8_t best_hops = 0xFF; + uint8_t best_preference = 0; + for (int i = 0; i < _num; i++) { + const mesh::Packet* candidate = _table[i]; + if (candidate == NULL || candidate->getRouteType() != ROUTE_TYPE_TRANSPORT_FLOOD) continue; + uint8_t candidate_hash[MAX_HASH_SIZE]; + candidate->calculatePacketHash(candidate_hash); + if (memcmp(packet_hash, candidate_hash, sizeof(packet_hash)) != 0) continue; + + uint8_t hops = candidate->getPathHashCount(); + uint8_t preference = scope_preference(candidate, scope_preference_context); + if (preference == 0) continue; // unknown, denied, or otherwise unusable here + if (best == NULL || hops < best_hops + || (hops == best_hops && preference > best_preference)) { + best = candidate; + best_hops = hops; + best_preference = preference; + } + } + if (best == NULL) return; + + // Keep the receive-quality winner's path, SNR, and schedule, but give it the + // scope carried by the shortest equivalent scoped copy. Equal paths prefer + // the most specific locally-recognized scope supplied by the application. + packet->header = (packet->header & (uint8_t)~PH_ROUTE_MASK) | ROUTE_TYPE_TRANSPORT_FLOOD; + if (packet != best) { + memcpy(packet->transport_codes, best->transport_codes, sizeof(packet->transport_codes)); + } +} + +StaticPoolPacketManager::StaticPoolPacketManager(int pool_size) + : unused(pool_size), send_queue(pool_size), rx_queue(pool_size), + flood_scope_preference(NULL), flood_scope_preference_context(NULL) { // load up our unusued Packet pool for (int i = 0; i < pool_size; i++) { unused.add(new mesh::Packet(), 0, 0); @@ -159,7 +210,8 @@ void StaticPoolPacketManager::queueInbound(mesh::Packet* packet, uint32_t schedu } } mesh::Packet* StaticPoolPacketManager::getNextInbound(uint32_t now) { - return rx_queue.get(now); + return rx_queue.get(now, true, flood_scope_preference, + flood_scope_preference_context); } bool StaticPoolPacketManager::getNextInboundTime(uint32_t now, uint32_t& scheduled_for) const { diff --git a/src/helpers/StaticPoolPacketManager.h b/src/helpers/StaticPoolPacketManager.h index f0c390e3..41c2bcd9 100644 --- a/src/helpers/StaticPoolPacketManager.h +++ b/src/helpers/StaticPoolPacketManager.h @@ -2,6 +2,10 @@ #include +// Zero rejects a candidate as unknown/denied; positive values rank eligible +// scopes, with larger values preferred when received path lengths are equal. +typedef uint8_t (*FloodScopePreference)(const mesh::Packet* packet, void* context); + class PacketQueue { mesh::Packet** _table; uint8_t* _pri_table; @@ -10,10 +14,15 @@ class PacketQueue { uint32_t _next_schedule; void rebuildNextTime(); + void applyBestFloodTransportScope(mesh::Packet* packet, + FloodScopePreference scope_preference, + void* scope_preference_context) const; public: PacketQueue(int max_entries); - mesh::Packet* get(uint32_t now); + mesh::Packet* get(uint32_t now, bool resolve_flood_scope = false, + FloodScopePreference scope_preference = NULL, + void* scope_preference_context = NULL); mesh::Packet* peek(uint32_t now) const; bool add(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for); int count() const { return _num; } @@ -25,10 +34,17 @@ public: class StaticPoolPacketManager : public mesh::PacketManager { PacketQueue unused, send_queue, rx_queue; + FloodScopePreference flood_scope_preference; + void* flood_scope_preference_context; public: StaticPoolPacketManager(int pool_size); + void setFloodScopePreference(FloodScopePreference preference, void* context) { + flood_scope_preference = preference; + flood_scope_preference_context = context; + } + mesh::Packet* allocNew() override; void free(mesh::Packet* packet) override; bool queueOutbound(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for) override; diff --git a/test/test_mesh_tables/test_simple_mesh_tables.cpp b/test/test_mesh_tables/test_simple_mesh_tables.cpp index 2cb5a405..aff2fcd9 100644 --- a/test/test_mesh_tables/test_simple_mesh_tables.cpp +++ b/test/test_mesh_tables/test_simple_mesh_tables.cpp @@ -78,6 +78,18 @@ TEST(SimpleMeshTables, MarkSeen_DoesNotAffectOtherPackets) { EXPECT_FALSE(t.wasSeen(&p2)); } +TEST(SimpleMeshTables, TransportScopeDoesNotChangeDuplicateIdentity) { + SimpleMeshTables t; + Packet unscoped = makeFloodPacket(0x35); + Packet scoped = unscoped; + scoped.header = (scoped.header & (uint8_t)~PH_ROUTE_MASK) | ROUTE_TYPE_TRANSPORT_FLOOD; + scoped.transport_codes[0] = 0x1234; + scoped.transport_codes[1] = 0x5678; + + t.markSeen(&unscoped); + EXPECT_TRUE(t.wasSeen(&scoped)); +} + // Canonical pattern used at every onRecvPacket call site: // if (!wasSeen(pkt)) { markSeen(pkt); process(pkt); } TEST(SimpleMeshTables, QueryThenMark_WorksCorrectly) { diff --git a/test/test_packet_manager/test_rx_reserve_packet_manager.cpp b/test/test_packet_manager/test_rx_reserve_packet_manager.cpp index 8595ada0..bf6b653f 100644 --- a/test/test_packet_manager/test_rx_reserve_packet_manager.cpp +++ b/test/test_packet_manager/test_rx_reserve_packet_manager.cpp @@ -1,5 +1,4 @@ #include - #include class TestClock : public mesh::MillisecondClock { @@ -162,6 +161,197 @@ TEST(StaticPoolPacketManager, EarliestQueueTimeIsCorrectAcrossMillisRollover) { manager.free(after_wrap); } +static void setFloodIdentity(mesh::Packet* packet, uint8_t route, uint8_t seed) { + packet->header = route | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT); + packet->path_len = 0; + packet->payload[0] = seed; + packet->payload_len = 1; +} + +static uint8_t acceptFloodScope(const mesh::Packet*, void*) { + return 1; +} + +TEST(StaticPoolPacketManager, ScopedRxDelayCopyRequiresLocalValidator) { + StaticPoolPacketManager manager(4); + mesh::Packet* unscoped = manager.allocNew(); + mesh::Packet* scoped = manager.allocNew(); + ASSERT_NE(unscoped, nullptr); + ASSERT_NE(scoped, nullptr); + setFloodIdentity(unscoped, ROUTE_TYPE_FLOOD, 0x41); + setFloodIdentity(scoped, ROUTE_TYPE_TRANSPORT_FLOOD, 0x41); + scoped->transport_codes[0] = 0x1234; + + manager.queueInbound(unscoped, 100); + manager.queueInbound(scoped, 200); + + EXPECT_EQ(unscoped, manager.getNextInbound(100)); + EXPECT_EQ(ROUTE_TYPE_FLOOD, unscoped->getRouteType()); + manager.free(unscoped); + manager.free(manager.getNextInbound(200)); +} + +TEST(StaticPoolPacketManager, ScopedRxDelayCopyUpgradesQueuedUnscopedCopy) { + StaticPoolPacketManager manager(4); + manager.setFloodScopePreference(acceptFloodScope, NULL); + mesh::Packet* unscoped = manager.allocNew(); + mesh::Packet* scoped = manager.allocNew(); + ASSERT_NE(unscoped, nullptr); + ASSERT_NE(scoped, nullptr); + setFloodIdentity(unscoped, ROUTE_TYPE_FLOOD, 0x42); + setFloodIdentity(scoped, ROUTE_TYPE_TRANSPORT_FLOOD, 0x42); + scoped->transport_codes[0] = 0x1234; + scoped->transport_codes[1] = 0x5678; + + manager.queueInbound(unscoped, 100); + manager.queueInbound(scoped, 200); + + EXPECT_EQ(ROUTE_TYPE_FLOOD, unscoped->getRouteType()); + EXPECT_EQ(unscoped, manager.getNextInbound(100)); + EXPECT_EQ(ROUTE_TYPE_TRANSPORT_FLOOD, unscoped->getRouteType()); + EXPECT_EQ(0x1234, unscoped->transport_codes[0]); + EXPECT_EQ(0x5678, unscoped->transport_codes[1]); + manager.free(unscoped); + manager.free(manager.getNextInbound(200)); +} + +TEST(StaticPoolPacketManager, QueuedScopedCopyUpgradesNewEarlierUnscopedCopy) { + StaticPoolPacketManager manager(4); + manager.setFloodScopePreference(acceptFloodScope, NULL); + mesh::Packet* scoped = manager.allocNew(); + mesh::Packet* unscoped = manager.allocNew(); + ASSERT_NE(scoped, nullptr); + ASSERT_NE(unscoped, nullptr); + setFloodIdentity(scoped, ROUTE_TYPE_TRANSPORT_FLOOD, 0x24); + setFloodIdentity(unscoped, ROUTE_TYPE_FLOOD, 0x24); + scoped->transport_codes[0] = 0xABCD; + scoped->transport_codes[1] = 0; + + manager.queueInbound(scoped, 200); + manager.queueInbound(unscoped, 100); + + EXPECT_EQ(ROUTE_TYPE_FLOOD, unscoped->getRouteType()); + EXPECT_EQ(unscoped, manager.getNextInbound(100)); + EXPECT_EQ(ROUTE_TYPE_TRANSPORT_FLOOD, unscoped->getRouteType()); + EXPECT_EQ(0xABCD, unscoped->transport_codes[0]); + manager.free(unscoped); + manager.free(manager.getNextInbound(200)); +} + +TEST(StaticPoolPacketManager, RxDelayScopeDoesNotCrossPacketIdentity) { + StaticPoolPacketManager manager(4); + mesh::Packet* scoped = manager.allocNew(); + mesh::Packet* different = manager.allocNew(); + ASSERT_NE(scoped, nullptr); + ASSERT_NE(different, nullptr); + setFloodIdentity(scoped, ROUTE_TYPE_TRANSPORT_FLOOD, 0x11); + setFloodIdentity(different, ROUTE_TYPE_FLOOD, 0x12); + scoped->transport_codes[0] = 0xCAFE; + + manager.queueInbound(scoped, 200); + manager.queueInbound(different, 100); + + EXPECT_EQ(ROUTE_TYPE_FLOOD, different->getRouteType()); + EXPECT_EQ(different, manager.getNextInbound(100)); + manager.free(different); + manager.free(manager.getNextInbound(200)); +} + +TEST(StaticPoolPacketManager, RxDelayNeverAddsScopeToFloodTrace) { + StaticPoolPacketManager manager(4); + manager.setFloodScopePreference(acceptFloodScope, NULL); + mesh::Packet* unscoped = manager.allocNew(); + mesh::Packet* scoped = manager.allocNew(); + ASSERT_NE(unscoped, nullptr); + ASSERT_NE(scoped, nullptr); + setFloodIdentity(unscoped, ROUTE_TYPE_FLOOD, 0x13); + setFloodIdentity(scoped, ROUTE_TYPE_TRANSPORT_FLOOD, 0x13); + unscoped->header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_TRACE << PH_TYPE_SHIFT); + scoped->header = ROUTE_TYPE_TRANSPORT_FLOOD | (PAYLOAD_TYPE_TRACE << PH_TYPE_SHIFT); + scoped->transport_codes[0] = 0xCAFE; + + manager.queueInbound(unscoped, 100); + manager.queueInbound(scoped, 200); + + EXPECT_EQ(unscoped, manager.getNextInbound(100)); + EXPECT_EQ(ROUTE_TYPE_FLOOD, unscoped->getRouteType()); + manager.free(unscoped); + manager.free(manager.getNextInbound(200)); +} + +static uint8_t testFloodScopePreference(const mesh::Packet* packet, void*) { + return (uint8_t)(packet->transport_codes[0] & 0xFF); +} + +TEST(StaticPoolPacketManager, RxDelayRejectsUnknownShorterScope) { + StaticPoolPacketManager manager(4); + manager.setFloodScopePreference(testFloodScopePreference, NULL); + mesh::Packet* known = manager.allocNew(); + mesh::Packet* unknown = manager.allocNew(); + ASSERT_NE(known, nullptr); + ASSERT_NE(unknown, nullptr); + setFloodIdentity(known, ROUTE_TYPE_TRANSPORT_FLOOD, 0x30); + setFloodIdentity(unknown, ROUTE_TYPE_TRANSPORT_FLOOD, 0x30); + known->setPathHashSizeAndCount(1, 4); + unknown->setPathHashSizeAndCount(1, 1); + known->transport_codes[0] = 3; + unknown->transport_codes[0] = 0; // rejected by the local validator + + manager.queueInbound(known, 100); + manager.queueInbound(unknown, 200); + + EXPECT_EQ(known, manager.getNextInbound(100)); + EXPECT_EQ(3, known->transport_codes[0]); + manager.free(known); + manager.free(manager.getNextInbound(200)); +} + +TEST(StaticPoolPacketManager, RxDelayShorterScopedPathBeatsNarrowerScope) { + StaticPoolPacketManager manager(4); + manager.setFloodScopePreference(testFloodScopePreference, NULL); + mesh::Packet* narrower = manager.allocNew(); + mesh::Packet* shorter = manager.allocNew(); + ASSERT_NE(narrower, nullptr); + ASSERT_NE(shorter, nullptr); + setFloodIdentity(narrower, ROUTE_TYPE_TRANSPORT_FLOOD, 0x31); + setFloodIdentity(shorter, ROUTE_TYPE_TRANSPORT_FLOOD, 0x31); + narrower->setPathHashSizeAndCount(1, 4); + shorter->setPathHashSizeAndCount(1, 2); + narrower->transport_codes[0] = 9; // higher local preference + shorter->transport_codes[0] = 1; + + manager.queueInbound(narrower, 100); + manager.queueInbound(shorter, 200); + + EXPECT_EQ(narrower, manager.getNextInbound(100)); + EXPECT_EQ(1, narrower->transport_codes[0]); + manager.free(narrower); + manager.free(manager.getNextInbound(200)); +} + +TEST(StaticPoolPacketManager, RxDelayEqualPathsPreferNarrowerLocalScope) { + StaticPoolPacketManager manager(4); + manager.setFloodScopePreference(testFloodScopePreference, NULL); + mesh::Packet* shallow = manager.allocNew(); + mesh::Packet* narrower = manager.allocNew(); + ASSERT_NE(shallow, nullptr); + ASSERT_NE(narrower, nullptr); + setFloodIdentity(shallow, ROUTE_TYPE_TRANSPORT_FLOOD, 0x32); + setFloodIdentity(narrower, ROUTE_TYPE_TRANSPORT_FLOOD, 0x32); + shallow->setPathHashSizeAndCount(1, 3); + narrower->setPathHashSizeAndCount(1, 3); + shallow->transport_codes[0] = 1; + narrower->transport_codes[0] = 9; // higher local preference + + manager.queueInbound(shallow, 100); + manager.queueInbound(narrower, 200); + + EXPECT_EQ(shallow, manager.getNextInbound(100)); + EXPECT_EQ(9, shallow->transport_codes[0]); + manager.free(shallow); + manager.free(manager.getNextInbound(200)); +} + TEST(Dispatcher, QueueWakeDelayIncludesSchedulesAndChannelBackoff) { RxReservePacketManager manager(8, 4); TestClock clock; diff --git a/variants/generic-e22/platformio.ini b/variants/generic-e22/platformio.ini index 53eb886b..9d7a3fe5 100644 --- a/variants/generic-e22/platformio.ini +++ b/variants/generic-e22/platformio.ini @@ -41,6 +41,7 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 + -D FLOOD_CHANNEL_SCOPE_SLOTS=31 ; classic ESP32 DRAM cannot fit the default 255 with LoRa OTA ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 lib_deps = @@ -120,6 +121,7 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 + -D FLOOD_CHANNEL_SCOPE_SLOTS=31 ; classic ESP32 DRAM cannot fit the default 255 with LoRa OTA ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 lib_deps = diff --git a/variants/heltec_v2/platformio.ini b/variants/heltec_v2/platformio.ini index 8bbab0d8..5fff6ea0 100644 --- a/variants/heltec_v2/platformio.ini +++ b/variants/heltec_v2/platformio.ini @@ -37,6 +37,7 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 + -D FLOOD_CHANNEL_SCOPE_SLOTS=31 ; classic ESP32 DRAM cannot fit the default 255 with LoRa OTA ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Heltec_lora32_v2.build_src_filter} diff --git a/variants/lilygo_tbeam_SX1262/platformio.ini b/variants/lilygo_tbeam_SX1262/platformio.ini index 37327ab4..d79e3038 100644 --- a/variants/lilygo_tbeam_SX1262/platformio.ini +++ b/variants/lilygo_tbeam_SX1262/platformio.ini @@ -80,6 +80,7 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 + -D FLOOD_CHANNEL_SCOPE_SLOTS=31 ; classic ESP32 DRAM cannot fit the default 255 with LoRa OTA ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${LilyGo_TBeam_SX1262.build_src_filter} @@ -168,6 +169,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 + -D FLOOD_CHANNEL_SCOPE_SLOTS=31 ; retain scope support within this observer's tight internal DRAM -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 diff --git a/variants/lilygo_tbeam_SX1276/platformio.ini b/variants/lilygo_tbeam_SX1276/platformio.ini index 0d945eb7..8756f8fe 100644 --- a/variants/lilygo_tbeam_SX1276/platformio.ini +++ b/variants/lilygo_tbeam_SX1276/platformio.ini @@ -74,6 +74,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D PERSISTANT_GPS=1 + -D FLOOD_CHANNEL_SCOPE_SLOTS=31 ; classic ESP32 DRAM cannot fit the default 255 with LoRa OTA ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${LilyGo_TBeam_SX1276.build_src_filter} @@ -164,6 +165,7 @@ build_flags = -D MAX_NEIGHBOURS=50 -D PERSISTANT_GPS=1 -D WITH_MQTT_BRIDGE=1 + -D FLOOD_CHANNEL_SCOPE_SLOTS=31 ; retain scope support within this observer's tight internal DRAM -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 diff --git a/variants/lilygo_tlora_v2_1/platformio.ini b/variants/lilygo_tlora_v2_1/platformio.ini index 78443265..6b2ea21d 100644 --- a/variants/lilygo_tlora_v2_1/platformio.ini +++ b/variants/lilygo_tlora_v2_1/platformio.ini @@ -61,6 +61,7 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 + -D FLOOD_CHANNEL_SCOPE_SLOTS=4 ; ultra-tight classic ESP32: three wildcards plus one exact channel ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 ; -D CORE_DEBUG_LEVEL=3 @@ -162,6 +163,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 + -D FLOOD_CHANNEL_SCOPE_SLOTS=4 ; no-PSRAM observer must retain internal DRAM for MQTT/TLS -D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"' -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 diff --git a/variants/meshadventurer/platformio.ini b/variants/meshadventurer/platformio.ini index 659cee75..4f5b2256 100644 --- a/variants/meshadventurer/platformio.ini +++ b/variants/meshadventurer/platformio.ini @@ -50,6 +50,7 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 + -D FLOOD_CHANNEL_SCOPE_SLOTS=31 ; classic ESP32 DRAM cannot fit the default 255 with LoRa OTA ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 lib_deps = @@ -119,6 +120,7 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 + -D FLOOD_CHANNEL_SCOPE_SLOTS=31 ; classic ESP32 DRAM cannot fit the default 255 with LoRa OTA ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 lib_deps = diff --git a/variants/rak3x72/platformio.ini b/variants/rak3x72/platformio.ini index bbbeec9c..91406437 100644 --- a/variants/rak3x72/platformio.ini +++ b/variants/rak3x72/platformio.ini @@ -19,6 +19,9 @@ extends = rak3x72 board_upload.maximum_size = 245760 ; 240 KiB app, 16 KiB LittleFS build_flags = ${rak3x72.build_flags} -flto + -fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary + -fipa-pta + -fno-semantic-interposition -D LFS_FLASH_TOTAL_SIZE=16384 -D ADVERT_NAME='"RAK3x72 Repeater"' -D ADMIN_PASSWORD='"password"' diff --git a/variants/tiny_relay/platformio.ini b/variants/tiny_relay/platformio.ini index d4ed4da4..c8f11ca7 100644 --- a/variants/tiny_relay/platformio.ini +++ b/variants/tiny_relay/platformio.ini @@ -20,6 +20,9 @@ extends = Tiny_Relay board_upload.maximum_size = 245760 ; 240 KiB app, 16 KiB LittleFS build_flags = ${Tiny_Relay.build_flags} -flto ; retain headroom for current repeater safety features + -fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary + -fipa-pta + -fno-semantic-interposition -D LFS_FLASH_TOTAL_SIZE=16384 -D ADVERT_NAME='"tiny_relay Repeater"' -D ADVERT_LAT=0.0 diff --git a/variants/wio-e5-dev/platformio.ini b/variants/wio-e5-dev/platformio.ini index 8af9651b..2d0ac15c 100644 --- a/variants/wio-e5-dev/platformio.ini +++ b/variants/wio-e5-dev/platformio.ini @@ -20,6 +20,9 @@ extends = lora_e5 board_upload.maximum_size = 245760 ; 240 KiB app, 16 KiB LittleFS build_flags = ${lora_e5.build_flags} -flto + -fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary + -fipa-pta + -fno-semantic-interposition -D LFS_FLASH_TOTAL_SIZE=16384 -D LORA_TX_POWER=22 -D ADVERT_NAME='"WIO-E5 Repeater"' @@ -33,6 +36,9 @@ extends = lora_e5 board_upload.maximum_size = 245760 ; 240 KiB app, 16 KiB LittleFS build_flags = ${lora_e5.build_flags} -flto + -fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary + -fipa-pta + -fno-semantic-interposition -D LFS_FLASH_TOTAL_SIZE=16384 -D MESH_ENABLE_RECENT_REPEATERS=1 -D LORA_TX_POWER=22 diff --git a/variants/wio-e5-mini/platformio.ini b/variants/wio-e5-mini/platformio.ini index 61ec2a13..84c38d93 100644 --- a/variants/wio-e5-mini/platformio.ini +++ b/variants/wio-e5-mini/platformio.ini @@ -22,6 +22,9 @@ extends = lora_e5_mini board_upload.maximum_size = 245760 ; 240 KiB app, 16 KiB LittleFS build_flags = ${lora_e5_mini.build_flags} -flto + -fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary + -fipa-pta + -fno-semantic-interposition -D LFS_FLASH_TOTAL_SIZE=16384 -D MESH_ENABLE_RECENT_REPEATERS=1 -D LORA_TX_POWER=22