Extend flood policy and harden message retries

This commit is contained in:
mikecarper committed 2026-08-07 14:26:31 -07:00
1 parent 687e114828
commit 7f56da7a34
34 files changed
+5297 -1338

No files matched your search

+1 -1
View File
@@ -1907,7 +1907,7 @@ apply_esp32_full_size_profile() {
# The FULL artifact uses expanded dual-OTA slots, so restore features that
# target or portable profiles disabled only to save application space.
append_platformio_build_unflags "-DWEBCONFIG_DISABLED=1"
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UWEBCONFIG_DISABLED -DWIFI_OTA_SEEDER=1"
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UWEBCONFIG_DISABLED -DWIFI_OTA_SEEDER=1 -DMESHCORE_ESP32_FULL_PROFILE=1"
# Keep ordinary builds at their board-defined neighbor capacity. FULL builds
# normally use the largest table supported by the one-byte neighbor discovery
+2 -4
View File
@@ -117,10 +117,9 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| Routing | [`get/set flood.max.unscoped`](cli_commands.md#limit-the-number-of-hops-for-an-unscoped-flood-message) | Repeater | Yes | Yes | Yes |
| Routing | [`get/set flood.max.advert`](cli_commands.md#limit-the-number-of-hops-for-an-advert-flood-message) | Repeater | Yes | Yes | Yes |
| Routing | [`get/set flood.channel.data`; `get/set flood.channel.data.hops`](cli_commands.md#forward-flood-group-data-packets-on-repeaters) | Repeater | Yes | Yes | Yes |
| Routing | [`get/set/del flood.channel.block*`](cli_commands.md#block-selected-flood-channel-packets-on-repeaters) | Repeater | Yes | Yes | Yes |
| Routing | [`get/set/del flood.channel.scope*`](cli_commands.md#force-a-transport-scope-onto-floods) | Repeater | Yes | Yes | Yes |
| Routing | [`get/set/del flood.channel.scope.require*`](cli_commands.md#require-valid-incoming-scopes-only-on-selected-channels) | Repeater | Yes | Yes | Yes |
| Routing | [`get/set/del flood.filter*`; `get/set/del flood.filter.blacklist*`](cli_commands.md#filter-flood-packets-by-payload-type-hop-count-and-path) | Repeater | Yes | Yes | Yes |
| Routing | [`get/set/del flood.rule*`; `get/set/del flood.filter*`; `get/set/del flood.filter.blacklist*`](cli_commands.md#change-persistent-flood-rules-in-the-field) | Repeater; `flood.rule`/`flood.filter` also on FULL ESP32 room server (no blacklist) | Yes | Yes | Yes |
| Routing | [`get/set/del flood.moderation*`](cli_commands.md#moderate-flood-group-text-by-channel-sender-and-source-path) | Repeater | Yes | Yes | Yes |
| Routing | [`get/set outpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes |
| Routing | [`get/set altpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes |
@@ -273,10 +272,9 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| Routing | [`get/set flood.max.unscoped`](cli_commands.md#limit-the-number-of-hops-for-an-unscoped-flood-message) | Repeater, full common parser | Yes | Yes | Yes | No | No | Yes | Yes |
| Routing | [`get/set flood.max.advert`](cli_commands.md#limit-the-number-of-hops-for-an-advert-flood-message) | Repeater, full common parser | Yes | Yes | Yes | No | No | Yes | Yes |
| Routing | [`get/set flood.channel.data`; `get/set flood.channel.data.hops`](cli_commands.md#forward-flood-group-data-packets-on-repeaters) | Repeater, full common parser | Yes | Yes | Yes | No | No | Yes | Yes |
| Routing | [`get/set/del flood.channel.block*`](cli_commands.md#block-selected-flood-channel-packets-on-repeaters) | Repeater, full common parser | Yes | Yes | Yes | No | No | Yes | Yes |
| Routing | [`get/set/del flood.channel.scope*`](cli_commands.md#force-a-transport-scope-onto-floods) | Repeater role handler | Yes | Yes | Yes | No | Yes | Yes | Yes |
| Routing | [`get/set/del flood.channel.scope.require*`](cli_commands.md#require-valid-incoming-scopes-only-on-selected-channels) | Repeater role handler | Yes | Yes | Yes | No | Yes | Yes | Yes |
| Routing | [`get/set/del flood.filter*`; `get/set/del flood.filter.blacklist*`](cli_commands.md#filter-flood-packets-by-payload-type-hop-count-and-path) | Repeater role handler | Yes | Yes | Yes | No | Yes | Yes | Yes |
| Routing | [`get/set/del flood.rule*`; `get/set/del flood.filter*`; `get/set/del flood.filter.blacklist*`](cli_commands.md#change-persistent-flood-rules-in-the-field) | Repeater role handler; `flood.rule`/`flood.filter` also on FULL ESP32 room server (no blacklist) | Yes | Yes | Yes | No | Yes | Yes | Yes |
| Routing | [`get/set/del flood.moderation*`](cli_commands.md#moderate-flood-group-text-by-channel-sender-and-source-path) | Repeater role handler | Yes | Yes | Yes | No | Yes | Yes | Yes |
| Routing | [`get/set outpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Routing | [`get/set altpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
+217 -185
View File
@@ -25,7 +25,7 @@ arguments such as node names, passwords, and keys is left unchanged.
- [System](#system)
- [GPIO](#control-an-exposed-gpio)
- [Routing](#routing)
- [Flood Filtering](#filter-flood-packets-by-payload-type-and-hop)
- [Flood Rules](#change-persistent-flood-rules-in-the-field)
- [Group Text Moderation](#moderate-flood-group-text-by-channel-sender-and-source-path)
- [ACL](#acl)
- [Region Management](#region-management-v110)
@@ -1016,10 +1016,10 @@ get clock.sync.status
**Usage:**
- `get gpio` — list the Arduino pin numbers this firmware build permits
- `get gpio state`, `get gpio states`, or `get gpio status` — list every available pin currently controlled by the user (anything not in `reset`)
- `get gpio state <pin>` — show one pin's state; `states` and `status` are accepted here too
- `get gpio <pin>` — show `on`, `off`, or `reset`, plus any pending timed transition
- `get gpio` - list the Arduino pin numbers this firmware build permits
- `get gpio state`, `get gpio states`, or `get gpio status` - list every available pin currently controlled by the user (anything not in `reset`)
- `get gpio state <pin>` - show one pin's state; `states` and `status` are accepted here too
- `get gpio <pin>` - show `on`, `off`, or `reset`, plus any pending timed transition
- `set gpio <pin> on`
- `set gpio <pin> off`
- `set gpio <pin> reset`
@@ -1027,9 +1027,9 @@ get clock.sync.status
**Examples:**
- `set gpio 16 on 30 off` — drive GPIO16 high for 30 seconds, then drive it low
- `set gpio 16 on 5ms off` — drive GPIO16 high for 5 milliseconds, then drive it low
- `set gpio 16 off 5 reset` — drive GPIO16 low for 5 seconds, then return it to high impedance
- `set gpio 16 on 30 off` - drive GPIO16 high for 30 seconds, then drive it low
- `set gpio 16 on 5ms off` - drive GPIO16 high for 5 milliseconds, then drive it low
- `set gpio 16 off 5 reset` - drive GPIO16 low for 5 seconds, then return it to high impedance
An integer duration has seconds as its default unit, so `5` means 5 seconds. Add `ms` for milliseconds (`5ms`); an explicit `s` suffix is also accepted (`5s`). The maximum duration is 24 hours (86,400 seconds or 86,400,000 milliseconds). `on` or `off` without a duration remains in that state until another command, reset, or reboot.
@@ -1334,108 +1334,36 @@ reduce false recoveries when no traffic is expected.
**Forwarding behavior:** Repeater firmware only. The repeater still receives and
logs the packet when logging is enabled; this only blocks retransmission.
This is checked before `flood.channel.block` and applies to flood `GRP_DATA`
packets regardless of channel key. Flood group text (`GRP_TXT`) is unaffected by
this setting.
`flood.channel.data.hops` is separate from `flood.channel.block.hops`.
`flood.channel.block.hops` does not restrict unkeyed `GRP_DATA` packets. With
the default `flood.channel.data on`, `GRP_DATA` repeats normally even when
`flood.channel.block.hops` is set for keyed channel blocks.
This is checked before FPF7 and applies to flood `GRP_DATA` packets regardless
of channel key. Flood group text (`GRP_TXT`) is unaffected by this setting.
`get flood.channel.data` includes the active hop gate as `h=all` or `h>N`.
---
#### Block selected flood channel packets on repeaters
**Usage:**
- `get flood.channel.block`
- `get flood.channel.block.<n>`
- `get flood.channel.block <name|8_hex_prefix>`
- `get flood.channel.block.hops`
- `set flood.channel.block <key> <name> [h=<all|1-7|default>]`
- `set flood.channel.block.<n> <key> <name> [h=<all|1-7|default>]`
- `set flood.channel.block #channel [h=<all|1-7|default>]`
- `set flood.channel.block.<n> #channel [h=<all|1-7|default>]`
- `set flood.channel.block.hops <all|1-7>`
- `del flood.channel.block.<n>`
- `del flood.channel.block <name|8_hex_prefix>`
#### Block selected flood channels with FPF7
**Parameters:**
- `n`: Slot number from `1` to `15`.
- `key`: 128-bit or 256-bit channel key as hex.
- `#channel`: Public hashtag channel name; derives the 128-bit channel key from the hashtag and is stored as the row name.
- `name`: Local label for hex-key rows. Not needed for `#channel`; extra text after `#channel` is ignored unless it is a hop setting.
- `8_hex_prefix`: First 4 bytes of the derived channel hash, shown by single-entry `get`.
- `all`: Block matching flood channel packets at any received flood hop count.
- `1-7`: Maximum received flood path hash count to repeat. Matching packets over this hop count are blocked.
- `default`: Row inherits the global `flood.channel.block.hops` setting.
**Slot behavior:** Without `.n`, `set flood.channel.block` updates an existing
row with the same derived channel prefix or name, otherwise it uses the next
empty slot. If all 15 slots are full, the command fails. With `.n`, the command
writes that slot.
**Default row:** Repeater firmware seeds a new block list with
`#wardriving h=4` in slot 1. This is a normal row, so it can be changed with
`set flood.channel.block #wardriving h=<all|1-7|default>` or removed with
`del flood.channel.block #wardriving`. Once the block list has been saved, the
firmware uses the saved list and does not recreate the default after deletion.
Restore the exact seeded row with:
The separate `flood.channel.block` command and 15-row table have been retired.
Generalized repeaters use the 31-row FPF7 table for authenticated channel
blocks:
```text
set flood.channel.block.1 #wardriving h=4
set flood.rule type=any channel=#test hops=all drop
set flood.rule.2 type=any channel=#wardriving hops=5+ drop
set flood.rule type=any channel=9cd8fcf22a47333b591d96a2b848b73f hops=4+ drop
get flood.rule
del flood.rule.2
```
This explicitly writes slot 1. Omit `.1` to update an existing wardriving row
or use the first empty slot instead.
`type=any` with a channel condition can authenticate only `GRP_TXT` and
`GRP_DATA`, so it does not match other payload types. Use `hops=all` to block
at every received hop count. To preserve the old `h=N` meaning of repeating
through `N` hops and blocking longer paths, use `hops=N+1+`; old `h=4` is
therefore `hops=5+`.
**Forwarding behavior:** Repeater firmware only. This only affects received
flood `GRP_TXT` and `GRP_DATA` channel packets. The repeater still receives and
logs the packet, but it does not retransmit it when a configured block entry can
validate/decode it. If `flood.channel.data` is `off`, `GRP_DATA` packets are
checked against the separate `flood.channel.data.hops` gate before this
per-channel check runs.
**Hop gate:** `flood.channel.block.hops` defaults to `all`, which preserves the
original behavior. When set to `N` from `1` to `7`, block rows that inherit the
global setting only block packets whose received flood path hash count is
greater than `N`; packets at `N` hops or lower can still repeat. For example,
`set flood.channel.block.hops 1` repeats zero-hop and one-hop matches but blocks
two-hop and longer matches.
Each block row can override the global hop gate with `h=<all|1-7|default>`.
For example, the seeded `#wardriving h=4` row blocks `#wardriving` matches
above four hops, while `set flood.channel.block #bot h=7` blocks
`#bot` matches above seven hops. Use `h=default` to make the row inherit the
global setting again.
`get flood.channel.block` includes the global default first, then adds per-row
overrides as `/h>N` or `/h=all`; inherited rows do not show a suffix. Single-row
`get` replies include that row's stored hop mode as `h=def`, `h=all`, or `h>N`.
List replies truncate displayed row names only when the full list would exceed
the remote-management response limit.
**Matching behavior:** Each block entry stores the first 4 bytes of the derived
channel hash for display and lookup. Current group packets carry only the first
channel-hash byte, so that byte is used as a cheap prefilter. Only entries whose
first hash byte matches the packet try MAC/decrypt with their stored key. If
multiple blocked channels share the same first byte, the repeater tries each
matching key until one validates; the packet is blocked only after a successful
MAC/decrypt.
**Examples:**
```
set flood.channel.block #test
set flood.channel.block.2 9cd8fcf22a47333b591d96a2b848b73f #test
set flood.channel.block.hops 3
set flood.channel.block #wardriving h=4
set flood.channel.block #bot h=7
get flood.channel.block
get flood.channel.block.hops
get flood.channel.block #test
del flood.channel.block.2
```
New generalized repeater tables seed slot 2 with the second example. Existing
FCB2 rows are imported once into free FPF7 slots and the retired file is then
removed. The fixed-size STM32WL FPF6 build cannot match authenticated channels.
---
@@ -1443,8 +1371,8 @@ del flood.channel.block.2
**Usage:**
- `get flood.channel.scope`
- `get flood.channel.scope.<n>`
- `set flood.channel.scope <channel|txt:*|login:*|other:*> <region> [path=blacklist|path=bucket:1-6] [tx=slow]`
- `set flood.channel.scope.<n> <channel|txt:*|login:*|other:*> <region> [path=blacklist|path=bucket:1-6] [tx=slow]`
- `set flood.channel.scope <channel|txt:*|login:*|other:*> <region|scope=name> [path=blacklist|path=bucket:1-6] [tx=slow]`
- `set flood.channel.scope.<n> <channel|txt:*|login:*|other:*> <region|scope=name> [path=blacklist|path=bucket:1-6] [tx=slow]`
- `del flood.channel.scope.<n>`
- `del flood.channel.scope all`
@@ -1460,11 +1388,16 @@ del flood.channel.block.2
`RESPONSE`, `TXT_MSG`, `ANON_REQ`, and `PATH` (`0x00`, `0x01`, `0x02`,
`0x07`, and `0x08`). It classifies the outer type; a transit repeater cannot
authenticate whether a packet is actually part of a login session.
- `other:*`: Type-based wildcard for every remaining flood payload type except
TRACE, including ACK, advert, multipart, control, OTA, reserved types, and
raw custom. TRACE is deliberately exempt from forced-scope wildcards.
- `other:*`: Type-based wildcard for every remaining flood payload type,
including flood-form TRACE, ACK, advert, multipart, control, OTA, reserved
types, and raw custom.
- `region`: Existing named region with a usable transport key. A unique region
name prefix is accepted; wildcard region `*` is not a scope target.
- `scope=<name>`: Regionless alternative to `region`. The public name is
normalized with a leading `#`, and its 128-bit transport key is derived
directly from that hashtag exactly as for `flood.filter scope=<name>`. It
does not need to exist in the region list. Public names up to 30 characters
are accepted; private `$` scopes are not.
- `path=blacklist`: Optional. Require the received path to match the passive
`flood.filter.blacklist` ID table. No `flood.filter` drop row needs to be
enabled. One exact listed ID qualifies a 3-byte path. A 2-byte path requires
@@ -1494,15 +1427,16 @@ rows for the same channel. With `.n`, it replaces that slot. The three
wildcard classes are independent and consume one slot each.
`get flood.channel.scope` reports active/total slot counts; use the numbered
form for row detail. Keyed rows are displayed by the first four bytes of their
derived channel hash because channel secrets are never returned.
derived channel hash because channel secrets are never returned. Regionless
targets are displayed with their normalized leading `#`.
This acts on received `ROUTE_TYPE_FLOOD` and
`ROUTE_TYPE_TRANSPORT_FLOOD` packets. An unscoped packet gains the configured
scope; an already-scoped packet has its existing transport codes replaced. For
`GRP_TXT` and `GRP_DATA`, all exact channel-key rows are tried first and must
validate the packet MAC/decryption. Matching path-qualified exact rows are
tried before ordinary exact fallback rows. A row whose target region is
missing or unusable is skipped; later exact rows and then `txt:*` are tried.
tried before ordinary exact fallback rows. A region-backed row whose target
is missing or unusable is skipped; later exact rows and then `txt:*` are tried.
Exact keyed rows with a usable target therefore beat `txt:*` regardless of
slot number. Within each wildcard class, path-qualified rows similarly precede
ordinary fallback rows. `login:*` and `other:*` select their non-overlapping
@@ -1510,19 +1444,18 @@ outer-type families without decrypting the payload. The lowest usable slot
wins within each priority tier.
Standard traceroute is direct-routed and is therefore outside this flood-only
table. A custom flood-form `TRACE` is also left unchanged: no wildcard adds a
scope, an existing transport code is preserved, and region/unknown-code gates
do not block it.
table. A custom flood-form `TRACE` is treated like every other flood: an
applicable wildcard may rewrite it and region/unknown-code gates still apply.
On a match, the repeater sets the route to `ROUTE_TYPE_TRANSPORT_FLOOD`,
computes transport code 0 from the selected region key and packet payload, and
sets transport code 1 to zero. This occurs before region enforcement,
forwarding filters, and the seen-packet lookup. For an already-scoped packet,
the selected code replaces both incoming transport-code fields. Direct routes
are never rewritten. A packet converted from unscoped is no longer subject to
`flood.max.unscoped`; all rewritten packets remain subject to normal payload
handling, `flood.max`, region allow/deny, `flood.filter`,
`flood.channel.block`, loop detection, and moderation. Assigning a scope does
computes transport code 0 from the selected region or direct hashtag key and
packet payload, and sets transport code 1 to zero. This occurs before region
enforcement, forwarding filters, and the seen-packet lookup. For an
already-scoped packet, the selected code replaces both incoming transport-code
fields. Direct routes are never rewritten. A packet converted from unscoped is
no longer subject to `flood.max.unscoped`; all rewritten packets remain subject
to normal payload handling, `flood.max`, `flood.filter`,
loop detection, and moderation. Assigning a scope does
not make a packet type forwardable if the core would otherwise reject it. By
default, if the selected scope differs and the rewritten packet is accepted
for forwarding, its initial retransmission uses zero `txdelay` and the highest
@@ -1536,29 +1469,39 @@ Neither mode preempts an active radio transmission or bypasses CAD and
airtime-budget limits. Selecting the scope already carried by the packet is a
no-op and does not grant special transmit treatment.
If a row's target region has been removed or has no usable key, the repeater
A region-backed target must be locally flood-allowed and remains subject to
the normal region gate. A `scope=<name>` target is trusted for this matched
receive pass even though it has no region-list entry, matching the behavior of
`flood.filter scope=<name>`. It does not create a region, consume a region
slot, or change the allow/deny state for unrelated packets carrying the same
transport code.
If a region-backed row's target has been removed or has no usable key, the repeater
tries the next applicable row. For group packets this means later authenticated
exact rows followed by `txt:*`; wildcard duplicates likewise fall through to
the next usable slot. When no usable mapping exists, the packet retains its
original unscoped or scoped route.
LoRa OTA remains functional when `other:*` is configured. OTA packets are
given that region's transport code, replacing an existing code when necessary,
given that target's transport code, replacing an existing code when necessary,
but the OTA handler still accepts and re-floods them during the temporary-radio
window. The target region must allow flooding. The OTA core itself is dormant
outside that window; no default flood filter row is needed for that behavior.
Forced scope does not make OTA operate outside the window.
window. A region target must allow flooding; a direct target follows the
regionless trust behavior above. The OTA core itself is dormant outside that
window; no default flood filter row is needed for that behavior. Forced scope
does not make OTA operate outside the window.
**Capacity cost:** Each slot uses 36 bytes of runtime RAM and persistent
storage, plus a 5-byte file header. The four-slot minimum uses 144 bytes RAM and
a 149-byte file; it has room for the three wildcard classes plus one exact
channel mapping. Very-tight 15-slot builds use 540 bytes RAM and a 545-byte
file. The 31-slot table uses 1,116 bytes RAM and a 1,121-byte file. Roomy ESP32
builds use a 255-slot table: 9,180 bytes RAM and a 9,185-byte file. Classic
ESP32 LoRa-OTA builds that cannot afford that RAM use 31 slots instead. The
existing region table still permits 32 named regions, so a 255-slot channel
table may reuse targets but cannot name more than 32 distinct configured region
scopes.
**Capacity cost:** Each rule slot retains its 36-byte runtime and persistent
record. A separate 32-byte-name table holds up to the smaller of the rule count
or 32 distinct regionless targets; very-tight STM32WL builds hold one reusable
direct target. Region-backed targets do not consume this table.
The four-slot minimum uses 272 bytes RAM and a 278-byte file; it has room for
the three wildcard classes plus one exact channel mapping. Very-tight 15-slot
builds use 572 bytes RAM and a 578-byte file. The 31-slot table uses 2,108
bytes RAM and a 2,114-byte file. Roomy ESP32 builds use 255 rule slots and 32
direct-target slots: 10,204 bytes RAM and a 10,210-byte file. Classic ESP32
LoRa-OTA builds that cannot afford the 255-rule table use 31 slots instead.
Both configured regions and regionless targets can be reused by any number of
rules.
**Duplicate behavior:** Mesh dedup hashes payload type and payload bytes; it
does not hash route type, transport codes, or the ordinary flood path. Adding
@@ -1579,9 +1522,9 @@ already-scoped winner may have its code replaced by the better queued scope.
The comparison is deferred until dequeue so each copy retains its original
scope and path for arbitration. It can only use copies still present in
`rxdelay`; it cannot replace a packet that already won the dedupe race. TRACE
is excluded from scope arbitration entirely, so rxdelay never adds or replaces
a trace transport code.
`rxdelay`; it cannot replace a packet that already won the dedupe race.
Flood-form TRACE participates in this arbitration; direct traceroute does not
enter the flood queue.
A packet that matches a fast `flood.channel.scope` or `flood.filter scope=`
action and needs its scope changed bypasses the inbound `rxdelay` queue. A
@@ -1601,6 +1544,19 @@ get flood.channel.scope.1
del flood.channel.scope.2
```
A regionless exact mapping needs no `region` command:
```text
set flood.channel.scope #rgdata scope=BlackHole86
get flood.channel.scope
get flood.channel.scope.1
```
For example, if an authenticated `#rgdata` packet arrives carrying scope
`#usa`, that rule replaces `#usa` with `#BlackHole86` before forwarding. The
rule also assigns `#BlackHole86` when the packet is unscoped or carries any
other scope; it is a channel-to-target mapping, not an incoming-scope filter.
To use bridge bucket 1 to assign `east` to `public` packets whose received
3-byte path contains `7576FB`, while assigning `west` to every other
authenticated `public` packet:
@@ -1641,7 +1597,7 @@ they cannot rescue it or grant special receive/transmit timing.
Other group channels bypass the region/unknown-code gate while the table is
active. They remain subject to every other forwarding control, including
`repeat`, `flood.max*`, packet filters, channel blocks, loop detection, payload
`repeat`, `flood.max*`, packet filters, loop detection, payload
validation, and moderation. Non-channel flood payload types retain normal
global region enforcement.
@@ -1665,12 +1621,17 @@ get flood.channel.scope.require.1
---
#### Filter flood packets by payload type, hop count, and path
#### Change persistent flood rules in the field
For setup guidance, interactions with the existing forwarding controls, and
worked moderation examples, see [Repeater Flood Filtering and Moderation](flood_filtering.md).
worked moderation examples, see [Flood Filtering and Moderation](flood_filtering.md).
**Usage:**
- `get flood.rule`
- `get flood.rule.<n>`
- `set flood.rule[.<n>] type=<type> [hops=<range>] [channel=<channel>] [prefix=<path-prefix>] [in=<input-scope>] <drop|scope=<name>|region=<name>|rate=<N>/min|stop> [priority=<0-255>] [tx=slow] [suspend=tempradio]`
- `del flood.rule.<n>`
- `del flood.rule all`
- `get flood.filter`
- `get flood.filter.<n>`
- `get flood.filter.blacklist`
@@ -1684,40 +1645,71 @@ worked moderation examples, see [Repeater Flood Filtering and Moderation](flood_
- `del flood.filter.<n>`
- `del flood.filter all`
The extended table is available on repeaters with the rule engine enabled and
on FULL-profile ESP32 room servers. A FULL room server exposes both
`flood.rule` and `flood.filter`, has 31 slots, and requires an administrator
for remote changes. It does not have the repeater's passive path blacklist, so
`flood.filter.blacklist*` and `path=blacklist` are repeater-only; use the
ordered `prefix=` match on a room server. Standard room-server profiles do not
compile this table.
**Parameters:**
- `n`: Rule slot from `1` to `16`.
- `n`: Rule slot in the build's compiled table (normally `1-31`; some
constrained profiles use fewer slots).
- `type`: Payload type name, full `PAYLOAD_TYPE_*` name, decimal value `0-15`,
hexadecimal value `0x00-0x0F`, or `any`.
- `hops`: Optional; omitted means `all`.
- `N`: Block only at received hop count `N`.
- `N+`: Block at received hop count `N` and higher.
- `N-M`: Block the inclusive received-hop range.
- `all`: Block at every received hop count (`0-63`).
- `N`: Match only at received hop count `N`.
- `N+`: Match at received hop count `N` and higher.
- `N-M`: Match the inclusive received-hop range.
- `all`: Match every received hop count (`0-63`).
- `0+`, `all`, and an omitted hop expression are equivalent. The CLI displays
the saved range as `all`.
- `channel=*|public|#name|128-bit-key|256-bit-key`: Optional authenticated
group-channel match. It applies only to `GRP_TXT`/`GRP_DATA`; `type=any`
plus a channel condition therefore matches only those group types.
- `prefix=<ID[,ID...]>`: Optional ordered source-path prefix of one to three
pbyte IDs. IDs must all be 2, 4, or 6 hex characters, matching a packet's
1-, 2-, or 3-byte pbyte width. `path=<prefix>` is an alias.
- `in=any|none|scoped|allowed|unknown|scope:<name>|region:<name>`: Optional
condition on the original incoming scope, before any rule rewrites it.
`none` is an unscoped flood. `scope:name` is the exact public
hashtag-derived scope. `region:name` is an exact allowed region match.
- `drop`: Explicit drop action. The `flood.rule` form requires an explicit
action. For compatibility, a legacy `flood.filter` row with no rewrite,
rate, or stop action is treated as drop.
- `region=<name>`: Rewrite action using an existing locally allowed region.
- `rate=N/min`: Per-node, per-row fixed one-minute forwarding limit. It can be
the only action or accompany `scope=`/`region=`. Counters are charged only
for packets that pass all forwarding gates.
- `priority=0-255`: Optional processing order. Higher values run first and
lower slot number breaks a tie. The default is `0`; `pri=` is an alias.
- `stop` or `action=stop`: Apply this matching row, then stop lower-order FPF7
rows from processing. It can stand alone or accompany drop, rewrite, or
rate. A stop-only row acts as an exception to lower-priority FPF7 rules.
- `suspend=tempradio`: Optional. Skip this row only while the temporary radio
is actually active.
- `scope=<name>`: Optional scope-setting action. The name is normalized with a
leading `#` and its 128-bit transport key is derived directly from that
hashtag. It does not need to exist in the region list. Public names up to 30
characters are accepted; private `$` scopes are not.
- `require=region`: Optional and valid only with `scope=`. Apply the scope
rewrite only if the original incoming packet already passes this repeater's
- `require=region`: Legacy alias for `in=allowed`. Apply the row only if the
original incoming packet already passes this repeater's
region gate. An incoming transport scope must resolve to a locally allowed
region; an unscoped flood must be allowed by the wildcard region. The check
occurs before any scope rewrite during this receive pass.
- `tx=slow`: Optional and valid only with `scope=`. Use an effective inbound
- `tx=slow`: Optional and valid with `scope=` or `region=`. Use an effective inbound
`rxdelay` base of `max(2, configured rxdelay * 2)`, keep normal outbound
queue priority, and retransmit with the maximum supported `txdelay` factor
of `2.0`. Scope rows default to fast; `tx=fast` explicitly restores that
default when replacing a slow row.
- `path=blacklist`: Optional unordered path condition. The persistent blacklist
contains up to 255 unique 3-byte repeater IDs on ESP32 builds and 18 on other
builds, each written as six hexadecimal digits. A packet with 3-byte path
hashes matches after one exact ID hit. A packet with 2-byte path hashes
matches after two path entries match the first two bytes of listed IDs.
Packets with 1-byte path hashes never match this condition. Each received
path entry is counted at most once.
is repeater-only. It contains up to 255 unique 3-byte repeater IDs on ESP32
builds and 18 on other builds, each written as six hexadecimal digits. A
packet with 3-byte path hashes matches after one exact ID hit. A packet with
2-byte path hashes matches after two path entries match the first two bytes
of listed IDs. Packets with 1-byte path hashes never match this condition.
Each received path entry is counted at most once.
The payload names follow the [MeshCore packet-format allocation](https://docs.meshcore.io/packet_format/):
@@ -1745,16 +1737,21 @@ The payload names follow the [MeshCore packet-format allocation](https://docs.me
`ROUTE_TYPE_FLOOD` (`0x01`, unscoped flood). Direct routes `0x02` and `0x03`
are never affected.
**Behavior:** A row with `path=blacklist` must meet the path condition as well
as its payload-type and hop-range conditions. Blacklist IDs can occur anywhere
in the received path and their configured order is irrelevant. A matching row
without `scope=` prevents retransmission by this
repeater. A matching row with `scope=` instead sets or replaces the packet's
transport scope and does not block it. The lowest-numbered matching scope row
wins; matching drop rows remain independent and can still block the rewritten
packet. Scope rewriting happens before region enforcement and is trusted even
**Behavior:** Match fields within one row are ANDed. Every FPF7 row is matched
against the same immutable receive-time packet, before any rule changes its
scope. Matching rows are processed in descending `priority`, with lower slot
number winning a tie. The first matching `stop` row is included and all
lower-order FPF7 matches are discarded. A stop cannot undo an earlier drop or
bypass hard forwarding gates and separate forwarding tables. A row with
`path=blacklist` must meet the path condition as well as its other conditions;
blacklist IDs can occur anywhere in the received path and their configured
order is irrelevant. In contrast, `prefix=` begins at the first received path
entry and preserves order. A matching drop row prevents retransmission. The
highest-order remaining matching scope/region row wins; matching drop and rate
rows remain independent and can still block the rewritten packet. Scope rewriting
happens before region enforcement and is trusted even
when its name is absent from the local region list. It does not bypass
`repeat`, `flood.max`, other drop rows, channel blocking, loop detection, or
`repeat`, `flood.max`, other drop rows, loop detection, or
moderation.
With `require=region`, a failed check makes that scope row ineligible. It leaves
@@ -1773,13 +1770,26 @@ to ten packet airtimes. Selecting the scope already carried is a no-op and
does not grant special treatment. An active radio transmission is not
preempted, and CAD and airtime-budget limits still apply.
The packet is still received and can still be logged. Rules are persistent.
The packet is still received and can still be logged. Rules are persistent
data and can be changed over serial or authenticated remote CLI without an OTA
or reboot. `flood.rule` and `flood.filter` address the same table on extended
builds; FPF6 files are migrated in memory and the next save writes FPF7.
FPF1-FPF5 files are rejected and filtering fails open. FPF7 stores canonical
region names rather than transient numeric region
IDs. Removing, reordering, or reusing a region ID therefore cannot silently
retarget a rule. If a saved input or target region name is absent, that input
match or rewrite is inert; restoring the same region name reactivates it.
While the temporary radio is active, only rows explicitly marked
`suspend=tempradio` are skipped. `tempradio` is a radio state, not an OTA mode;
normal payload types can also use the temporary channel. Other rows remain in
force. A malformed persisted table fails open (no general rules are applied).
**Default row:** Repeater firmware seeds a new flood-filter table with
Within one receive evaluation, rows that use the same channel key share one
authentication result. The cache is discarded after that packet and stores
neither plaintext nor passwords; different keys are authenticated separately.
**Default row:** Repeater firmware and FULL ESP32 room-server firmware seed a
new flood-filter table with
`ota all suspend=tempradio` in slot 1. This blocks repeated LoRa OTA (`0x0C`)
floods at every received hop unless temporary radio is actually active. The OTA
core independently refuses OTA receive, relay, and transmit outside temporary
@@ -1793,24 +1803,28 @@ set flood.filter.1 0x0C all suspend=tempradio
Omitting `all` is equivalent. Omit `.1` as well to reuse an identical rule or
the first empty slot instead of replacing slot 1.
**Remote-admin protection:** Drop rows cannot block `anon_req`, `path`, or
`response` at received hop counts `0-6`; those login-capable types become
blockable at hop `7`. Flood `txt_msg` cannot be blocked at hops `0-4` and
becomes blockable at hop `5`. Scope-setting rows are non-blocking and may apply
inside these protected ranges. `req`, `ack`, and multipart ACK have no special
protection. Transit repeaters cannot decrypt these packets to distinguish an
admin exchange from ordinary peer traffic, so each floor necessarily covers
all flood packets of that outer type. These exceptions apply only to
`flood.filter` drop actions; `repeat`, `flood.max*`, loop-detection, and other
forwarding gates remain authoritative.
**Remote-admin lockout warning:** There are no hidden payload-type or short-hop
exceptions. FPF7 drop and rate rows may block `req`, `response`, `txt_msg`,
`anon_req`, `path`, ACK, and multipart traffic beginning at hop `0` when their
match fields say so. Transit repeaters cannot decrypt these outer types to
distinguish an admin exchange from ordinary peer traffic. Keep a serial or
other recovery path and stage broad deny/rate rules carefully.
Without `.n`, `set` reuses an existing rule with the same match, scope,
requirement, and suspension settings, or uses the first empty slot. This lets
`tx=slow` or `tx=fast` change that rule's timing without creating a duplicate.
With `.n`, it replaces that slot.
`get flood.filter` gives a compact list. Use `get flood.filter.<n>` for full
details, including `path=blacklist`, `scope=`, and `suspend=tempradio`.
The detail output also includes `require=region` and `tx=slow` when enabled.
Without `.n`, `set` reuses an identical rule or uses the first empty slot. With
`.n`, it replaces that slot, which is the intended way to change a row's match
or action.
`get flood.filter` or `get flood.rule` gives a compact list. Use the numbered
form for full details, including channel, prefix, original-scope condition,
action, timing, rate, and temporary-radio suspension.
If all of those fields plus long names would exceed one CLI reply, the
numbered form automatically switches to a non-truncating compact spelling.
The compact aliases are also accepted by `set`: `c=` means `channel=`, `p=`
means `prefix=`, `i=*|n|s|a|u|s:<scope>|r:<region>` means the corresponding
`in=` condition, `q=N` means `rate=N/min`, `pri=N` means priority, and `f=st`
combines slow timing
(`s`) with temporary-radio suspension (`t`). Packet type is shown numerically
in that fallback. Normal-sized rows keep the descriptive spelling above.
The blacklist and filter rows are persisted separately. Replacing or deleting
the blacklist does not delete rows containing `path=blacklist`; such rows
@@ -1827,12 +1841,16 @@ IDs as fit in one reply; use numbered `get` to inspect entries beyond that
reply.
Standard traceroute is direct-routed and therefore outside `flood.filter`
entirely. For a custom flood-form trace, catch-all `any` rows are deliberately
ignored; only a deliberately explicit `set flood.filter trace ...` row can
match it.
entirely. A custom flood-form trace participates normally: `type=any`, explicit
`trace`, rewrite, rate, drop, and stop rows can all apply.
**Examples:**
```text
set flood.rule.2 type=grp_data hops=4+ channel=#rgdata in=none scope=BlackHole86
set flood.rule.3 type=grp_data channel=#rgdata in=scope:usa scope=BlackHole86
set flood.rule.4 type=any prefix=860C rate=10/min
set flood.rule.5 type=grp_data hops=0-2 channel=#rgdata priority=200 stop
get flood.rule.2
set flood.filter grp_data 4+
set flood.filter.2 PAYLOAD_TYPE_ADVERT 6+
set flood.filter ota 2-4
@@ -1853,6 +1871,19 @@ get flood.filter.2
del flood.filter.2
```
The first rule authenticates `#rgdata`, requires more than three received hops,
and adds `#BlackHole86` only when no scope was present. The second rewrites the
exact incoming `#usa` scope. The third demonstrates a two-byte pbyte source
prefix and a global per-row rate cap. The fourth authenticates `#rgdata` at
zero through two hops, applies no FPF7 action of its own, and stops lower-order
FPF7 rows; hard gates and separate tables still apply.
The fixed 240 KB STM32WL profiles leave
`MESH_ENABLE_FLOOD_RULE_ENGINE=0` and retain the compact, persistent FPF6
`flood.filter` and blacklist commands. They still filter floods, but do not
expose the `flood.rule` alias or its extended channel, prefix, input-scope,
region-action, or rate fields. No partition size changes are required.
---
#### Moderate flood group text by channel, sender, and source path
@@ -1933,7 +1964,8 @@ del flood.moderation.3
**Filter manager scope:** Permission `5` can use an explicit allowlist of
non-secret operational/filter status commands and can change the forwarding
controls `repeat`, `loop.detect`, `flood.max*`, `flood.channel.data*`,
`flood.channel.block*`, `flood.filter*`, and `flood.moderation*`. It cannot read
`flood.filter*`, `flood.rule*`, and
`flood.moderation*`. It cannot read
guest, WiFi, MQTT, bridge, or other credentials, and it cannot change regions,
ACL entries, radio settings, or other admin configuration. Permission `4`
is limited to region commands, `flood.channel.scope*`, and the same non-secret
+279 -112
View File
@@ -1,9 +1,13 @@
# Repeater Flood Filtering and Moderation
# Flood Filtering and Moderation
This guide explains the Keymind repeater forwarding filters. The filters decide
whether this repeater retransmits a packet and can assign a transport scope
before that decision. They do not stop local reception, packet logging, or MQTT
observation.
This guide explains the Keymind forwarding filters. Repeaters expose the full
set of channel, rule, blacklist, and moderation controls described here.
FULL-profile ESP32 room servers expose the generalized `flood.rule` table (and
its `flood.filter` alias) with 31 slots, but not the repeater's channel tables,
passive blacklist, or text-moderation table. Standard room-server profiles do
not compile the rule table. Filters decide whether the node retransmits a
packet and can assign a transport scope before that decision. They do not stop
local reception, packet logging, or MQTT observation.
Only flood routes are filtered:
@@ -18,7 +22,7 @@ OTA assignment noted below.
## Before making changes
Show the current forwarding controls:
On a repeater, show the current forwarding controls:
```text
get repeat
@@ -27,39 +31,63 @@ get flood.max.unscoped
get flood.max.advert
get flood.channel.data
get flood.channel.data.hops
get flood.channel.block
get flood.channel.scope
get flood.channel.scope.require
get flood.filter
get flood.rule
get flood.moderation
```
The `flood.filter` and `flood.moderation` tables each have 16 persistent slots.
A new `flood.filter` table starts with `ota all suspend=tempradio` in slot 1;
`flood.moderation` starts empty. A row can opt into `suspend=tempradio`;
`flood.rule` is an alias for `flood.filter`, not another table. The
The generalized FPF7 table has 31 persistent slots; compact target profiles
may compile fewer FPF6 filter slots. `flood.moderation` has 16 slots. A new
repeater FPF7 table starts with `ota all suspend=tempradio` in slot 1 and an
authenticated `#wardriving hops=5+` drop in slot 2; FULL room servers seed only
the OTA row. `flood.moderation` starts empty. A row can opt into
`suspend=tempradio`;
temporary radio is not synonymous with OTA and can carry normal packet types
too. A corrupt or truncated table fails open, so corrupt storage does not
silently enable blocking.
On a FULL ESP32 room server, use `get flood.rule` (or `get flood.filter`) for
the available table. Remote rule changes require room-server administrator
access. `flood.filter.blacklist*` and `path=blacklist` are repeater-only; use
the ordered `prefix=` condition on a room server.
## Force floods into a transport scope
`flood.channel.scope` can add a scope to a received unscoped flood or replace
the scope of a transport-scoped flood before this repeater forwards it:
```text
set flood.channel.scope <channel|txt:*|login:*|other:*> <region> [path=blacklist|path=bucket:1-6] [tx=slow]
set flood.channel.scope.<slot> <channel|txt:*|login:*|other:*> <region> [path=blacklist|path=bucket:1-6] [tx=slow]
set flood.channel.scope <channel|txt:*|login:*|other:*> <region|scope=name> [path=blacklist|path=bucket:1-6] [tx=slow]
set flood.channel.scope.<slot> <channel|txt:*|login:*|other:*> <region|scope=name> [path=blacklist|path=bucket:1-6] [tx=slow]
get flood.channel.scope
get flood.channel.scope.<slot>
del flood.channel.scope.<slot>
del flood.channel.scope all
```
The channel may be `public`, `#channel`, or a 128/256-bit hex key. The region
must already exist and provide a usable transport key. Keyed rules first check
the one-byte channel hash carried in the packet, then validate the MAC by
decrypting with the configured channel key. A hash collision alone cannot
force a scope.
The channel may be `public`, `#channel`, or a 128/256-bit hex key. A bare
target names an existing region with a usable transport key. Use
`scope=<name>` instead to derive a regionless public hashtag scope exactly as
`flood.filter scope=<name>` does. The direct name is normalized with a leading
`#`, may contain up to 30 characters, and does not need a region-list entry.
Keyed rules first check the one-byte channel hash carried in the packet, then
validate the MAC by decrypting with the configured channel key. A hash
collision alone cannot force a scope.
For example, this authenticates only `#rgdata` and rewrites it to
`#BlackHole86` without creating a region:
```text
set flood.channel.scope #rgdata scope=BlackHole86
get flood.channel.scope.1
```
If that channel arrives scoped to `#usa`, the rule replaces `#usa` with
`#BlackHole86`. It also handles unscoped packets and replaces any other
incoming scope; the source scope is not a condition on the rule.
Add `path=blacklist` to make a channel-scope row eligible only when the
received path matches the passive `flood.filter.blacklist` ID table. It does
@@ -81,11 +109,11 @@ There are three independent wildcard classes:
- `txt:*` handles otherwise-unmatched `GRP_TXT` and `GRP_DATA`; plain `*` is
its alias.
- `login:*` handles `REQ`, `RESPONSE`, `TXT_MSG`, `ANON_REQ`, and `PATH`.
- `other:*` handles every remaining flood payload type except TRACE, including
OTA. TRACE is deliberately exempt from forced-scope wildcards.
- `other:*` handles every remaining flood payload type, including flood-form
TRACE and OTA.
`login:*` and `other:*` classify only the visible outer payload type; they do
not authenticate its contents. Exact channel rows with usable target regions
not authenticate its contents. Exact channel rows with usable targets
always take precedence over `txt:*`, even if that wildcard has a lower slot
number. Within the exact class, matching path-qualified rows are tried before
ordinary fallback rows. The same qualified-then-fallback order applies within
@@ -110,12 +138,14 @@ enabled.
On a successful match, an unscoped route changes from `ROUTE_TYPE_FLOOD` to
`ROUTE_TYPE_TRANSPORT_FLOOD`; an already-scoped route remains transport-flood
but receives replacement codes. Transport code 0 is calculated with the target
region key over the payload type and payload, and code 1 becomes zero. The
region or direct hashtag key over the payload type and payload, and code 1 becomes zero. The
change happens before region enforcement, forwarding filters, and
deduplication. Consequently `flood.max.unscoped` no longer applies to a packet
converted from unscoped, while `flood.max`, target-region permissions,
`flood.filter`, channel blocking, loop detection, and moderation still apply
to every rewritten packet. By default, if the selected scope differs and the
converted from unscoped, while `flood.max`, `flood.filter`, loop detection, and
moderation still apply to every rewritten packet. A region
target must be flood-allowed. A regionless target is trusted for this matched
receive pass, but it neither creates a region nor changes how unrelated packets
with the same transport code pass the region gate. By default, if the selected scope differs and the
rewritten packet passes those checks, its initial retransmission uses zero
`txdelay` and the highest outbound queue priority so the newly scoped copy can
win at the next hop. Add `tx=slow` to use an effective inbound `rxdelay` base
@@ -126,17 +156,19 @@ It does not preempt an active radio transmission or bypass CAD and
airtime-budget limits. Selecting the scope already present is a no-op and does
not grant special treatment.
Direct routes are never rewritten. TRACE is never rewritten even in flood
form; its existing code, if any, is preserved and it bypasses
region/unknown-code enforcement. Scope assignment also does not override
normal payload validation or make an otherwise non-forwardable packet type
forwardable.
Direct routes are never rewritten. Standard traceroute is direct-routed and
therefore remains outside this flood-only table. A custom flood-form TRACE is
treated like every other flood: an applicable wildcard may rewrite it and the
normal region/unknown-code gates still apply. Scope assignment does not
override normal payload validation or make an otherwise non-forwardable
packet type forwardable.
LoRa OTA (`0x0C`) falls under `other:*`. A matching row adds the selected
transport code or replaces the existing one, but OTA still operates normally
during the temporary-radio window because the OTA handler accepts both
unscoped and transport-scoped flood routes. The target region must allow
flooding. A new repeater also seeds `ota all suspend=tempradio` in flood-filter
unscoped and transport-scoped flood routes. A region target must allow
flooding; a direct target uses the regionless trust behavior above. A new
repeater also seeds `ota all suspend=tempradio` in flood-filter
slot 1. That visible rule blocks OTA forwarding at every received hop outside
temporary-radio operation and is skipped while temporary radio is active.
Independently, the OTA core refuses OTA receive, relay, and transmit outside an
@@ -145,17 +177,22 @@ replaced.
Capacity is selected at build time:
- Roomy ESP32 builds: 255 slots, 9,180 bytes RAM, 9,185-byte file.
- Roomy ESP32 builds: 255 rule slots and 32 regionless-target slots, 10,204
bytes RAM, 10,210-byte file.
- DRAM-tight classic ESP32 LoRa-OTA repeaters, nRF52, and other normal
constrained builds: 31 slots, 1,116 bytes RAM, 1,121-byte file.
- Very-tight STM32WL builds: 15 slots, 540 bytes RAM, 545-byte file.
- The no-PSRAM LilyGo T-LoRa V2.1 repeater/observer: 4 slots, 144 bytes RAM,
149-byte file. This minimum holds the three wildcard classes and one exact
channel mapping.
constrained builds: 31 rule and regionless-target slots, 2,108 bytes RAM,
2,114-byte file.
- Very-tight STM32WL builds: 15 rule slots and one reusable regionless-target
slot, 572 bytes RAM, 578-byte file.
- The no-PSRAM LilyGo T-LoRa V2.1 repeater/observer: 4 rule and
regionless-target slots, 272 bytes RAM, 278-byte file. This minimum holds the
three wildcard classes and one exact channel mapping.
The region map still has 32 named-region entries. Large ESP32 tables can map
many channels to the same targets, but cannot reference more than 32 distinct
configured region names.
Each rule retains its 36-byte record. A separate table holds 32-byte normalized
names for up to the smaller of the rule count or 32 distinct regionless
targets, except that very-tight STM32WL builds retain one reusable direct
target. Both configured regions and regionless targets can be reused by any
number of rules.
## Require valid incoming scopes only on selected channels
@@ -183,7 +220,7 @@ cannot rescue it or grant special receive/transmit timing.
Group-channel packets that do not authenticate against any listed key bypass
the region/unknown-code forwarding gate. They still pass through `repeat`,
`flood.max*`, `flood.filter`, `flood.channel.block`, loop detection, payload
`flood.max*`, `flood.filter`, loop detection, payload
validation, and moderation. Non-channel flood payload types retain the normal
global region behavior. A one-byte channel-hash collision is only a prefilter;
the packet must also pass MAC validation/decryption with the configured key.
@@ -225,8 +262,8 @@ of a less-preferred scope it already carried.
Scope selection happens at dequeue so the original scopes remain available for
comparison. It applies only while copies are queued and cannot alter a copy
already processed into the seen table. TRACE is excluded from scope arbitration
entirely, so rxdelay never adds or replaces a trace transport code.
already processed into the seen table. Flood-form TRACE participates in the
same arbitration. Direct traceroute never enters this flood queue.
A packet that already matches a configured fast `flood.channel.scope` or
`flood.filter scope=` action and needs its scope changed bypasses this inbound
@@ -234,10 +271,130 @@ A packet that already matches a configured fast `flood.channel.scope` or
configured base, floored at `2.0`, and participates in normal queued-copy
scope arbitration.
## Filter by payload type, received hop count, and path
## Runtime flood rules
Use `flood.filter` when the packet type, current path length, or listed path
identifiers are enough to make the decision:
On repeaters with the rule engine enabled and on FULL-profile ESP32 room
servers, `flood.rule` and `flood.filter` are two names for the same persistent
table. The evaluator is fixed firmware, but every row is data, so an
authenticated operator can add, replace, inspect, or delete a row without an
OTA or reboot. Existing `flood.filter` commands remain compatible. Only FPF6
and FPF7 files are accepted; FPF1-FPF5 files are rejected and filtering fails
open. A row saved by the extended engine uses FPF7.
The former `flood.channel.block` table is now represented by ordinary FPF7
rows. On a generalized repeater, an existing FCB2 file is imported once into
free FPF7 slots and then removed. For example, an old `#wardriving h=4` row
becomes `type=any channel=#wardriving hops=5+ drop`. The 31 FPF7 slots replace
the former 16 general-filter plus 15 channel-block slots. Compact STM32WL FPF6
builds cannot match authenticated channels.
FPF7 binds `in=region:<name>` and `region=<name>` to canonical region names,
not numeric region IDs. Removing, reordering, or reusing a region ID cannot
silently redirect a rule. If the saved name is missing, an input-region match
does not match and a target-region rewrite is skipped. Re-adding the same name
reactivates the rule.
The extended form is:
```text
set flood.rule[.<slot>] type=<type> [hops=<range>] [channel=<channel>]
[prefix=<ID[,ID...]>] [in=<input-scope>] <action> [rate=<N>/min]
[priority=<0-255>] [stop] [tx=fast|slow] [suspend=tempradio]
get flood.rule
get flood.rule.<slot>
del flood.rule.<slot>
del flood.rule all
```
The command must be entered on one line. Match fields in one row are ANDed.
Every row is matched against the same immutable packet state captured on
receive, before any rule rewrites its scope. Matching rows are then processed
by descending `priority`; lower slot number wins a priority tie. Priority
defaults to `0`.
The first matching `stop` row ends FPF7 processing after that row. Higher-order
matches and the stop row still apply; lower-order matches do not. A stop-only
row is therefore an exception to lower-priority FPF7 rows. It cannot undo a
higher-priority drop and it does not bypass hard forwarding gates or separate
forwarding tables. Without a stop row, matching drop and rate rows remain
independent and the highest-order matching scope or region rewrite wins.
Match fields:
- `type=` accepts the same packet names and numeric values as legacy
`flood.filter`. The positional form remains accepted.
- `hops=` accepts `all`, `N`, `N+`, or `N-M`. The positional form remains
accepted. Received hops over 3 are written as `hops=4+`.
- `channel=*|public|#name|128-bit-key|256-bit-key` authenticates a `GRP_TXT` or
`GRP_DATA` packet with that channel key before the row can match. With
`type=any`, this condition naturally limits the row to those two group
packet types.
- `prefix=` is a source-path prefix containing one to three comma-separated
pbyte IDs. Every ID must use the packet's pbyte width: 2, 4, or 6 hex
characters for 1-, 2-, or 3-byte paths. Order matters and matching begins at
the first received path entry. `path=<prefix>` is an alias;
`path=blacklist` retains its separate unordered-list behavior.
- `in=any|none|scoped|allowed|unknown|scope:<name>|region:<name>` tests the
original incoming route before any rewrite. `none` means an unscoped flood;
`scoped` means any transport flood; `scope:name` compares the exact public
hashtag-derived scope; and `region:name` compares an allowed configured
region. `allowed` is the legacy `require=region` test and includes an
unscoped packet when the wildcard region allows it. `unknown` means a scoped
packet that does not resolve to an allowed local region.
Actions:
- `drop` prevents retransmission when the row matches. The strict
`flood.rule` form requires an explicit action. For backward compatibility,
only a legacy `flood.filter` row with no rewrite, rate, or stop action means
drop implicitly.
- `scope=<name>` assigns a direct public hashtag scope without requiring a
region-list entry.
- `region=<name>` assigns an existing locally allowed region and its transport
key.
- `rate=N/min` is a per-node, per-row fixed one-minute forwarding limit. It can
stand alone or accompany a scope/region rewrite. Quota is charged only after
every other forwarding gate, including moderation, accepts the packet. It is
not keyed per sender; use `flood.moderation` when a group-text rate must be
tied to an exact display name.
- `priority=0-255` controls processing order. Higher values run first; lower
slot number breaks ties. `pri=` is the compact alias.
- `stop` (or `action=stop`) applies this row and prevents lower-order FPF7 rows
from acting. It can stand alone or accompany drop, rewrite, or rate.
When several rows use the same channel key, authentication is performed once
for that packet and reused by those rows. This cache lives only for the current
receive evaluation; it is not persisted and never stores plaintext or a
password.
The exact requested examples are:
```text
# If #rgdata arrives unscoped with more than 3 received hops, add
# the regionless #BlackHole86 scope.
set flood.rule.2 type=grp_data hops=4+ channel=#rgdata in=none scope=BlackHole86
# Rewrite the exact incoming #usa scope to #BlackHole86 for #rgdata.
set flood.rule.3 type=grp_data channel=#rgdata in=scope:usa scope=BlackHole86
# Match a two-byte source-path prefix and cap forwarding at 10 per minute.
set flood.rule.4 type=any prefix=860C rate=10/min
# Keep authenticated #rgdata at two hops or less out of lower-priority FPF7
# rules. Hard gates and separate tables still apply.
set flood.rule.5 type=grp_data hops=0-2 channel=#rgdata priority=200 stop
get flood.rule.2
get flood.rule.3
get flood.rule.4
```
The 240 KB STM32WL profiles keep `MESH_ENABLE_FLOOD_RULE_ENGINE=0` and retain
the persistent compact FPF6 `flood.filter` and blacklist syntax below. They
still perform filtering, but omit the generalized `flood.rule` parser and
extended fields. No partition size is changed by this feature.
The compatible filter and blacklist commands are:
```text
set flood.filter.blacklist <ID[,ID...]>
@@ -277,32 +434,38 @@ of listed IDs. Each received entry is counted at most once. A 1-byte path
never qualifies. The IDs may occur anywhere in the received path; neither
their list order nor their path order matters.
Without a slot number, `set` reuses a rule with the same match, scope,
requirement, and suspension settings, or selects the first empty slot. This
lets `tx=slow` or `tx=fast` change that rule's timing without creating a
duplicate. With a slot number, it replaces that slot. Omitting the hop
expression means `all` (`0-63`).
Without a slot number, `set` reuses an identical row or selects the first empty
slot. Use a slot number to replace a row whose match or action is changing.
Omitting the hop expression means `all` (`0-63`).
A row without `scope=` is the existing drop action. A row with `scope=` is a
scope-setting action instead: it adds transport scope to an unscoped packet or
replaces the codes on an already-scoped packet. The scope name is normalized
Numbered `get` normally uses the long field names. If a rule containing
several maximum-length names would exceed one CLI reply, it switches to a
non-truncating compact spelling that `set` also accepts: `c=` is `channel=`,
`p=` is `prefix=`, `i=*|n|s|a|u|s:<scope>|r:<region>` represents `in=`, `q=N`
is `rate=N/min`, and `f=st` combines slow timing (`s`) and temporary-radio
suspension (`t`). The fallback prints packet type numerically.
A legacy row without `scope=` is the existing drop action. On extended builds,
an explicit `drop` has the same result, while `rate=` by itself creates a
rate-only row. A row with `scope=` adds transport scope to an unscoped packet
or replaces the codes on an already-scoped packet. The scope name is normalized
with a leading `#`, and the 128-bit transport key is derived directly from that
hashtag. The name does not need to exist in the region list and is not added to
it. Public names up to 30 characters are accepted; private `$` scopes are not.
Add `require=region` to a scope row when rewriting must not rescue a packet
that the incoming-region gate would reject. The repeater evaluates the packet's
`require=region` is the legacy spelling of `in=allowed`. The repeater evaluates the packet's
original route before any rewrite in that receive pass. An incoming transport
scope must match a locally allowed region; an unscoped flood must be allowed by
the wildcard region. If the check fails, that scope row is skipped, the filter
does not grant its region bypass, and the unchanged packet is allowed to fail
the wildcard region. If the check fails, that row is skipped, the filter does
not grant a region bypass, and the unchanged packet is allowed to fail
normal region enforcement. Other independently configured scope rows still
apply in their normal order.
When multiple scope rows match, the lowest-numbered row wins. Scope rows do not
approve a packet: any matching drop row and every remaining forwarding gate can
When multiple scope or region rows match, the highest-priority row wins; lower
slot number breaks a priority tie.
Rewrite rows do not approve a packet: any matching drop row and every remaining forwarding gate can
still reject it. A filter-assigned scope is trusted without local region-list
validation, but `repeat`, `flood.max`, channel blocking, loop detection, and
validation, but `repeat`, `flood.max`, loop detection, and
moderation still apply. By default, a changed scope bypasses inbound `rxdelay`,
then is retransmitted with zero `txdelay` and the highest outbound queue
priority. Add `tx=slow` to use an effective inbound `rxdelay` base of
@@ -312,10 +475,13 @@ zero through ten packet airtimes. `tx=fast` explicitly restores the default.
Selecting the scope already present does not grant special treatment. Active
radio transmission, CAD, and airtime-budget limits are unchanged.
The blacklist and rule table are persisted separately. Deleting the blacklist
leaves `path=blacklist` rows in place but dormant until IDs are configured
again. Path hashes are truncated routing identifiers and are not authenticated
proof that a particular repeater handled a packet.
On repeaters, the blacklist and rule table are persisted separately. FPF7
stores compiled match/action fields and canonical region names, not executable
code, a script, or numeric region references. Deleting the blacklist leaves
`path=blacklist` rows in place but dormant until IDs are configured again.
Path hashes are truncated routing identifiers and are not authenticated proof
that a particular repeater handled a packet. FULL room servers have only the
rule table and reject blacklist commands.
On first initialization, flood-filter slot 1 is seeded with:
@@ -333,38 +499,29 @@ on a temporary channel.
Suspension does not approve a packet or bypass the rest of the filter table. It
skips that row, then evaluation continues with the next row and the remaining
forwarding gates. An ordinary drop `any` row therefore still applies during
the temporary-radio window, subject to the short-path remote-admin protection
below. `repeat`, `flood.max*`, region handling, loop detection, and the OTA
the temporary-radio window unless an earlier matching stop row ends FPF7
processing. `repeat`, `flood.max*`, region handling, loop detection, and the OTA
subsystem's own hop limit also remain in force.
Standard traceroute uses direct routing and never enters `flood.filter`. For a
custom flood-form trace, catch-all `any` rows are deliberately ignored; only an
explicit `trace` row can match it. The stock core does not normally
flood-forward TRACE packets.
custom flood-form trace, `type=any`, explicit `trace`, scope, region, rate, and
drop rows all behave normally. The stock core does not normally flood-forward
TRACE packets.
### Remote administration cannot be type-filtered on short paths
### Remote-administration lockout warning
`flood.filter` drop actions use two minimum filterable hop counts:
- `anon_req`, `path`, and `response` cannot be blocked at received hops `0-6`;
configured rules begin applying at hop `7`.
- Flood `txt_msg` cannot be blocked at received hops `0-4`; configured rules
begin applying at hop `5`.
`req`, `ack`, and multipart ACK have no special floor and remain filterable from
hop `0`. Scope-setting rows do not block traffic and may apply within the
protected ranges.
There are no hidden payload-type or short-hop exemptions in FPF7. Drop and
rate rules can block `req`, `response`, `txt_msg`, `anon_req`, `path`, ACK, and
multipart traffic beginning at hop `0` when their match fields say so.
A flooded login starts as `ANON_REQ`; its reply is commonly a `PATH` packet
carrying an encrypted `RESPONSE`. Before a direct return path is established,
administrative replies and CLI text can also be flooded. Transit repeaters do
not have the session key and cannot distinguish those encrypted admin exchanges
from ordinary peer packets with the same outer type. Each hop floor therefore
covers the complete outer packet class, not only packets that ultimately
authenticate as administrators.
This protects only against configurable `flood.filter` drop actions. It does
not override `repeat`, `flood.max*`, loop detection, or other forwarding gates.
from ordinary peer packets with the same outer type. A rule therefore affects
the complete outer packet class, not only packets that ultimately authenticate
as administrators. Keep a serial or other recovery path and stage broad
deny/rate rules carefully.
Hop expressions are based on the path count when this repeater receives the
packet:
@@ -431,9 +588,9 @@ get flood.filter
On a new table, the factory OTA rule occupies slot 1, so these unnumbered
commands normally fill slots 2 through 7. Existing tables may choose different
free slots. The `response`, `anon_req`, and `path` thresholds are above their
protected `0-6` range, so all six rules take effect at the thresholds shown.
The Control rule allows a flood received with path count `0` to be forwarded
free slots. All six rules take effect at the thresholds shown; there are no
hidden short-hop exceptions. The Control rule allows a flood received with
path count `0` to be forwarded
once, then stops it at the next repeater. Normal node-discovery Control packets
are direct zero-hop packets and never enter `flood.filter`. These rules affect
only retransmission by the repeater; local reception and logging remain
@@ -542,18 +699,23 @@ other words, the controls combine as deny rules:
original incoming scope; unlisted group channels bypass the later region
gate while the table is active.
2. `flood.channel.scope` tries a path-qualified channel row before that
channel's ordinary fallback, then adds or replaces the scope.
3. A matching `flood.filter scope=` row may replace that result; its scope does
not require a region-list entry.
channel's ordinary fallback, then adds or replaces the scope from either a
configured region or a direct `scope=<name>` target.
3. All extended `flood.rule` match fields are evaluated against the same
original incoming packet. Matches are ordered by descending priority and
then ascending slot. The first matching `stop` row removes every later FPF7
match. The highest-order remaining `scope=` or `region=` row may replace the
channel-scope result; a direct scope does not require a region-list entry.
4. `repeat`, `flood.max*`, and the channel-data gate are checked.
5. `flood.filter` drop rows check payload type and hop range, subject to the
login floor of `7` and flood-text floor of `5` described above.
6. `flood.channel.block` checks keyed channels.
7. Region and loop-detection rules are checked; a filter-assigned scope is
already trusted when it has no region-list match, except that it cannot
rescue a channel rejected by `flood.channel.scope.require`.
8. `flood.moderation` checks decrypted group text, username, rate, hops, and
path.
5. Remaining `flood.filter`/`flood.rule` drop and rate rows use that saved
match result. No packet type or short-hop range is silently exempted.
6. Region and loop-detection rules are checked; a regionless scope assigned by
either table is already trusted when it has no region-list match, except
that it cannot rescue a channel rejected by
`flood.channel.scope.require`.
7. `flood.moderation` checks decrypted group text, username, rate, hops, and
path. If it accepts the packet, matching general-rule rate counters are
charged immediately before retransmission is approved.
The first denial is enough to prevent retransmission. A packet that is denied
can still appear in local logs or MQTT output. Moderation runs last because its
@@ -562,25 +724,30 @@ control and will actually be retransmitted.
## Delegate filter management
ACL permission `5` is the filter-manager role:
On repeaters, ACL permission `5` is the filter-manager role:
```text
setperm <companion-public-key-hex> 5
```
A filter manager can read non-secret operational status and manage `repeat`,
`loop.detect`, `flood.max*`, `flood.channel.data*`, `flood.channel.block*`,
`flood.filter*`, and `flood.moderation*`. Delegated `get` access uses an
`loop.detect`, `flood.max*`, `flood.channel.data*`,
`flood.filter*`, `flood.rule*`, and `flood.moderation*`. Delegated `get` access uses an
explicit allowlist: it cannot retrieve guest, WiFi, MQTT, bridge, or other
credentials, and it cannot change regions, ACL entries, radio settings, or
unrelated administrator settings. Because `flood.filter scope=` derives a
public hashtag key directly, a filter manager can configure that action without
region-manager permission; it still cannot edit the region hierarchy.
FULL ESP32 room servers use their existing administrator check for remote
`flood.rule` and `flood.filter` commands; they do not grant this table through
permission `5`.
ACL permission `4`, the region/scope-manager role, can read, add, replace, and
delete `flood.channel.scope` and `flood.channel.scope.require` rows and manage
regions. This lets the same delegate create target regions, assign forced
scopes, and select the channels that require valid incoming scopes.
scopes from regions or direct hashtag names, and select the channels that
require valid incoming scopes.
## Security limitations
@@ -599,13 +766,13 @@ private transport/channel keys, and controlled device access.
The repeater's factory-seeded forwarding rows can be restored through the CLI:
```text
set flood.channel.block.1 #wardriving h=4
set flood.filter.1 0x0C all suspend=tempradio
set flood.rule.1 type=ota hops=all drop suspend=tempradio
set flood.rule.2 type=any channel=#wardriving hops=5+ drop
```
Each command explicitly replaces slot 1 in its own table. Inspect the slot first
if it may now contain another rule. To preserve existing slot assignments, omit
`.1`; the command then reuses an identical row or uses the first empty slot.
These commands explicitly replace the two seeded generalized-repeater slots.
Inspect them first if they may now contain other rules. Compact FPF6 builds use
only the first command's `flood.filter.1 0x0C all suspend=tempradio` form.
## Remove the custom rules
@@ -618,5 +785,5 @@ get flood.filter
get flood.moderation
```
This does not change the older `flood.max*`, channel-block, loop-detection, or
region settings; inspect or reset those separately when troubleshooting.
This does not change the older `flood.max*`, loop-detection, or region settings;
inspect or reset those separately when troubleshooting.
+4 -5
View File
@@ -80,13 +80,12 @@ set flood.retry.ignore none
| `battery.alert.low` | Warning threshold percentage. Must be greater than `battery.alert.critical`. | `get battery.alert.low`, `set battery.alert.low <1-100>` | `set battery.alert.low 20` |
| `battery.alert.critical` | Critical threshold percentage. Critical and warning alerts use the same 12-hour resend cooldown. | `get battery.alert.critical`, `set battery.alert.critical <0-99>` | `set battery.alert.critical 10` |
| `recent.repeater` | Shows, searches, seeds, or clears the recent repeater prefix/SNR table used by direct retry and bridge freshness checks. Search results include the last-recorded age. Entries older than 24 hours are removed by a three-hour sweep. | `get recent.repeater [page]`, `get recent.repeaters search <2|4|6 hex> [page]`, `set recent.repeater <prefix> <snr_db>`, `clear recent.repeater` | `get recent.repeaters search A1B2` |
| `flood.channel.data` | Turns forwarding of flood `GRP_DATA` channel packets on or off. With the default `on`, `GRP_DATA` repeats normally even when `flood.channel.block.hops` is set. | `get flood.channel.data`, `set flood.channel.data on/off` | `set flood.channel.data off` |
| `flood.channel.data` | Turns forwarding of all flood `GRP_DATA` channel packets on or off. The default is `on`; use an authenticated FPF7 `channel=` drop when only selected channels should be blocked. | `get flood.channel.data`, `set flood.channel.data on/off` | `set flood.channel.data off` |
| `flood.channel.data.hops` | Separate hop gate used only when `flood.channel.data` is `off`; `all` blocks `GRP_DATA` at any hop count, `1`-`7` repeats at that hop count or lower and blocks longer paths. | `get flood.channel.data.hops`, `set flood.channel.data.hops <all|1-7>` | `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=<all|1-7|default>` for a per-channel hop override. | `get flood.channel.block`, `set flood.channel.block[.n] <key|#channel> [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 <all|1-7>` | `set flood.channel.block.hops 3` |
| `flood.channel.scope` | Adds a transport-region scope to received unscoped floods or replaces the scope of already-scoped floods. By default, a changed packet bypasses inbound `rxdelay` and is forwarded at the highest outbound queue priority with zero initial `txdelay`, so the selected scope can win at the next hop. `tx=slow` uses an effective inbound `rxdelay` base of `max(2, configured rxdelay * 2)`, retains normal queue priority, and forces the maximum `txdelay` factor of `2.0`; its actual randomized transmit delay ranges from zero through ten packet airtimes. `path=blacklist` and `path=bucket:<1-6>` make a row path-qualified; bridge buckets remain usable while bridge retry is off. An already-matching scope is a no-op. Exact channel keys beat `txt:*`; path-qualified rows beat the ordinary channel fallback. `login:*` covers the remote-login family, and `other:*` covers every remaining flood type except TRACE, including OTA. TRACE remains unchanged across scope boundaries. ACL permission `4` can manage the table. | `get flood.channel.scope[.n]`, `set flood.channel.scope[.n] <channel|txt:*|login:*|other:*> <region> [path=blacklist|path=bucket:1-6] [tx=slow]`, `del flood.channel.scope.<n>|all` | `set flood.channel.scope public east path=bucket:1` |
| `flood.channel.scope` | Adds a transport scope to received unscoped floods or replaces the scope of already-scoped floods. A bare target uses an existing flood-allowed region; `scope=<name>` derives a public hashtag target directly without creating a region, exactly like `flood.filter scope=`. By default, a changed packet bypasses inbound `rxdelay` and is forwarded at the highest outbound queue priority with zero initial `txdelay`, so the selected scope can win at the next hop. `tx=slow` uses an effective inbound `rxdelay` base of `max(2, configured rxdelay * 2)`, retains normal queue priority, and forces the maximum `txdelay` factor of `2.0`; its actual randomized transmit delay ranges from zero through ten packet airtimes. `path=blacklist` and `path=bucket:<1-6>` make a row path-qualified; bridge buckets remain usable while bridge retry is off. An already-matching scope is a no-op. Exact channel keys beat `txt:*`; path-qualified rows beat the ordinary channel fallback. `login:*` covers the remote-login family, and `other:*` covers every remaining flood type, including flood-form TRACE and OTA. Direct traceroute remains outside the flood table. ACL permission `4` can manage the table. | `get flood.channel.scope[.n]`, `set flood.channel.scope[.n] <channel|txt:*|login:*|other:*> <region|scope=name> [path=blacklist|path=bucket:1-6] [tx=slow]`, `del flood.channel.scope.<n>|all` | `set flood.channel.scope #rgdata scope=BlackHole86` |
| `flood.channel.scope.require` | Switches group-channel region enforcement to opt-in when the table has entries. Listed authenticated `GRP_TXT`/`GRP_DATA` channels must arrive already scoped to a locally allowed region; unscoped, unknown, or denied incoming scopes are dropped before any rewrite can rescue them. Unlisted group channels bypass only the region gate and retain all other forwarding controls. An empty table preserves global region behavior; non-channel payloads are unchanged. ACL permission `4` can manage the table. | `get flood.channel.scope.require[.n]`, `set flood.channel.scope.require[.n] <public|#channel|key>`, `del flood.channel.scope.require.<n>|all` | `set flood.channel.scope.require #bot` |
| `flood.filter` | Persistent repeater-only rules for flood routes `0x00`/`0x01`, selected by payload type, optional received hop count/range (omitted means `all`), and optional unordered `path=blacklist`. The separate blacklist stores up to 255 full 3-byte repeater IDs on ESP32 and 18 on other builds; the path condition qualifies on at least one exact 3-byte hit, at least two 2-byte-prefix hits, and never on 1-byte paths. Rows normally drop matching retransmissions; `scope=<name>` instead adds or replaces a public hashtag-derived scope without requiring a region-list entry. Scope rewrites default to bypassing inbound `rxdelay` and using zero initial `txdelay` plus highest queue priority; `tx=slow` uses an effective inbound `rxdelay` base of `max(2, configured rxdelay * 2)`, retains normal queue priority, and forces `txdelay` factor `2.0`. Adding `require=region` makes that rewrite conditional on the original incoming packet already passing local region enforcement, so unknown or denied incoming scopes are not rescued. New tables seed slot 1 with `ota all suspend=tempradio`; only rows marked `suspend=tempradio` are skipped during temporary-radio operation. Login-capable `anon_req`/`path`/`response` drop actions begin at hop `7`; flood `txt_msg` drops begin at hop `5`. Standard direct traceroute, other direct routing, and local receive/logging are unchanged. | `get/set/del flood.filter.blacklist[.n]`, `get flood.filter[.n]`, `set flood.filter[.n] <type> [N|N+|N-M|all] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]`, `del flood.filter.<n>|all` | `set flood.filter grp_txt all scope=local tx=slow` |
| `flood.filter` | Persistent flood-route rules selected by payload type and optional hop range. Extended builds have 31 FPF7 slots and add authenticated channel, ordered pbyte prefix, original scope/region, rewrite, rate, priority, and terminal stop fields; `flood.rule` is an alias for the same table. Repeaters also support a separate unordered `path=blacklist`. FULL ESP32 room servers have the same 31 extended slots but no blacklist and require remote administrator access. Fixed 240 KB STM32WL repeaters retain compact FPF6 filtering and blacklist commands. New generalized repeater tables seed slot 1 with `ota all suspend=tempradio` and slot 2 with an authenticated `#wardriving hops=5+` drop. Direct routes and local receive/logging are unchanged. | `get flood.filter[.n]`, `set flood.filter[.n] <type> [N|N+|N-M|all] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]`, `del flood.filter.<n>|all`; repeater only: `get/set/del flood.filter.blacklist[.n]` | `set flood.filter grp_txt all scope=local tx=slow` |
| `flood.rule` | Live alias for extended `flood.filter` on rule-engine repeaters and FULL ESP32 room servers. A row can AND packet type, hop range, authenticated group channel, ordered 1/2/3-byte pbyte source prefix, and original scope/region conditions, then drop, rewrite to a direct scope or configured region, enforce a per-row rate, and/or stop lower-priority FPF7 rules. All rows match the original receive-time packet; higher `priority` runs first and lower slot breaks a tie. Repeated rows with one channel key share a per-packet authentication result. Persistent FPF7 stores canonical region names, so region ID reorder or reuse cannot retarget a rule. A missing saved region name makes that match or rewrite inert until the name returns. Fixed 240 KB STM32WL profiles keep FPF6 and do not expose this alias; partition sizes are unchanged. | `get flood.rule[.n]`, `set flood.rule[.n] type=<type> [hops=...] [channel=...] [prefix=...] [in=...] <drop|scope=...|region=...|rate=N/min|stop> [priority=0-255]`, `del flood.rule.<n>|all` | `set flood.rule.2 type=grp_data hops=4+ channel=#rgdata in=none scope=BlackHole86` |
| `flood.moderation` | Decrypts keyed `GRP_TXT` channels and applies drop, per-username messages/minute, and maximum-hop controls, optionally matched against the first 1-3 path hashes. Supports `public`, `#channel`, and 128/256-bit channel keys. Sender names and truncated path hashes are moderation hints, not authenticated identities. | `get flood.moderation[.n]`, `set flood.moderation[.n] <channel> <sender> <drop|rate=X/min|hops=N> [path=...]`, `del flood.moderation.<n>|all` | `set flood.moderation public "Noisy User" rate=5/min hops=4` |
| `clock.sync.mesh` | Defaults on for nRF52 repeaters and off for other builds; a saved setting overrides that default. It estimates UTC as soon as the configured number of fresh signed-advert or valid Public-channel sources is collected, with a 30-minute bootstrap/retry timer when evidence is still insufficient, then repeats lazily seven days after each successful estimate. New evidence retriggers evaluation after a no-consensus result. `clock.sync.mesh now` queues an immediate LoRa-only attempt without bypassing quorum or source suppression. Only timestamps from firmware build time through build time plus ten years are recorded. Successful CLI, GPS, or WiFi/NTP clock updates suppress LoRa time collection until reboot; after reboot LoRa is the fallback if NTP cannot sync. Status reports the reason a clock was not set; its `.table` and `.1` through `.16` forms inspect collected samples. | `get clock.sync.mesh`, `set clock.sync.mesh <on|off>`, `clock.sync.mesh now`, `get clock.sync.status[.table|.1-.16]` | `set clock.sync.mesh on` |
| `clock.sync.mesh.edge` | Defaults on so edge repeaters can collect clock evidence when all packets arrive through one relay path. Verified evidence is observed before the forwarding decision, so `repeat off` and forwarding filters do not prevent collection. Signed adverts are deduplicated by public key and Public-channel timestamps by case-insensitive display name; all may share one receive path. Public display names are unauthenticated and can be spoofed. Changing this setting clears current clock samples. | `get clock.sync.mesh.edge`, `set clock.sync.mesh.edge <on|off>` | `set clock.sync.mesh.edge on` |
+16 -1
View File
@@ -59,7 +59,15 @@ Appdata Flags
## Acknowledgement
An acknowledgement that a message was received. Note that for returned path messages, an acknowledgement can be sent in the "extra" payload (see [Returned Path](#returned-path)) instead of as a separate acknowledgement packet. CLI commands do not cause acknowledgement responses, neither discrete nor extra.
An acknowledgement that a message was received. Note that for returned path messages, an acknowledgement can be sent in the "extra" payload (see [Returned Path](#returned-path)) instead of as a separate acknowledgement packet. Current `CLI_DATA` commands do not cause acknowledgement responses, neither discrete nor extra; their text reply is the application-level result. Repeaters still ACK the legacy plain-text form before processing it.
Repeater remote CLI keeps one volatile copy of the most recently completed
reply, keyed by the authenticated sender, request timestamp, and command text.
Repeating that same logical request re-sends the text reply without executing
the command again. A retry must therefore preserve the original timestamp and
command text. The cache is cleared by reboot and replaced by the next completed
remote command; commands that intentionally produce no text reply remain
silent.
| Field | Size (bytes) | Description |
|----------|--------------|------------------------------------------------------------|
@@ -174,6 +182,13 @@ txt_type
| `0x01` | CLI command | the command text of the message |
| `0x02` | signed plain text message | first four bytes is sender pubkey prefix, followed by plain text message |
For a room post, companion firmware uses its own monotonic clock for the
on-air timestamp and preserves that timestamp across application retries. Room
servers track post timestamps separately from login, request, and CLI traffic.
They also remember recent accepted posts by sender, timestamp, and text: an
exact retry is ACKed again without storing a duplicate, while stale or
same-timestamp mismatches are rejected.
## Anonymous request
| Field | Size (bytes) | Description |
+14
View File
@@ -2021,9 +2021,23 @@ void MyMesh::handleCmdFrame(size_t len) {
result = sendCommandData(*recipient, msg_timestamp, attempt, text, est_timeout);
expected_ack = 0; // no Ack expected
} else {
const uint32_t app_timestamp = msg_timestamp;
const bool is_room_post = recipient->type == ADV_TYPE_ROOM;
if (is_room_post
&& !room_message_timestamps.find(text_fingerprint, app_timestamp,
&msg_timestamp)) {
// Room login/control packets already use the node's monotonic clock.
// Give a new post that same clock source, then preserve the mapping
// so application retries keep one logical server-side timestamp.
msg_timestamp = getRTCClock()->getCurrentTimeUnique();
}
result = sendMessage(*recipient, msg_timestamp, attempt, text, expected_ack, est_timeout,
packet_retry_key,
replacement_entry != NULL ? replacement_entry->retry_key : NULL);
if (result != MSG_SEND_FAILED && is_room_post) {
room_message_timestamps.remember(text_fingerprint, app_timestamp,
msg_timestamp);
}
}
if (result == MSG_SEND_FAILED) {
writeErrFrame(ERR_CODE_TABLE_FULL);
+6
View File
@@ -40,6 +40,7 @@
#include <helpers/ArduinoHelpers.h>
#include <helpers/BaseSerialInterface.h>
#include <helpers/IdentityStore.h>
#include <helpers/LogicalMessageCache.h>
#include <helpers/SimpleMeshTables.h>
#include <helpers/StaticPoolPacketManager.h>
#include <target.h>
@@ -73,6 +74,10 @@
#define OFFLINE_QUEUE_SIZE 16
#endif
#ifndef ROOM_MESSAGE_TIMESTAMP_CACHE_SIZE
#define ROOM_MESSAGE_TIMESTAMP_CACHE_SIZE 16
#endif
#ifndef BLE_NAME_PREFIX
#define BLE_NAME_PREFIX "MeshCore-"
#endif
@@ -324,6 +329,7 @@ private:
};
#define EXPECTED_ACK_TABLE_SIZE 8
AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table
mesh::LogicalMessageCache<ROOM_MESSAGE_TIMESTAMP_CACHE_SIZE> room_message_timestamps;
int next_ack_idx;
unsigned long next_ack_expiry;
bool has_next_ack_expiry;
File diff suppressed because it is too large. Load diff
+89 -37
View File
@@ -73,6 +73,7 @@
#include <helpers/ClientACL.h>
#include <helpers/CommonCLI.h>
#include <helpers/DeferredCliCommand.h>
#include <helpers/RemoteCliReplyCache.h>
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
#include <helpers/UserGpioReplyTracker.h>
#endif
@@ -138,8 +139,22 @@ struct NeighbourInfo {
#define MAX_SCHEDULED_RADIO_SETTINGS (MAX_SCHEDULED_RADIO_SETTINGS_PER_TYPE * 2)
#ifndef MESH_ENABLE_FLOOD_RULE_ENGINE
// STM32WL repeater images have only 240 KB of application flash. They keep
// the established dynamic flood.filter table unless a larger target profile
// explicitly opts into the generalized rule parser and persistence format.
#if defined(STM32_PLATFORM)
#define MESH_ENABLE_FLOOD_RULE_ENGINE 0
#else
#define MESH_ENABLE_FLOOD_RULE_ENGINE 1
#endif
#endif
#ifndef FLOOD_PACKET_FILTER_SLOTS
#define FLOOD_PACKET_FILTER_SLOTS 16
#if MESH_ENABLE_FLOOD_RULE_ENGINE
#define FLOOD_PACKET_FILTER_SLOTS 31
#else
#define FLOOD_PACKET_FILTER_SLOTS 16
#endif
#endif
#define FLOOD_PACKET_FILTER_ANY_TYPE 0xFF
#define FLOOD_PACKET_FILTER_MAX_HOPS 63
@@ -151,6 +166,9 @@ struct NeighbourInfo {
#endif
#define FLOOD_PACKET_FILTER_BLACKLIST_REPLACE_MAX 18
#define FLOOD_PACKET_FILTER_PATH_ID_SIZE 3
#define FLOOD_PACKET_FILTER_PATH_PREFIX_HOPS_MAX 3
#define FLOOD_PACKET_FILTER_PATH_PREFIX_BYTES_MAX \
(FLOOD_PACKET_FILTER_PATH_PREFIX_HOPS_MAX * 3)
#ifndef FLOOD_CHANNEL_SCOPE_SLOTS
#if defined(ESP32)
@@ -164,6 +182,18 @@ struct NeighbourInfo {
#define FLOOD_CHANNEL_SCOPE_TXT_ANY 0
#define FLOOD_CHANNEL_SCOPE_LOGIN_ANY 1
#define FLOOD_CHANNEL_SCOPE_OTHER_ANY 2
#ifndef FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS
// STM32WL repeater images and RAM are both exceptionally tight. Keep one
// reusable regionless target there; other platforms scale with the rule
// table up to the region-map capacity.
#if defined(STM32_PLATFORM)
#define FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS 1
#elif FLOOD_CHANNEL_SCOPE_SLOTS < MAX_REGION_ENTRIES
#define FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS FLOOD_CHANNEL_SCOPE_SLOTS
#else
#define FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS MAX_REGION_ENTRIES
#endif
#endif
#ifndef FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS
#define FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS FLOOD_CHANNEL_SCOPE_SLOTS
#endif
@@ -234,6 +264,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
uint64_t uptime_millis;
unsigned long next_local_advert, next_flood_advert;
mesh::DeferredCliCommand deferred_cli_command;
mesh::RemoteCliReplyCache remote_cli_reply_cache;
TransportKey deferred_cli_reply_scope;
bool deferred_cli_reply_scoped;
uint32_t pending_self_advert_delay;
@@ -259,7 +290,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
RegionMap region_map, temp_map;
RegionEntry* load_stack[8];
RegionEntry* recv_pkt_region;
bool recv_pkt_filter_scope_set;
bool recv_pkt_regionless_scope_set;
bool recv_pkt_channel_scope_bypass;
bool recv_pkt_channel_scope_rejected;
TransportKey default_scope;
@@ -277,14 +308,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
uint8_t prefix_len;
uint32_t last_heard_millis;
};
struct FloodChannelBlockEntry {
bool active;
uint8_t key_len;
uint8_t max_hops;
uint8_t hash_prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN];
uint8_t secret[PUB_KEY_SIZE];
char name[FLOOD_CHANNEL_BLOCK_NAME_LEN];
};
struct FloodPacketFilterEntry {
bool active;
uint8_t payload_type;
@@ -293,12 +316,37 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
bool suspend_on_temp_radio;
char scope_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN];
bool match_blacklisted_path;
#if !MESH_ENABLE_FLOOD_RULE_ENGINE
bool scope_requires_region_match;
#endif
bool scope_uses_slow_timing;
#if MESH_ENABLE_FLOOD_RULE_ENGINE
uint8_t incoming_scope_kind;
char incoming_scope_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN];
uint8_t channel_key_len;
uint8_t channel_hash;
uint8_t channel_secret[PUB_KEY_SIZE];
char channel_name[FLOOD_GROUP_MODERATION_NAME_LEN];
uint8_t path_hash_size;
uint8_t path_hops;
uint8_t path[FLOOD_PACKET_FILTER_PATH_PREFIX_BYTES_MAX];
char target_region_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN];
bool drop_on_match;
bool rate_limit_enabled;
uint16_t rate_per_minute;
uint8_t priority;
bool stop_on_match;
uint32_t rate_window_started;
uint16_t rate_window_count;
bool rate_window_active;
#endif
};
struct FloodChannelScopeEntry {
uint16_t region_id; // zero means unused
// Low bits select txt:*/login:*/other:* or an exact channel-key length.
// Zero means unused. Otherwise this is either a region ID or a one-based
// flood_channel_direct_scopes index, as selected by selector.
uint16_t target_id;
// Encodes txt:*/login:*/other:* or an exact key length, target kind,
// optional path selector, and fast/slow timing.
uint8_t selector;
uint8_t channel_hash;
uint8_t secret[PUB_KEY_SIZE];
@@ -311,7 +359,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
struct FloodGroupModerationEntry {
bool active;
uint8_t key_len;
uint8_t hash_prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN];
uint8_t hash_prefix[FLOOD_CHANNEL_KEY_PREFIX_LEN];
uint8_t secret[PUB_KEY_SIZE];
char channel_name[FLOOD_GROUP_MODERATION_NAME_LEN];
char sender[FLOOD_GROUP_MODERATION_NAME_LEN];
@@ -334,15 +382,17 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
};
mutable FloodRetryBridgeState flood_retry_bridge_states[MAX_FLOOD_RETRY_SLOTS];
FloodRetryBridgeReachability flood_retry_bridge_reachability[FLOOD_RETRY_BRIDGE_BUCKETS + 1];
FloodChannelBlockEntry flood_channel_blocks[FLOOD_CHANNEL_BLOCK_SLOTS];
FloodPacketFilterEntry flood_packet_filters[FLOOD_PACKET_FILTER_SLOTS];
uint8_t flood_packet_filter_blacklist_count;
uint8_t flood_packet_filter_blacklist[FLOOD_PACKET_FILTER_BLACKLIST_MAX]
[FLOOD_PACKET_FILTER_PATH_ID_SIZE];
FloodChannelScopeEntry flood_channel_scopes[FLOOD_CHANNEL_SCOPE_SLOTS];
char flood_channel_direct_scopes[FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS]
[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN];
FloodChannelScopeRequireEntry
flood_channel_scope_requirements[FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS];
FloodGroupModerationEntry flood_group_moderation[FLOOD_GROUP_MODERATION_SLOTS];
uint32_t recv_pkt_filter_match_mask;
ClockSyncSample clock_sync_samples[CLOCK_SYNC_SAMPLE_SLOTS];
bool clock_sync_mesh_enabled;
bool clock_sync_mesh_edge_enabled;
@@ -530,32 +580,40 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
void refreshScheduledRadioState();
void processScheduledRadioSettings();
bool isMillisTimerDue(unsigned long timestamp) const;
void loadFloodChannelBlocks();
bool saveFloodChannelBlocks();
void seedDefaultFloodChannelBlocks();
void clearFloodChannelBlockEntry(FloodChannelBlockEntry& entry);
void deriveFloodChannelBlockPrefix(const uint8_t* secret, uint8_t key_len,
uint8_t prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]) const;
uint8_t resolveFloodChannelBlockHops(uint8_t max_hops) const;
bool floodChannelBlockHopApplies(const mesh::Packet* packet, uint8_t max_hops) const;
bool floodChannelDataHopApplies(const mesh::Packet* packet) const;
bool floodChannelBlockMatches(const FloodChannelBlockEntry& entry, const mesh::Packet* packet) const;
bool shouldBlockFloodChannelForward(const mesh::Packet* packet) const;
void loadFloodPacketFilters();
bool loadFloodPacketFilters();
bool saveFloodPacketFilters();
#if MESH_ENABLE_FLOOD_RULE_ENGINE
void migrateLegacyFloodChannelBlocks();
#endif
void loadFloodPacketFilterBlacklist();
bool saveFloodPacketFilterBlacklist();
void seedDefaultFloodPacketFilters();
bool floodPacketFilterBlacklistMatches(const mesh::Packet* packet) const;
bool floodPacketFilterMatches(const FloodPacketFilterEntry& entry,
const mesh::Packet* packet) const;
bool applyFloodPacketFilterScope(mesh::Packet* packet, bool incoming_region_allowed,
bool floodPacketFilterFieldsMatch(const FloodPacketFilterEntry& entry,
const mesh::Packet* packet,
bool incoming_is_scoped,
uint16_t incoming_transport_code,
bool incoming_region_allowed,
const RegionEntry* incoming_region) const;
bool authenticateFloodPacketFilterChannel(
const FloodPacketFilterEntry& entry,
const mesh::Packet* packet) const;
int nextFloodPacketFilterMatch(uint32_t match_mask,
uint32_t visited_mask) const;
uint32_t applyFloodPacketFilterStop(uint32_t match_mask) const;
uint32_t evaluateFloodPacketFilterMatches(
const mesh::Packet* packet, bool incoming_region_allowed,
const RegionEntry* incoming_region) const;
bool applyFloodPacketFilterScope(mesh::Packet* packet, uint32_t match_mask,
bool& scope_set, bool& fast_track,
bool log_change = true);
bool shouldBlockFloodPacketForward(const mesh::Packet* packet) const;
void commitFloodPacketFilterRates(const mesh::Packet* packet);
void formatFloodPacketFilters(const char* args, char* reply) const;
void formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len) const;
void setFloodPacketFilter(const char* args, char* reply);
void setFloodPacketFilter(const char* args, char* reply,
bool require_explicit_action = false);
void deleteFloodPacketFilter(const char* args, char* reply);
void formatFloodPacketFilterBlacklist(const char* args, char* reply) const;
void setFloodPacketFilterBlacklist(const char* args, char* reply);
@@ -564,8 +622,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
bool saveFloodChannelScopes(bool empty_table = false);
bool applyFloodChannelScopeTarget(mesh::Packet* packet, const FloodChannelScopeEntry& entry,
bool& scope_changed, bool& fast_track,
bool& regionless_scope_set,
bool log_change = true);
bool applyFloodChannelScope(mesh::Packet* packet, bool& fast_track,
bool& regionless_scope_set,
bool log_change = true);
static uint8_t scoreFloodTransportScope(const mesh::Packet* packet, void* context);
uint8_t getFloodTransportScopeDepth(const mesh::Packet* packet);
@@ -609,9 +669,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
void formatClockSyncStatus(const char* args, char* reply, size_t reply_len) const;
void formatClockSyncTable(char* reply, size_t reply_len) const;
void formatClockSyncSampleDetail(int index, char* reply, size_t reply_len) const;
int findFloodChannelBlockBySelector(const char* selector) const;
int findFloodChannelBlockSlot(const uint8_t prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN], const char* name) const;
void formatFloodChannelBlockDetail(char* reply, int idx) const;
bool hasScheduledRadioWorkDue() const;
uint32_t limitSleepToMillisTimer(unsigned long timestamp, uint32_t sleep_secs) const;
uint32_t limitSleepToScheduledRadioWork(uint32_t sleep_secs) const;
@@ -791,11 +848,6 @@ public:
void startRegionsLoad() override;
bool saveRegions() override;
void onDefaultRegionChanged(const RegionEntry* r) override;
void setFloodChannelBlock(int index, const uint8_t* secret, uint8_t key_len,
const char* name, uint8_t max_hops, char* reply) override;
void formatFloodChannelBlocks(const char* selector, char* reply) override;
void deleteFloodChannelBlock(const char* selector, char* reply) override;
mesh::LocalIdentity& getSelfId() override { return self_id; }
void saveIdentity(const mesh::LocalIdentity& new_id) override;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,91 @@
#pragma once
#if defined(MESHCORE_ESP32_FULL_PROFILE)
#include <Arduino.h>
#include <Mesh.h>
#include <helpers/IdentityStore.h>
#include <helpers/RegionMap.h>
class FloodRuleEngine {
public:
static constexpr uint8_t RULE_SLOTS = 31;
static constexpr uint8_t ANY_TYPE = 0xFF;
static constexpr uint8_t MAX_HOPS = 63;
static constexpr uint8_t NAME_LEN = 32;
static constexpr uint8_t PATH_PREFIX_HOPS_MAX = 3;
static constexpr uint8_t PATH_PREFIX_BYTES_MAX = 9;
static constexpr uint16_t RATE_UNLIMITED = 0xFFFF;
FloodRuleEngine();
void begin(FILESYSTEM* fs, RegionMap* regions);
uint32_t evaluate(const mesh::Packet* packet, bool temp_radio_active,
bool incoming_region_allowed,
const RegionEntry* incoming_region) const;
bool applyScope(mesh::Packet* packet, uint32_t match_mask,
bool& scope_set, bool& fast_track,
bool log_change = true);
bool shouldBlock(const mesh::Packet* packet, uint32_t match_mask,
uint32_t now_millis) const;
void commitRates(const mesh::Packet* packet, uint32_t match_mask,
uint32_t now_millis);
bool handleCommand(const char* command, char* reply);
private:
struct Entry {
bool active;
uint8_t payload_type;
uint8_t min_hops;
uint8_t max_hops;
bool suspend_on_temp_radio;
char scope_name[NAME_LEN];
bool match_blacklisted_path;
bool scope_uses_slow_timing;
uint8_t incoming_scope_kind;
char incoming_scope_name[NAME_LEN];
uint8_t channel_key_len;
uint8_t channel_hash;
uint8_t channel_secret[PUB_KEY_SIZE];
char channel_name[NAME_LEN];
uint8_t path_hash_size;
uint8_t path_hops;
uint8_t path[PATH_PREFIX_BYTES_MAX];
char target_region_name[NAME_LEN];
bool drop_on_match;
bool rate_limit_enabled;
uint16_t rate_per_minute;
uint8_t priority;
bool stop_on_match;
uint32_t rate_window_started;
uint16_t rate_window_count;
bool rate_window_active;
};
FILESYSTEM* _fs;
RegionMap* _regions;
Entry _entries[RULE_SLOTS];
void seedDefaults();
void load();
bool save();
bool fieldsMatch(const Entry& entry, const mesh::Packet* packet,
bool temp_radio_active, bool incoming_is_scoped,
uint16_t incoming_transport_code,
bool incoming_region_allowed,
const RegionEntry* incoming_region) const;
bool authenticateChannel(const Entry& entry,
const mesh::Packet* packet) const;
int nextMatch(uint32_t match_mask, uint32_t visited_mask) const;
uint32_t applyStop(uint32_t match_mask) const;
void format(const char* args, char* reply) const;
void formatDetail(int index, char* reply, size_t reply_len) const;
void set(const char* args, char* reply,
bool require_explicit_action = false);
void remove(const char* args, char* reply);
};
#endif
+245 -80
View File
@@ -1,5 +1,8 @@
#include "MyMesh.h"
#include <helpers/CLICommandUtils.h>
#if defined(MESHCORE_ESP32_FULL_PROFILE)
#include <helpers/FloodFilterPolicy.h>
#endif
#include <helpers/radiolib/RXPowerSaving.h>
#include <algorithm>
#include <helpers/RxReservePacketManager.h>
@@ -317,6 +320,69 @@ int MyMesh::calcRxDelay(float score, uint32_t air_time) const {
return (int)((powf(_prefs.rx_delay_base, 0.85f - score) - 1.0f) * air_time);
}
#if defined(MESHCORE_ESP32_FULL_PROFILE)
bool MyMesh::evaluateFloodRuleTiming(const mesh::Packet* packet,
bool& fast_track) {
fast_track = false;
if (packet == NULL || !packet->isRouteFlood()) return false;
bool incoming_region_allowed = false;
RegionEntry* incoming_region = NULL;
if (packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) {
incoming_region = region_map.findMatch(packet, REGION_DENY_FLOOD);
incoming_region_allowed = incoming_region != NULL;
} else if (packet->getRouteType() == ROUTE_TYPE_FLOOD) {
incoming_region_allowed =
(region_map.getWildcard().flags & REGION_DENY_FLOOD) == 0;
}
mesh::Packet candidate = *packet;
uint32_t match_mask = flood_rules.evaluate(
packet, isTempRadioActive(), incoming_region_allowed, incoming_region);
bool scope_set = false;
return flood_rules.applyScope(&candidate, match_mask, scope_set,
fast_track, false);
}
static const uint8_t ROOM_MAX_LOOP_MINIMAL[] = { 0, 4, 2, 1 };
static const uint8_t ROOM_MAX_LOOP_MODERATE[] = { 0, 2, 1, 1 };
static const uint8_t ROOM_MAX_LOOP_STRICT[] = { 0, 1, 1, 1 };
bool MyMesh::isLooped(const mesh::Packet* packet,
const uint8_t max_counters[]) const {
uint8_t hash_size = packet->getPathHashSize();
uint8_t hash_count = packet->getPathHashCount();
uint8_t occurrences = 0;
const uint8_t* path = packet->path;
while (hash_count > 0) {
if (self_id.isHashMatch(path, hash_size)) occurrences++;
hash_count--;
path += hash_size;
}
return occurrences >= max_counters[hash_size];
}
int MyMesh::calcRxDelayForPacket(const mesh::Packet* packet, float score,
uint32_t air_time) {
bool fast_track = false;
if (!evaluateFloodRuleTiming(packet, fast_track)) {
return calcRxDelay(score, air_time);
}
if (fast_track) return 0;
float slow_base =
FloodFilterPolicy::slowScopeRxDelayBase(_prefs.rx_delay_base);
return (int)((powf(slow_base, 0.85f - score) - 1.0f) * air_time);
}
uint32_t MyMesh::getSlowFloodRuleRetransmitDelay(
const mesh::Packet* packet) {
uint32_t airtime = _radio->getEstAirtimeFor(
packet->getPathByteLen() + packet->payload_len + 2);
uint32_t max_delay = FloodFilterPolicy::slowScopeMaxDelay(airtime);
return getRNG()->nextInt(0, max_delay + 1);
}
#endif
const char *MyMesh::getLogDateTime() {
static char tmp[32];
uint32_t now = getRTCClock()->getCurrentTime();
@@ -409,11 +475,62 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) {
if (packet->getPathHashCount() >= _prefs.flood_max) return false;
if (packet->getRouteType() == ROUTE_TYPE_FLOOD && packet->getPathHashCount() >= _prefs.flood_max_unscoped) return false;
if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && packet->getPathHashCount() >= _prefs.flood_max_advert) return false;
#if defined(MESHCORE_ESP32_FULL_PROFILE)
if (flood_rules.shouldBlock(packet, recv_pkt_rule_match_mask,
_ms->getMillis())) {
return false;
}
#endif
}
#if defined(MESHCORE_ESP32_FULL_PROFILE)
if (packet->isRouteFlood()) {
if (recv_pkt_region == NULL && !recv_pkt_regionless_scope_set) {
MESH_DEBUG_PRINTLN(
"allowPacketForward: unknown transport code, or wildcard not allowed for FLOOD packet");
return false;
}
if (_prefs.loop_detect != LOOP_DETECT_OFF) {
const uint8_t* maximums = _prefs.loop_detect == LOOP_DETECT_MINIMAL
? ROOM_MAX_LOOP_MINIMAL
: (_prefs.loop_detect == LOOP_DETECT_MODERATE
? ROOM_MAX_LOOP_MODERATE : ROOM_MAX_LOOP_STRICT);
if (isLooped(packet, maximums)) {
MESH_DEBUG_PRINTLN(
"allowPacketForward: FLOOD packet loop detected!");
return false;
}
}
flood_rules.commitRates(packet, recv_pkt_rule_match_mask,
_ms->getMillis());
}
#endif
return true;
}
mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) {
#if defined(MESHCORE_ESP32_FULL_PROFILE)
bool scope_changed = false;
bool fast_track_scope_change = false;
recv_pkt_regionless_scope_set = false;
recv_pkt_rule_match_mask = 0;
if (pkt->isRouteFlood()) {
bool incoming_region_allowed = false;
RegionEntry* incoming_region = NULL;
if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) {
incoming_region = region_map.findMatch(pkt, REGION_DENY_FLOOD);
incoming_region_allowed = incoming_region != NULL;
} else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) {
incoming_region_allowed =
(region_map.getWildcard().flags & REGION_DENY_FLOOD) == 0;
}
recv_pkt_rule_match_mask = flood_rules.evaluate(
pkt, isTempRadioActive(), incoming_region_allowed, incoming_region);
bool scope_set = false;
scope_changed = flood_rules.applyScope(
pkt, recv_pkt_rule_match_mask, scope_set, fast_track_scope_change);
recv_pkt_regionless_scope_set = scope_set;
}
#endif
if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) {
recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD);
} else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) {
@@ -425,7 +542,20 @@ mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) {
} else {
recv_pkt_region = NULL;
}
return Mesh::onRecvPacket(pkt);
mesh::DispatcherAction action = Mesh::onRecvPacket(pkt);
#if defined(MESHCORE_ESP32_FULL_PROFILE)
if (scope_changed && action != ACTION_RELEASE
&& action != ACTION_MANUAL_HOLD) {
if (fast_track_scope_change) {
action = ACTION_RETRANSMIT(0);
} else {
uint8_t priority = (action >> 24) - 1;
action = ACTION_RETRANSMIT_DELAYED(
priority, getSlowFloodRuleRetransmitDelay(pkt));
}
}
#endif
return action;
}
void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const mesh::Identity &sender,
@@ -602,96 +732,118 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
if (!(flags == TXT_TYPE_PLAIN || flags == TXT_TYPE_CLI_DATA)) {
MESH_DEBUG_PRINTLN("onPeerDataRecv: unsupported command flags received: flags=%02x", (uint32_t)flags);
} else if (sender_timestamp >= client->last_timestamp) { // prevent replay attacks, but send Acks for retries
bool is_retry = (sender_timestamp == client->last_timestamp);
client->last_timestamp = sender_timestamp;
return;
}
uint32_t now = getRTCClock()->getCurrentTimeUnique();
client->last_activity = now;
client->extra.room.push_failures = 0; // reset so push can resume (if prev failed)
// len can be > original length, but 'text' will be padded with zeroes
data[len] = 0; // need to make a C string again, with null terminator
const char* text = (const char*)&data[5];
const size_t text_len = strlen(text);
// len can be > original length, but 'text' will be padded with zeroes
data[len] = 0; // need to make a C string again, with null terminator
uint8_t temp[166];
temp[5] = 0;
bool send_ack = false;
uint32_t ack_hash; // calc truncated hash of the message timestamp + text + sender pub_key, to prove to
// sender that we got it
mesh::Utils::sha256((uint8_t *)&ack_hash, 4, data, 5 + strlen((char *)&data[5]), client->id.pub_key,
PUB_KEY_SIZE);
uint8_t temp[166];
bool send_ack;
if (flags == TXT_TYPE_CLI_DATA) {
if (client->isAdmin()) {
if (is_retry) {
temp[5] = 0; // no reply
} else {
handleCommand(sender_timestamp, (char *)&data[5], (char *)&temp[5],
i, packet->getPathHashSize());
temp[4] = (TXT_TYPE_CLI_DATA << 2); // attempt and flags, (NOTE: legacy was: TXT_TYPE_PLAIN)
}
send_ack = false;
} else {
temp[5] = 0; // no reply
send_ack = false; // and no ACK... user shoudn't be sending these
}
} else { // TXT_TYPE_PLAIN
if ((client->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) {
temp[5] = 0; // no reply
send_ack = false; // no ACK
} else {
if (!is_retry) {
addPost(client, (const char *)&data[5]);
}
temp[5] = 0; // no reply (ACK is enough)
send_ack = true;
}
}
uint32_t delay_millis;
if (send_ack) {
if (client->out_path_len == OUT_PATH_UNKNOWN) {
mesh::Packet *ack = createAck(ack_hash);
if (ack) sendFloodReply(ack, TXT_ACK_DELAY, packet->getPathHashSize());
delay_millis = TXT_ACK_DELAY + REPLY_DELAY_MILLIS;
} else {
uint32_t d = TXT_ACK_DELAY;
if (getExtraAckTransmitCount() > 0) {
mesh::Packet *a1 = createMultiAck(ack_hash, 1);
if (a1) sendDirect(a1, client->out_path, client->out_path_len, d);
d += 300;
}
mesh::Packet *a2 = createAck(ack_hash);
if (a2) sendDirect(a2, client->out_path, client->out_path_len, d);
delay_millis = d + REPLY_DELAY_MILLIS;
if (flags == TXT_TYPE_PLAIN) {
const bool is_guest =
(client->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST;
if (is_guest) {
if (sender_timestamp < client->extra.room.last_post_timestamp) {
MESH_DEBUG_PRINTLN("onPeerDataRecv: stale room post detected");
return;
}
client->extra.room.last_post_timestamp = sender_timestamp;
} else {
delay_millis = 0;
uint8_t message_fingerprint[MAX_HASH_SIZE];
mesh::Utils::sha256(message_fingerprint, sizeof(message_fingerprint),
client->id.pub_key, PUB_KEY_SIZE,
(const uint8_t*)text, text_len);
const auto replay_decision = recent_room_posts.classifyAndRemember(
message_fingerprint, sender_timestamp,
client->extra.room.last_post_timestamp);
using ReplayDecision =
mesh::LogicalMessageCache<ROOM_MESSAGE_CACHE_SIZE>::ReplayDecision;
if (replay_decision == ReplayDecision::StaleOrMismatched) {
MESH_DEBUG_PRINTLN("onPeerDataRecv: stale or mismatched room post detected");
return;
}
if (replay_decision == ReplayDecision::NewMessage) {
addPost(client, text);
}
// Exact retries are ACKed again regardless of whether newer posts have
// advanced this client's room-post replay timestamp.
send_ack = true;
}
} else { // TXT_TYPE_CLI_DATA
if (sender_timestamp < client->last_timestamp) {
MESH_DEBUG_PRINTLN("onPeerDataRecv: possible replay attack detected");
return;
}
int text_len = strlen((char *)&temp[5]);
if (text_len > 0) {
if (now == sender_timestamp) {
// WORKAROUND: the two timestamps need to be different, in the CLI view
now++;
}
memcpy(temp, &now, 4); // mostly an extra blob to help make packet_hash unique
const bool is_retry = sender_timestamp == client->last_timestamp;
client->last_timestamp = sender_timestamp;
if (client->isAdmin() && !is_retry) {
handleCommand(sender_timestamp, (char*)text, (char*)&temp[5],
i, packet->getPathHashSize());
temp[4] = (TXT_TYPE_CLI_DATA << 2); // attempt and flags, (NOTE: legacy was: TXT_TYPE_PLAIN)
}
// CLI_DATA replies are the result signal; no separate ACK is expected.
}
// calc expected ACK reply
// mesh::Utils::sha256((uint8_t *)&expected_ack_crc, 4, temp, 5 + text_len, self_id.pub_key,
// PUB_KEY_SIZE);
uint32_t now = getRTCClock()->getCurrentTimeUnique();
client->last_activity = now;
client->extra.room.push_failures = 0; // reset so push can resume (if prev failed)
auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len);
if (reply) {
if (client->out_path_len == OUT_PATH_UNKNOWN) {
sendFloodReply(reply, delay_millis + SERVER_RESPONSE_DELAY, packet->getPathHashSize());
} else {
sendDirect(reply, client->out_path, client->out_path_len, delay_millis + SERVER_RESPONSE_DELAY);
}
uint32_t delay_millis;
if (send_ack) {
uint32_t ack_hash; // prove receipt of this timestamp, attempt, text, and sender
mesh::Utils::sha256((uint8_t*)&ack_hash, 4, data, 5 + text_len,
client->id.pub_key, PUB_KEY_SIZE);
if (client->out_path_len == OUT_PATH_UNKNOWN) {
mesh::Packet *ack = createAck(ack_hash);
if (ack) sendFloodReply(ack, TXT_ACK_DELAY, packet->getPathHashSize());
delay_millis = TXT_ACK_DELAY + REPLY_DELAY_MILLIS;
} else {
uint32_t d = TXT_ACK_DELAY;
if (getExtraAckTransmitCount() > 0) {
mesh::Packet *a1 = createMultiAck(ack_hash, 1);
if (a1) sendDirect(a1, client->out_path, client->out_path_len, d);
d += 300;
}
mesh::Packet *a2 = createAck(ack_hash);
if (a2) sendDirect(a2, client->out_path, client->out_path_len, d);
delay_millis = d + REPLY_DELAY_MILLIS;
}
} else {
MESH_DEBUG_PRINTLN("onPeerDataRecv: possible replay attack detected");
delay_millis = 0;
}
int reply_text_len = strlen((char*)&temp[5]);
if (reply_text_len > 0) {
if (now == sender_timestamp) {
// WORKAROUND: the two timestamps need to be different, in the CLI view
now++;
}
memcpy(temp, &now, 4); // mostly an extra blob to help make packet_hash unique
// calc expected ACK reply
// mesh::Utils::sha256((uint8_t *)&expected_ack_crc, 4, temp, 5 + reply_text_len, self_id.pub_key,
// PUB_KEY_SIZE);
auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret,
temp, 5 + reply_text_len);
if (reply) {
if (client->out_path_len == OUT_PATH_UNKNOWN) {
sendFloodReply(reply, delay_millis + SERVER_RESPONSE_DELAY,
packet->getPathHashSize());
} else {
sendDirect(reply, client->out_path, client->out_path_len,
delay_millis + SERVER_RESPONSE_DELAY);
}
}
}
} else if (type == PAYLOAD_TYPE_REQ && len >= 5) {
uint32_t sender_timestamp;
@@ -909,6 +1061,10 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
radio_apply_retry_at = 0;
radio_apply_failures = 0;
recv_pkt_region = NULL;
#if defined(MESHCORE_ESP32_FULL_PROFILE)
recv_pkt_rule_match_mask = 0;
recv_pkt_regionless_scope_set = false;
#endif
// defaults
memset(&_prefs, 0, sizeof(_prefs));
@@ -998,6 +1154,9 @@ void MyMesh::begin(FILESYSTEM *fs) {
acl.load(_fs, self_id);
region_map.load(_fs);
#if defined(MESHCORE_ESP32_FULL_PROFILE)
flood_rules.begin(_fs, &region_map);
#endif
// establish default-scope
{
@@ -1818,7 +1977,13 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
addSystemPost(msg);
snprintf(reply, MAX_POST_TEXT_LEN, "OK");
}
} else{
}
#if defined(MESHCORE_ESP32_FULL_PROFILE)
else if (flood_rules.handleCommand(command, reply)) {
// handled by the FULL-profile persistent flood rule engine
}
#endif
else {
_cli.handleCommand(sender_timestamp, command, reply); // common CLI commands
}
}
+24
View File
@@ -24,7 +24,9 @@
#endif
#include <helpers/StatsFormatHelper.h>
#include <helpers/ClientACL.h>
#include <helpers/LogicalMessageCache.h>
#include <helpers/RegionMap.h>
#include "FloodRuleEngine.h"
#include <RTClib.h>
#include <target.h>
@@ -87,6 +89,10 @@
#define MAX_UNSYNCED_POSTS 32
#endif
#ifndef ROOM_MESSAGE_CACHE_SIZE
#define ROOM_MESSAGE_CACHE_SIZE 32
#endif
#ifndef SERVER_RESPONSE_DELAY
#define SERVER_RESPONSE_DELAY 300
#endif
@@ -128,7 +134,13 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
NodePrefs _prefs;
TransportKeyStore key_store;
RegionMap region_map, temp_map;
#if defined(MESHCORE_ESP32_FULL_PROFILE)
FloodRuleEngine flood_rules;
uint32_t recv_pkt_rule_match_mask;
bool recv_pkt_regionless_scope_set;
#endif
ClientACL acl;
mesh::LogicalMessageCache<ROOM_MESSAGE_CACHE_SIZE> recent_room_posts;
CommonCLI _cli;
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
UserGpioReplyTracker _gpio_reply_tracker;
@@ -218,6 +230,14 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
mesh::Packet* createSelfAdvert();
File openAppend(const char* fname);
int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len);
#if defined(MESHCORE_ESP32_FULL_PROFILE)
bool evaluateFloodRuleTiming(const mesh::Packet* packet,
bool& fast_track);
bool isLooped(const mesh::Packet* packet,
const uint8_t max_counters[]) const;
uint32_t getSlowFloodRuleRetransmitDelay(
const mesh::Packet* packet);
#endif
protected:
bool isTempRadioActive() const override {
@@ -233,6 +253,10 @@ protected:
void logTxFail(mesh::Packet* pkt, int len) override;
int calcRxDelay(float score, uint32_t air_time) const override;
#if defined(MESHCORE_ESP32_FULL_PROFILE)
int calcRxDelayForPacket(const mesh::Packet* packet, float score,
uint32_t air_time) override;
#endif
const char* getLogDateTime() override;
uint32_t getRetransmitDelay(const mesh::Packet* packet) override;
uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override;
+1
View File
@@ -28,6 +28,7 @@ struct ClientInfo {
union {
struct {
uint32_t sync_since; // sync messages SINCE this timestamp (by OUR clock)
uint32_t last_post_timestamp; // sender timestamp for room posts only (transient)
uint32_t pending_ack;
uint32_t push_post_timestamp;
unsigned long ack_timeout;
+21 -255
View File
@@ -497,92 +497,20 @@ static void formatFloodRetryPathGate(char* dest, uint8_t path_gate) {
}
}
static bool parseFloodChannelBlockHops(const char* value, uint8_t& max_hops) {
static bool parseFloodChannelHops(const char* value, uint8_t& max_hops) {
if (value == NULL) {
return false;
}
value = skipSpacesConst(value);
if (strcmp(value, "all") == 0) {
max_hops = FLOOD_CHANNEL_BLOCK_HOPS_ALL;
max_hops = FLOOD_CHANNEL_HOPS_ALL;
return true;
}
return parseUint8Strict(value, 1, 7, max_hops);
}
static bool parseFloodChannelBlockRowHops(const char* value, uint8_t& max_hops) {
if (value == NULL) {
return false;
}
value = skipSpacesConst(value);
if (strcmp(value, "default") == 0 || strcmp(value, "def") == 0 || strcmp(value, "inherit") == 0) {
max_hops = FLOOD_CHANNEL_BLOCK_HOPS_INHERIT;
return true;
}
return parseFloodChannelBlockHops(value, max_hops);
}
static bool parseFloodChannelBlockHopAssignment(const char* text, bool allow_bare, uint8_t& max_hops) {
char token[16];
text = skipSpacesConst(text);
if (text == NULL || *text == 0) {
return false;
}
size_t len = 0;
while (text[len] && text[len] != ' ' && len + 1 < sizeof(token)) {
token[len] = text[len];
len++;
}
token[len] = 0;
const char* value = NULL;
if (strncmp(token, "h=", 2) == 0) {
value = token + 2;
} else if (strncmp(token, "hops=", 5) == 0) {
value = token + 5;
} else if (allow_bare) {
value = token;
} else {
return false;
}
return parseFloodChannelBlockRowHops(value, max_hops);
}
static bool looksFloodChannelBlockHopAssignment(const char* text) {
text = skipSpacesConst(text);
if (text == NULL || *text == 0) {
return false;
}
return (*text >= '0' && *text <= '9')
|| strncmp(text, "h=", 2) == 0
|| strncmp(text, "hops=", 5) == 0
|| strncmp(text, "all", 3) == 0
|| strncmp(text, "def", 3) == 0
|| strncmp(text, "default", 7) == 0
|| strncmp(text, "inherit", 7) == 0;
}
static bool trimFloodChannelBlockHopSuffix(char* name, uint8_t& max_hops) {
size_t len = strlen(name);
while (len > 0 && name[len - 1] == ' ') {
name[--len] = 0;
}
char* token = strrchr(name, ' ');
if (token == NULL) {
return true;
}
if (strncmp(token + 1, "h=", 2) != 0 && strncmp(token + 1, "hops=", 5) != 0) {
return true;
}
if (!parseFloodChannelBlockHopAssignment(token + 1, false, max_hops)) {
return false;
}
*token = 0;
return strlen(name) > 0;
}
static void formatFloodChannelBlockHops(char* dest, uint8_t max_hops) {
if (max_hops == FLOOD_CHANNEL_BLOCK_HOPS_ALL) {
static void formatFloodChannelHops(char* dest, uint8_t max_hops) {
if (max_hops == FLOOD_CHANNEL_HOPS_ALL) {
strcpy(dest, "h=all");
} else {
sprintf(dest, "h>%u", (unsigned int)max_hops);
@@ -699,69 +627,6 @@ static bool formatFloodRetryBucketCollisionWarning(char* reply, const NodePrefs*
return true;
}
static bool parseFloodChannelBlockKey(const char* text, uint8_t secret[PUB_KEY_SIZE], uint8_t& key_len) {
if (text == NULL || text[0] == 0) {
return false;
}
memset(secret, 0, PUB_KEY_SIZE);
if (text[0] == '#') {
if (!isValidName(text)) {
return false;
}
mesh::Utils::sha256(secret, CIPHER_KEY_SIZE, (const uint8_t*)text, strlen(text));
key_len = CIPHER_KEY_SIZE;
return true;
}
size_t hex_len = strlen(text);
if (!(hex_len == CIPHER_KEY_SIZE * 2 || hex_len == PUB_KEY_SIZE * 2)) {
return false;
}
for (size_t i = 0; i < hex_len; i++) {
if (!mesh::Utils::isHexChar(text[i])) {
return false;
}
}
key_len = (uint8_t)(hex_len / 2);
return mesh::Utils::fromHex(secret, key_len, text);
}
static bool parseFloodChannelBlockDotIndex(const char*& cursor, int& index) {
if (*cursor != '.') {
index = 0;
return true;
}
cursor++;
if (*cursor < '0' || *cursor > '9') {
return false;
}
int value = 0;
while (*cursor >= '0' && *cursor <= '9') {
value = (value * 10) + (*cursor - '0');
if (value > FLOOD_CHANNEL_BLOCK_SLOTS) {
return false;
}
cursor++;
}
if (value < 1) {
return false;
}
index = value;
return true;
}
static void copyTrimmedFloodChannelBlockName(char* dest, size_t dest_len, const char* src) {
src = skipSpacesConst(src);
StrHelper::strncpy(dest, src, dest_len);
size_t len = strlen(dest);
while (len > 0 && dest[len - 1] == ' ') {
dest[--len] = 0;
}
}
static void applyDirectRetryPreset(NodePrefs* prefs, uint8_t preset) {
prefs->retry_preset = preset;
if (preset == RETRY_PRESET_INFRA) {
@@ -1208,8 +1073,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
memset(_prefs->battery_alert_region, 0, sizeof(_prefs->battery_alert_region));
_prefs->direct_retry_recent_enabled = DIRECT_RETRY_RECENT_DEFAULT;
_prefs->flood_channel_data_enabled = 1;
_prefs->flood_channel_block_max_hops = FLOOD_CHANNEL_BLOCK_HOPS_ALL;
_prefs->flood_channel_data_max_hops = FLOOD_CHANNEL_BLOCK_HOPS_ALL;
_prefs->legacy_flood_channel_block_max_hops = FLOOD_CHANNEL_HOPS_ALL;
_prefs->flood_channel_data_max_hops = FLOOD_CHANNEL_HOPS_ALL;
has_flood_retry_prefs = file.available() >= 2;
if (has_flood_retry_prefs) {
file.read((uint8_t *)&_prefs->flood_retry_attempts, sizeof(_prefs->flood_retry_attempts)); // 311
@@ -1244,8 +1109,9 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
if (file.available() >= (int)sizeof(_prefs->flood_channel_data_enabled)) {
file.read((uint8_t *)&_prefs->flood_channel_data_enabled, sizeof(_prefs->flood_channel_data_enabled));
}
if (file.available() >= (int)sizeof(_prefs->flood_channel_block_max_hops)) {
file.read((uint8_t *)&_prefs->flood_channel_block_max_hops, sizeof(_prefs->flood_channel_block_max_hops));
if (file.available() >= (int)sizeof(_prefs->legacy_flood_channel_block_max_hops)) {
file.read((uint8_t *)&_prefs->legacy_flood_channel_block_max_hops,
sizeof(_prefs->legacy_flood_channel_block_max_hops));
}
if (file.available() >= (int)sizeof(_prefs->flood_channel_data_max_hops)) {
file.read((uint8_t *)&_prefs->flood_channel_data_max_hops, sizeof(_prefs->flood_channel_data_max_hops));
@@ -1431,13 +1297,14 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
_prefs->direct_retry_recent_enabled = constrain(_prefs->direct_retry_recent_enabled, 0, 1);
_prefs->flood_channel_data_enabled = constrain(_prefs->flood_channel_data_enabled, 0, 1);
_prefs->telemetry_access = constrain(_prefs->telemetry_access, 0, 1);
if (_prefs->flood_channel_block_max_hops != FLOOD_CHANNEL_BLOCK_HOPS_ALL
&& (_prefs->flood_channel_block_max_hops < 1 || _prefs->flood_channel_block_max_hops > 7)) {
_prefs->flood_channel_block_max_hops = FLOOD_CHANNEL_BLOCK_HOPS_ALL;
if (_prefs->legacy_flood_channel_block_max_hops != FLOOD_CHANNEL_HOPS_ALL
&& (_prefs->legacy_flood_channel_block_max_hops < 1
|| _prefs->legacy_flood_channel_block_max_hops > 7)) {
_prefs->legacy_flood_channel_block_max_hops = FLOOD_CHANNEL_HOPS_ALL;
}
if (_prefs->flood_channel_data_max_hops != FLOOD_CHANNEL_BLOCK_HOPS_ALL
if (_prefs->flood_channel_data_max_hops != FLOOD_CHANNEL_HOPS_ALL
&& (_prefs->flood_channel_data_max_hops < 1 || _prefs->flood_channel_data_max_hops > 7)) {
_prefs->flood_channel_data_max_hops = FLOOD_CHANNEL_BLOCK_HOPS_ALL;
_prefs->flood_channel_data_max_hops = FLOOD_CHANNEL_HOPS_ALL;
}
if (_prefs->battery_alert_low_percent < 1
|| _prefs->battery_alert_low_percent > 100
@@ -1564,7 +1431,7 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) {
WRITE_COMMON_PREFS(&prefs->battery_alert_critical_percent);
WRITE_COMMON_PREFS(&prefs->direct_retry_recent_enabled);
WRITE_COMMON_PREFS(&prefs->flood_channel_data_enabled);
WRITE_COMMON_PREFS(&prefs->flood_channel_block_max_hops);
WRITE_COMMON_PREFS(&prefs->legacy_flood_channel_block_max_hops);
WRITE_COMMON_PREFS(&prefs->flood_channel_data_max_hops);
WRITE_COMMON_PREFS(&prefs->telemetry_access); // 674
#if defined(ENABLE_OTA)
@@ -1708,7 +1575,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) {
file.write((uint8_t *)&_prefs->battery_alert_critical_percent, sizeof(_prefs->battery_alert_critical_percent));
file.write((uint8_t *)&_prefs->direct_retry_recent_enabled, sizeof(_prefs->direct_retry_recent_enabled));
file.write((uint8_t *)&_prefs->flood_channel_data_enabled, sizeof(_prefs->flood_channel_data_enabled));
file.write((uint8_t *)&_prefs->flood_channel_block_max_hops, sizeof(_prefs->flood_channel_block_max_hops));
file.write((uint8_t *)&_prefs->legacy_flood_channel_block_max_hops,
sizeof(_prefs->legacy_flood_channel_block_max_hops));
file.write((uint8_t *)&_prefs->flood_channel_data_max_hops, sizeof(_prefs->flood_channel_data_max_hops));
file.write((uint8_t *)&_prefs->telemetry_access, sizeof(_prefs->telemetry_access)); // 674
#if defined(ENABLE_OTA)
@@ -3945,7 +3813,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
}
} else if (memcmp(config, "flood.channel.data.hops ", 24) == 0) {
uint8_t max_hops;
if (parseFloodChannelBlockHops(&config[24], max_hops)) {
if (parseFloodChannelHops(&config[24], max_hops)) {
_prefs->flood_channel_data_max_hops = max_hops;
savePrefs();
strcpy(reply, "OK");
@@ -3964,62 +3832,6 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
} else {
strcpy(reply, "Error, must be on or off");
}
} else if (memcmp(config, "flood.channel.block.hops ", 25) == 0) {
uint8_t max_hops;
if (parseFloodChannelBlockHops(&config[25], max_hops)) {
_prefs->flood_channel_block_max_hops = max_hops;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be all or 1-7");
}
} else if (memcmp(config, "flood.channel.block", 19) == 0
&& (config[19] == ' ' || config[19] == '.')) {
const char* cursor = &config[19];
int index = 0;
if (!parseFloodChannelBlockDotIndex(cursor, index)) {
sprintf(reply, "Error, index 1-%d", FLOOD_CHANNEL_BLOCK_SLOTS);
return;
}
cursor = skipSpacesConst(cursor);
const char* key_start = cursor;
while (*cursor && *cursor != ' ') cursor++;
size_t key_len_text = cursor - key_start;
char key_text[PUB_KEY_SIZE * 2 + 1];
if (key_len_text == 0 || key_len_text >= sizeof(key_text)) {
strcpy(reply, "Error, use: set flood.channel.block[.n] <hex-key> <name>|#channel");
return;
}
memcpy(key_text, key_start, key_len_text);
key_text[key_len_text] = 0;
char name[FLOOD_CHANNEL_BLOCK_NAME_LEN];
uint8_t block_hops = FLOOD_CHANNEL_BLOCK_HOPS_INHERIT;
if (key_text[0] == '#') {
StrHelper::strncpy(name, key_text, sizeof(name));
const char* extra = skipSpacesConst(cursor);
if (*extra && looksFloodChannelBlockHopAssignment(extra)
&& !parseFloodChannelBlockHopAssignment(extra, true, block_hops)) {
strcpy(reply, "Error, hops must be all, default, or 1-7");
return;
}
} else {
copyTrimmedFloodChannelBlockName(name, sizeof(name), cursor);
if (!trimFloodChannelBlockHopSuffix(name, block_hops)) {
strcpy(reply, "Error, bad name or hops");
return;
}
}
uint8_t secret[PUB_KEY_SIZE];
uint8_t decoded_key_len = 0;
if (!parseFloodChannelBlockKey(key_text, secret, decoded_key_len)) {
strcpy(reply, "Error, key must be 128/256-bit hex or #channel");
} else if (name[0] == 0 || !isValidName(name)) {
strcpy(reply, "Error, bad name");
} else {
_callbacks->setFloodChannelBlock(index, secret, decoded_key_len, name, block_hops, reply);
}
} else if (memcmp(config, "flood.retry.count ", 18) == 0) {
int attempts = looksUnsignedInteger(&config[18]) ? _atoi(&config[18]) : -1;
if (attempts >= 0 && attempts <= 15) {
@@ -4570,36 +4382,12 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
sprintf(reply, "> %s", StrHelper::ftoa(_prefs->tx_delay_factor));
} else if (strcmp(config, "flood.channel.data.hops") == 0) {
char hops[8];
formatFloodChannelBlockHops(hops, _prefs->flood_channel_data_max_hops);
formatFloodChannelHops(hops, _prefs->flood_channel_data_max_hops);
sprintf(reply, "> %s", hops);
} else if (strcmp(config, "flood.channel.data") == 0) {
char hops[8];
formatFloodChannelBlockHops(hops, _prefs->flood_channel_data_max_hops);
formatFloodChannelHops(hops, _prefs->flood_channel_data_max_hops);
sprintf(reply, "> %s %s", _prefs->flood_channel_data_enabled ? "on" : "off", hops);
} else if (memcmp(config, "flood.channel.block.hops", 24) == 0) {
char hops[8];
formatFloodChannelBlockHops(hops, _prefs->flood_channel_block_max_hops);
sprintf(reply, "> %s", hops);
} else if (memcmp(config, "flood.channel.block", 19) == 0
&& (config[19] == 0 || config[19] == ' ' || config[19] == '.')) {
const char* cursor = &config[19];
int index = 0;
if (!parseFloodChannelBlockDotIndex(cursor, index)) {
sprintf(reply, "Error, index 1-%d", FLOOD_CHANNEL_BLOCK_SLOTS);
return;
}
cursor = skipSpacesConst(cursor);
char selector[8];
if (index > 0 && *cursor != 0) {
strcpy(reply, "Error, use index or selector");
return;
}
if (index > 0) {
snprintf(selector, sizeof(selector), "%d", index);
_callbacks->formatFloodChannelBlocks(selector, reply);
} else {
_callbacks->formatFloodChannelBlocks(cursor, reply);
}
} else if (memcmp(config, "flood.max.advert", 16) == 0) {
sprintf(reply, "> %d", (uint32_t)_prefs->flood_max_advert);
} else if (memcmp(config, "flood.max.unscoped", 18) == 0) {
@@ -4799,28 +4587,6 @@ void CommonCLI::handleDelCmd(char* command, char* reply) {
_callbacks->deleteScheduledRadioParams(true, skipSpacesConst(&config[11]), reply);
} else if (memcmp(config, "radioat", 7) == 0 && (config[7] == 0 || config[7] == ' ')) {
_callbacks->deleteScheduledRadioParams(false, skipSpacesConst(&config[7]), reply);
} else if (memcmp(config, "flood.channel.block", 19) == 0
&& (config[19] == ' ' || config[19] == '.')) {
const char* cursor = &config[19];
int index = 0;
if (!parseFloodChannelBlockDotIndex(cursor, index)) {
sprintf(reply, "Error, index 1-%d", FLOOD_CHANNEL_BLOCK_SLOTS);
return;
}
cursor = skipSpacesConst(cursor);
char selector[8];
if (index > 0 && *cursor != 0) {
strcpy(reply, "Error, use index or selector");
return;
}
if (index > 0) {
snprintf(selector, sizeof(selector), "%d", index);
_callbacks->deleteFloodChannelBlock(selector, reply);
} else if (*cursor != 0) {
_callbacks->deleteFloodChannelBlock(cursor, reply);
} else {
strcpy(reply, "Error, use: del flood.channel.block <index|name|prefix>");
}
} else {
strcpy(reply, "unknown del: ");
StrHelper::strncpy(&reply[13], config, 160 - 14);
+6 -29
View File
@@ -90,17 +90,10 @@
#define COMMON_CLI_TMP_LEN ((FLOOD_RETRY_LIST_TEXT_MAX > (PRV_KEY_SIZE * 2 + 4)) ? FLOOD_RETRY_LIST_TEXT_MAX : (PRV_KEY_SIZE * 2 + 4))
#endif
#ifndef FLOOD_CHANNEL_BLOCK_SLOTS
#define FLOOD_CHANNEL_BLOCK_SLOTS 15
#ifndef FLOOD_CHANNEL_KEY_PREFIX_LEN
#define FLOOD_CHANNEL_KEY_PREFIX_LEN 4
#endif
#ifndef FLOOD_CHANNEL_BLOCK_NAME_LEN
#define FLOOD_CHANNEL_BLOCK_NAME_LEN 32
#endif
#ifndef FLOOD_CHANNEL_BLOCK_PREFIX_LEN
#define FLOOD_CHANNEL_BLOCK_PREFIX_LEN 4
#endif
#define FLOOD_CHANNEL_BLOCK_HOPS_ALL 0xFF
#define FLOOD_CHANNEL_BLOCK_HOPS_INHERIT 0xFE
#define FLOOD_CHANNEL_HOPS_ALL 0xFF
#define DIRECT_RETRY_CR4_MIN_SNR_X4_DEFAULT 40
#define DIRECT_RETRY_CR5_MIN_SNR_X4_DEFAULT 30
@@ -191,7 +184,9 @@ struct NodePrefs { // persisted to file
uint8_t battery_alert_critical_percent;
uint8_t direct_retry_recent_enabled;
uint8_t flood_channel_data_enabled;
uint8_t flood_channel_block_max_hops;
// Retains the removed flood.channel.block byte at its established file
// offset so every following preference remains upgrade-compatible.
uint8_t legacy_flood_channel_block_max_hops;
uint8_t flood_channel_data_max_hops;
uint8_t telemetry_access;
@@ -334,24 +329,6 @@ public:
(void)selector;
strcpy(reply, "Error: unsupported");
}
virtual void setFloodChannelBlock(int index, const uint8_t* secret, uint8_t key_len,
const char* name, uint8_t max_hops, char* reply) {
(void)index;
(void)secret;
(void)key_len;
(void)name;
(void)max_hops;
strcpy(reply, "Error: unsupported");
}
virtual void formatFloodChannelBlocks(const char* selector, char* reply) {
(void)selector;
strcpy(reply, "Error: unsupported");
}
virtual void deleteFloodChannelBlock(const char* selector, char* reply) {
(void)selector;
strcpy(reply, "Error: unsupported");
}
virtual void startRegionsLoad() {
// no op by default
}
+87
View File
@@ -0,0 +1,87 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
namespace mesh {
inline bool isCompanionPushFrame(const uint8_t* frame, size_t len) {
return frame != NULL && len > 0 && (frame[0] & 0x80) != 0;
}
/**
* Add a frame to a companion transport's contiguous outbound queue.
*
* Protocol responses use codes below 0x80 and complete an app command that is
* waiting for them. Push frames use codes at or above 0x80 and are
* asynchronous. Keep one slot available for a response, and if an older queue
* is already full of mixed traffic, let a response replace the newest push.
* Responses are inserted before pushes so packet-log traffic cannot delay a
* command indefinitely.
*/
template <typename Frame, typename QueueLength>
bool enqueueCompanionFrame(Frame queue[], QueueLength& queue_len, size_t capacity,
const uint8_t* src, size_t len) {
if (queue == NULL || src == NULL || len == 0 || capacity == 0) return false;
size_t count = static_cast<size_t>(queue_len);
if (count > capacity) return false;
const bool push = isCompanionPushFrame(src, len);
if (push && count >= capacity - 1) {
return false; // preserve one slot for the reply to an app command
}
if (count == capacity) {
// A response may displace best-effort asynchronous traffic, but never an
// earlier response that another command is already waiting for.
size_t evict = count;
while (evict > 0) {
--evict;
if (isCompanionPushFrame(queue[evict].buf, queue[evict].len)) break;
}
if (!isCompanionPushFrame(queue[evict].buf, queue[evict].len)) return false;
for (size_t i = evict; i + 1 < count; ++i) {
queue[i] = queue[i + 1];
}
--count;
}
size_t insert_at = count;
if (!push) {
// Keep responses FIFO with respect to one another, ahead of asynchronous
// pushes such as raw-packet logs and message-waiting notifications.
// The stable partition also repairs a queue populated by older admission
// behavior before this policy gets a chance to add the next response.
for (size_t i = 1; i < count; ++i) {
if (isCompanionPushFrame(queue[i].buf, queue[i].len)) continue;
Frame response = queue[i];
size_t j = i;
while (j > 0
&& isCompanionPushFrame(queue[j - 1].buf, queue[j - 1].len)) {
queue[j] = queue[j - 1];
--j;
}
queue[j] = response;
}
for (size_t i = 0; i < count; ++i) {
if (isCompanionPushFrame(queue[i].buf, queue[i].len)) {
insert_at = i;
break;
}
}
for (size_t i = count; i > insert_at; --i) {
queue[i] = queue[i - 1];
}
}
queue[insert_at].len = len;
memcpy(queue[insert_at].buf, src, len);
queue_len = static_cast<QueueLength>(count + 1);
return true;
}
} // namespace mesh
+139
View File
@@ -23,6 +23,16 @@ static constexpr uint8_t SCOPE_PATH_BRIDGE_BUCKET_BASE = 2;
static constexpr uint8_t SCOPE_PATH_BRIDGE_BUCKET_COUNT = 6;
static constexpr uint8_t SCOPE_PATH_INVALID_BUCKET = 0xFF;
enum RuleIncomingScope : uint8_t {
RULE_IN_ANY = 0,
RULE_IN_NONE = 1,
RULE_IN_SCOPED = 2,
RULE_IN_ALLOWED = 3,
RULE_IN_UNKNOWN = 4,
RULE_IN_SCOPE = 5,
RULE_IN_REGION = 6,
};
enum ChannelScopeGate {
CHANNEL_SCOPE_USE_GLOBAL,
CHANNEL_SCOPE_BYPASS,
@@ -66,6 +76,49 @@ inline uint8_t scopeSelectorValue(uint8_t stored_selector) {
& (uint8_t)~(SLOW_SCOPE_FLAG | SCOPE_PATH_MASK);
}
// Regionless channel-scope rows use alternate base selector values so the
// existing 16-bit target field can hold a one-based direct-scope table index.
// Region-backed rows retain their original on-disk selector values.
static constexpr uint8_t CHANNEL_SCOPE_DIRECT_KEY_128 = 17;
static constexpr uint8_t CHANNEL_SCOPE_DIRECT_KEY_256 = 18;
static constexpr uint8_t CHANNEL_SCOPE_DIRECT_TXT_ANY = 33;
static constexpr uint8_t CHANNEL_SCOPE_DIRECT_LOGIN_ANY = 34;
static constexpr uint8_t CHANNEL_SCOPE_DIRECT_OTHER_ANY = 35;
static constexpr uint8_t CHANNEL_SCOPE_INVALID_SELECTOR = 0xFF;
inline uint8_t encodeChannelScopeTargetSelector(uint8_t match_selector,
bool direct_target) {
if (!direct_target) return match_selector;
if (match_selector <= 2) {
return (uint8_t)(CHANNEL_SCOPE_DIRECT_TXT_ANY + match_selector);
}
if (match_selector == 16 || match_selector == 32) {
return (uint8_t)(16 + match_selector / 16);
}
return CHANNEL_SCOPE_INVALID_SELECTOR;
}
inline bool channelScopeUsesDirectTarget(uint8_t stored_selector) {
uint8_t selector = scopeSelectorValue(stored_selector);
return (selector >= CHANNEL_SCOPE_DIRECT_KEY_128
&& selector <= CHANNEL_SCOPE_DIRECT_KEY_256)
|| (selector >= CHANNEL_SCOPE_DIRECT_TXT_ANY
&& selector <= CHANNEL_SCOPE_DIRECT_OTHER_ANY);
}
inline uint8_t channelScopeMatchSelectorValue(uint8_t stored_selector) {
uint8_t selector = scopeSelectorValue(stored_selector);
if (selector >= CHANNEL_SCOPE_DIRECT_KEY_128
&& selector <= CHANNEL_SCOPE_DIRECT_KEY_256) {
return (uint8_t)((selector - 16) * 16);
}
if (selector >= CHANNEL_SCOPE_DIRECT_TXT_ANY
&& selector <= CHANNEL_SCOPE_DIRECT_OTHER_ANY) {
return (uint8_t)(selector - CHANNEL_SCOPE_DIRECT_TXT_ANY);
}
return selector;
}
inline bool scopeUsesSlowTiming(uint8_t stored_selector) {
return (stored_selector & SLOW_SCOPE_FLAG) != 0;
}
@@ -176,6 +229,92 @@ inline bool pathMatchesConfiguredIds(const mesh::Packet* packet,
packet, ids, configuredIdCount(ids, maximum_count));
}
inline bool pathStartsWith(const mesh::Packet* packet,
uint8_t hash_size,
uint8_t prefix_hops,
const uint8_t* prefix) {
if (prefix_hops == 0) return true;
if (packet == NULL || prefix == NULL || hash_size < 1 || hash_size > 3
|| packet->getPathHashSize() != hash_size
|| packet->getPathHashCount() < prefix_hops) {
return false;
}
return memcmp(packet->path, prefix, hash_size * prefix_hops) == 0;
}
inline bool ruleIncomingScopeMatches(uint8_t kind,
bool incoming_is_scoped,
uint16_t incoming_transport_code,
bool incoming_region_allowed,
uint16_t wanted_transport_code) {
switch (kind) {
case RULE_IN_ANY:
return true;
case RULE_IN_NONE:
return !incoming_is_scoped;
case RULE_IN_SCOPED:
return incoming_is_scoped;
case RULE_IN_ALLOWED:
return incoming_region_allowed;
case RULE_IN_UNKNOWN:
return incoming_is_scoped && !incoming_region_allowed;
case RULE_IN_SCOPE:
return incoming_is_scoped
&& incoming_transport_code == wanted_transport_code;
case RULE_IN_REGION:
// Region rules are name-bound and must be evaluated by the caller with
// RegionNameUtils. Numeric region IDs are deliberately not rule identity.
return false;
default:
return false;
}
}
inline bool rateLimitReached(bool window_active, uint32_t now,
uint32_t window_started, uint16_t window_count,
uint16_t rate_per_minute) {
uint16_t effective_count = (!window_active
|| now - window_started >= 60000UL)
? 0 : window_count;
return effective_count >= rate_per_minute;
}
inline bool sameChannelKey(uint8_t left_len, const uint8_t left[],
uint8_t right_len, const uint8_t right[]) {
return left != NULL && right != NULL && left_len != 0
&& left_len == right_len && memcmp(left, right, left_len) == 0;
}
inline int nextOrderedRule(uint32_t match_mask, uint32_t visited_mask,
const uint8_t priorities[], uint8_t count) {
if (priorities == NULL || count > 32) return -1;
int best = -1;
for (uint8_t i = 0; i < count; i++) {
uint32_t bit = (uint32_t)1U << i;
if ((match_mask & bit) == 0 || (visited_mask & bit) != 0) continue;
if (best < 0 || priorities[i] > priorities[best]) best = i;
}
return best;
}
inline uint32_t truncateRulesAtStop(uint32_t match_mask,
const uint8_t priorities[],
const uint8_t stop_flags[],
uint8_t count) {
if (stop_flags == NULL) return match_mask;
uint32_t effective = 0;
uint32_t visited = 0;
while (true) {
int index = nextOrderedRule(match_mask, visited, priorities, count);
if (index < 0) break;
uint32_t bit = (uint32_t)1U << index;
visited |= bit;
effective |= bit;
if (stop_flags[index] != 0) break;
}
return effective;
}
inline bool scopeRuleAllowed(bool requires_region_match,
bool incoming_region_allowed) {
return !requires_region_match || incoming_region_allowed;
+98
View File
@@ -0,0 +1,98 @@
#pragma once
#include <MeshCore.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
namespace mesh {
// Remembers a bounded set of logical messages independently of their on-air
// attempt byte. The fingerprint supplied by the caller must already identify
// the peer and message text.
template <size_t ENTRY_COUNT>
class LogicalMessageCache {
public:
enum class ReplayDecision : uint8_t {
NewMessage,
ExactRetry,
StaleOrMismatched
};
LogicalMessageCache() { clear(); }
bool find(const uint8_t fingerprint[MAX_HASH_SIZE], uint32_t source_timestamp,
uint32_t* mapped_timestamp = NULL) const {
if (fingerprint == NULL) return false;
for (size_t i = 0; i < ENTRY_COUNT; i++) {
const Entry& entry = entries_[i];
if (entry.valid && entry.source_timestamp == source_timestamp
&& memcmp(entry.fingerprint, fingerprint, MAX_HASH_SIZE) == 0) {
if (mapped_timestamp != NULL) {
*mapped_timestamp = entry.mapped_timestamp;
}
return true;
}
}
return false;
}
bool remember(const uint8_t fingerprint[MAX_HASH_SIZE], uint32_t source_timestamp,
uint32_t mapped_timestamp = 0) {
if (fingerprint == NULL) return false;
for (size_t i = 0; i < ENTRY_COUNT; i++) {
Entry& entry = entries_[i];
if (entry.valid && entry.source_timestamp == source_timestamp
&& memcmp(entry.fingerprint, fingerprint, MAX_HASH_SIZE) == 0) {
entry.mapped_timestamp = mapped_timestamp;
return true;
}
}
Entry& entry = entries_[next_entry_];
memcpy(entry.fingerprint, fingerprint, MAX_HASH_SIZE);
entry.source_timestamp = source_timestamp;
entry.mapped_timestamp = mapped_timestamp;
entry.valid = true;
next_entry_ = (next_entry_ + 1) % ENTRY_COUNT;
return true;
}
ReplayDecision classifyAndRemember(
const uint8_t fingerprint[MAX_HASH_SIZE], uint32_t source_timestamp,
uint32_t& latest_source_timestamp) {
if (fingerprint == NULL) return ReplayDecision::StaleOrMismatched;
if (find(fingerprint, source_timestamp)) {
return ReplayDecision::ExactRetry;
}
if (source_timestamp <= latest_source_timestamp) {
return ReplayDecision::StaleOrMismatched;
}
latest_source_timestamp = source_timestamp;
remember(fingerprint, source_timestamp);
return ReplayDecision::NewMessage;
}
void clear() {
memset(entries_, 0, sizeof(entries_));
next_entry_ = 0;
}
private:
static_assert(ENTRY_COUNT > 0, "LogicalMessageCache needs at least one entry");
struct Entry {
uint8_t fingerprint[MAX_HASH_SIZE];
uint32_t source_timestamp;
uint32_t mapped_timestamp;
bool valid;
};
Entry entries_[ENTRY_COUNT];
size_t next_entry_;
};
} // namespace mesh
+81
View File
@@ -0,0 +1,81 @@
#pragma once
#include <MeshCore.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
namespace mesh {
// Keeps the most recently completed remote CLI reply so an exact request
// retry can recover a lost response without executing the command twice.
// This is deliberately a single bounded entry: remote CLI commands are
// serialized, and repeater builds on small MCUs cannot afford one reply-sized
// buffer for every ACL client.
class RemoteCliReplyCache {
public:
static constexpr size_t MAX_REPLY_TEXT =
MAX_PACKET_PAYLOAD - CIPHER_MAC_SIZE - (CIPHER_BLOCK_SIZE - 1) - 5;
RemoteCliReplyCache() { clear(); }
static uint32_t fingerprint(const char* command, size_t command_len) {
if (command == NULL) return 0;
// FNV-1a is only a compact request-mismatch guard. Authentication and the
// sender/timestamp tuple remain the replay-protection boundary.
uint32_t value = 2166136261UL;
for (size_t i = 0; i < command_len; i++) {
value ^= (uint8_t)command[i];
value *= 16777619UL;
}
return value;
}
bool remember(const uint8_t* sender_pub_key, uint32_t request_timestamp,
uint32_t command_fingerprint, const char* response) {
if (sender_pub_key == NULL || response == NULL) return false;
memcpy(sender_pub_key_, sender_pub_key, sizeof(sender_pub_key_));
request_timestamp_ = request_timestamp;
command_fingerprint_ = command_fingerprint;
size_t response_len = 0;
while (response_len < MAX_REPLY_TEXT && response[response_len] != 0) {
response_len++;
}
memcpy(response_, response, response_len);
response_[response_len] = 0;
valid_ = true;
return true;
}
bool matches(const uint8_t* sender_pub_key, uint32_t request_timestamp,
uint32_t command_fingerprint) const {
return valid_ && sender_pub_key != NULL
&& request_timestamp_ == request_timestamp
&& command_fingerprint_ == command_fingerprint
&& memcmp(sender_pub_key_, sender_pub_key, sizeof(sender_pub_key_)) == 0;
}
const char* response() const { return response_; }
bool hasResponse() const { return valid_ && response_[0] != 0; }
bool isValid() const { return valid_; }
void clear() {
valid_ = false;
memset(sender_pub_key_, 0, sizeof(sender_pub_key_));
request_timestamp_ = 0;
command_fingerprint_ = 0;
memset(response_, 0, sizeof(response_));
}
private:
bool valid_;
uint8_t sender_pub_key_[PUB_KEY_SIZE];
uint32_t request_timestamp_;
uint32_t command_fingerprint_;
char response_[MAX_REPLY_TEXT + 1];
};
} // namespace mesh
+3 -6
View File
@@ -1,4 +1,5 @@
#include "SerialBLEInterface.h"
#include "../CompanionFrameQueue.h"
#include "esp_mac.h"
#include "esp_gap_ble_api.h"
@@ -211,15 +212,11 @@ size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) {
}
if (deviceConnected && len > 0) {
if (send_queue_len >= FRAME_QUEUE_SIZE) {
if (!mesh::enqueueCompanionFrame(send_queue, send_queue_len, FRAME_QUEUE_SIZE,
src, len)) {
BLE_DEBUG_PRINTLN("writeFrame(), send_queue is full!");
return 0;
}
send_queue[send_queue_len].len = len; // add to send queue
memcpy(send_queue[send_queue_len].buf, src, len);
send_queue_len++;
return len;
}
return 0;
+4 -7
View File
@@ -1,4 +1,5 @@
#include "SerialWifiInterface.h"
#include "../CompanionFrameQueue.h"
#include <WiFi.h>
void SerialWifiInterface::begin(int port) {
@@ -25,15 +26,11 @@ size_t SerialWifiInterface::writeFrame(const uint8_t src[], size_t len) {
}
if (deviceConnected && len > 0) {
if (send_queue_len >= FRAME_QUEUE_SIZE) {
if (!mesh::enqueueCompanionFrame(send_queue, send_queue_len, FRAME_QUEUE_SIZE,
src, len)) {
WIFI_DEBUG_PRINTLN("writeFrame(), send_queue is full!");
return 0;
}
send_queue[send_queue_len].len = len; // add to send queue
memcpy(send_queue[send_queue_len].buf, src, len);
send_queue_len++;
return len;
}
return 0;
@@ -173,4 +170,4 @@ size_t SerialWifiInterface::checkRecvFrame(uint8_t dest[]) {
bool SerialWifiInterface::isConnected() const {
return deviceConnected; //pServer != NULL && pServer->getConnectedCount() > 0;
}
}
@@ -1,4 +1,5 @@
#include "SerialEthernetInterface.h"
#include "../CompanionFrameQueue.h"
#define RECV_STATE_IDLE 0
#define RECV_STATE_HDR_FOUND 1
@@ -27,15 +28,11 @@ size_t SerialEthernetInterface::writeFrame(const uint8_t src[], size_t len) {
}
if (isConnected() && len > 0) {
if (send_queue_len >= FRAME_QUEUE_SIZE) {
if (!mesh::enqueueCompanionFrame(send_queue, send_queue_len, FRAME_QUEUE_SIZE,
src, len)) {
ETHERNET_DEBUG_PRINTLN("writeFrame(), send_queue is full!");
return 0;
}
send_queue[send_queue_len].len = len; // add to send queue
memcpy(send_queue[send_queue_len].buf, src, len);
send_queue_len++;
return len;
}
return 0;
+3 -6
View File
@@ -1,4 +1,5 @@
#include "SerialBLEInterface.h"
#include "../CompanionFrameQueue.h"
#include <stdio.h>
#include <string.h>
#include "ble_gap.h"
@@ -353,15 +354,11 @@ size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) {
bool connected = isConnected();
if (connected && len > 0) {
if (send_queue_len >= FRAME_QUEUE_SIZE) {
if (!mesh::enqueueCompanionFrame(send_queue, send_queue_len, FRAME_QUEUE_SIZE,
src, len)) {
BLE_DEBUG_PRINTLN("writeFrame(), send_queue is full!");
return 0;
}
send_queue[send_queue_len].len = len;
memcpy(send_queue[send_queue_len].buf, src, len);
send_queue_len++;
return len;
}
return 0;
+4 -1
View File
@@ -39,7 +39,10 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_prefs_save_routing` | `src/helpers/PrefsSaveRouting.h` | runtime common/observer setters write only their owning preference image; mixed-owner setters and migrations can deliberately write both |
| `test_mqtt_payload_builder` | `src/helpers/MQTTPayloadBuilder.cpp` | status/packet/raw JSON contracts; optional fields; escaping; RX metrics and path; score handling; exact buffer bounds; maximum representative payloads |
| `test_telemetry_history` | `src/helpers/TelemetryHistory.h` | 30-minute rings; seven-day temperature/voltage and dynamically sized GPS retention; exact 1 C temperature/status encoding; separate Base64 series payloads; 14-bit GPS differentials; resize preservation, heap budgets, and 1-based paging bounds |
| `test_flood_filter_policy` | `src/helpers/FloodFilterPolicy.h` | unordered 3-byte and 2-byte-prefix path matching; match thresholds; repeated path-entry semantics; blacklist and bridge-bucket channel-scope selection; `require=region` and per-channel scope-gate truth tables; fast/slow scope timing; adding, replacing, and preserving packet scope |
| `test_flood_filter_policy` | `src/helpers/FloodFilterPolicy.h` | unordered blacklist matching; ordered 1/2/3-byte pbyte rule prefixes; original incoming scope classes and canonical region-name identity; channel-authentication cache key comparison; priority ordering and terminal stop masks; bridge-bucket and regionless channel-target selector encoding; `require=region` and per-channel scope-gate truth tables; fast/slow timing; adding, replacing, and preserving packet scope |
| `test_logical_message_cache` | `src/helpers/LogicalMessageCache.h` | bounded logical-message mapping; stable retry timestamps; exact older retries after newer messages; stale and same-timestamp mismatch rejection |
| `test_remote_cli_reply_cache` | `src/helpers/RemoteCliReplyCache.h` | authenticated sender/timestamp/command matching; owned response storage; empty-response completion; on-air truncation; replacement and clearing |
| `test_companion_frame_queue` | `src/helpers/CompanionFrameQueue.h` | reserved reply capacity; response priority; push eviction under saturation; response preservation |
| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) |
## Conventions (and how to add a suite)
@@ -0,0 +1,78 @@
#include <gtest/gtest.h>
#include <helpers/CompanionFrameQueue.h>
namespace {
struct TestFrame {
size_t len;
uint8_t buf[8];
};
static bool enqueue(TestFrame queue[], size_t& count, size_t capacity,
uint8_t code) {
return mesh::enqueueCompanionFrame(queue, count, capacity, &code, 1);
}
TEST(CompanionFrameQueue, PushTrafficLeavesOneResponseSlot) {
TestFrame queue[4] = {};
size_t count = 0;
EXPECT_TRUE(enqueue(queue, count, 4, 0x88));
EXPECT_TRUE(enqueue(queue, count, 4, 0x84));
EXPECT_TRUE(enqueue(queue, count, 4, 0x81));
EXPECT_FALSE(enqueue(queue, count, 4, 0x8A));
ASSERT_EQ(3U, count);
EXPECT_TRUE(enqueue(queue, count, 4, 0x00));
ASSERT_EQ(4U, count);
EXPECT_EQ(0x00, queue[0].buf[0]);
}
TEST(CompanionFrameQueue, ResponsesRunBeforeQueuedPushesAndRemainFifo) {
TestFrame queue[5] = {};
size_t count = 0;
ASSERT_TRUE(enqueue(queue, count, 5, 0x88));
ASSERT_TRUE(enqueue(queue, count, 5, 0x06));
ASSERT_TRUE(enqueue(queue, count, 5, 0x84));
ASSERT_TRUE(enqueue(queue, count, 5, 0x00));
ASSERT_EQ(4U, count);
EXPECT_EQ(0x06, queue[0].buf[0]);
EXPECT_EQ(0x00, queue[1].buf[0]);
EXPECT_EQ(0x88, queue[2].buf[0]);
EXPECT_EQ(0x84, queue[3].buf[0]);
}
TEST(CompanionFrameQueue, ResponseEvictsNewestPushFromAnAlreadyFullQueue) {
TestFrame queue[4] = {
{1, {0x06}}, {1, {0x88}}, {1, {0x00}}, {1, {0x84}}};
size_t count = 4;
ASSERT_TRUE(enqueue(queue, count, 4, 0x01));
ASSERT_EQ(4U, count);
EXPECT_EQ(0x06, queue[0].buf[0]);
EXPECT_EQ(0x00, queue[1].buf[0]);
EXPECT_EQ(0x01, queue[2].buf[0]);
EXPECT_EQ(0x88, queue[3].buf[0]);
}
TEST(CompanionFrameQueue, ResponseDoesNotEvictAnotherResponse) {
TestFrame queue[3] = {
{1, {0x00}}, {1, {0x01}}, {1, {0x06}}};
size_t count = 3;
EXPECT_FALSE(enqueue(queue, count, 3, 0x02));
EXPECT_EQ(3U, count);
EXPECT_EQ(0x00, queue[0].buf[0]);
EXPECT_EQ(0x01, queue[1].buf[0]);
EXPECT_EQ(0x06, queue[2].buf[0]);
}
} // namespace
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -1,6 +1,7 @@
#include <gtest/gtest.h>
#include <helpers/FloodFilterPolicy.h>
#include <helpers/RegionNameUtils.h>
static mesh::Packet makeFloodPacket(uint8_t hash_size,
const uint8_t* path,
@@ -127,6 +128,161 @@ TEST(FloodFilterBlacklist, ConfiguredBucketStopsAtEmptyTrailingIds) {
&packet, &bucket[0][0], 17));
}
TEST(FloodRulePrefix, SupportsOneTwoAndThreeBytePathPrefixes) {
const uint8_t one_byte_path[] = {0x11, 0x22, 0x33};
const uint8_t two_byte_path[] = {0x11, 0x22, 0x33, 0x44};
const uint8_t three_byte_path[] = {
0x11, 0x22, 0x33,
0x44, 0x55, 0x66,
};
mesh::Packet one = makeFloodPacket(1, one_byte_path, 3);
mesh::Packet two = makeFloodPacket(2, two_byte_path, 2);
mesh::Packet three = makeFloodPacket(3, three_byte_path, 2);
EXPECT_TRUE(FloodFilterPolicy::pathStartsWith(
&one, 1, 2, one_byte_path));
EXPECT_TRUE(FloodFilterPolicy::pathStartsWith(
&two, 2, 2, two_byte_path));
EXPECT_TRUE(FloodFilterPolicy::pathStartsWith(
&three, 3, 2, three_byte_path));
}
TEST(FloodRulePrefix, RequiresTheExactPathWidthAndStartingSequence) {
const uint8_t path[] = {
0x11, 0x22,
0x33, 0x44,
};
const uint8_t wrong_start[] = {0x33, 0x44};
mesh::Packet packet = makeFloodPacket(2, path, 2);
EXPECT_FALSE(FloodFilterPolicy::pathStartsWith(
&packet, 1, 1, path));
EXPECT_FALSE(FloodFilterPolicy::pathStartsWith(
&packet, 2, 1, wrong_start));
EXPECT_FALSE(FloodFilterPolicy::pathStartsWith(
&packet, 2, 3, path));
}
TEST(FloodRuleIncomingScope, MatchesOriginalScopeClasses) {
using namespace FloodFilterPolicy;
EXPECT_TRUE(ruleIncomingScopeMatches(
RULE_IN_ANY, false, 0, false, 0));
EXPECT_TRUE(ruleIncomingScopeMatches(
RULE_IN_NONE, false, 0, true, 0));
EXPECT_FALSE(ruleIncomingScopeMatches(
RULE_IN_NONE, true, 0x1234, true, 0));
EXPECT_TRUE(ruleIncomingScopeMatches(
RULE_IN_SCOPED, true, 0x1234, false, 0));
EXPECT_TRUE(ruleIncomingScopeMatches(
RULE_IN_ALLOWED, true, 0x1234, true, 0));
EXPECT_TRUE(ruleIncomingScopeMatches(
RULE_IN_UNKNOWN, true, 0x1234, false, 0));
}
TEST(FloodRuleIncomingScope, ExactScopeDoesNotCrossMatch) {
using namespace FloodFilterPolicy;
EXPECT_TRUE(ruleIncomingScopeMatches(
RULE_IN_SCOPE, true, 0x1234, true, 0x1234));
EXPECT_FALSE(ruleIncomingScopeMatches(
RULE_IN_SCOPE, true, 0x1235, true, 0x1234));
EXPECT_FALSE(ruleIncomingScopeMatches(
RULE_IN_REGION, true, 0x1234, true, 0));
}
TEST(FloodRuleIncomingScope, RegionIdentityUsesCanonicalNames) {
EXPECT_TRUE(RegionNameUtils::equivalent("usa", "#usa"));
EXPECT_TRUE(RegionNameUtils::equivalent("BlackHole86", "BlackHole86"));
EXPECT_FALSE(RegionNameUtils::equivalent("usa", "BlackHole86"));
}
TEST(FloodRuleRate, BlocksAtTheConfiguredPerMinuteLimit) {
EXPECT_FALSE(FloodFilterPolicy::rateLimitReached(
false, 1000, 0, 500, 2));
EXPECT_FALSE(FloodFilterPolicy::rateLimitReached(
true, 2000, 1000, 1, 2));
EXPECT_TRUE(FloodFilterPolicy::rateLimitReached(
true, 2000, 1000, 2, 2));
EXPECT_TRUE(FloodFilterPolicy::rateLimitReached(
true, 2000, 1000, 0, 0));
}
TEST(FloodRuleRate, StartsANewWindowAfterSixtySeconds) {
EXPECT_FALSE(FloodFilterPolicy::rateLimitReached(
true, 61000, 1000, 2, 2));
EXPECT_TRUE(FloodFilterPolicy::rateLimitReached(
true, 999, 0xFFFFFF00U, 2, 2));
EXPECT_FALSE(FloodFilterPolicy::rateLimitReached(
true, 60000, 0xFFFFFF00U, 2, 2));
}
TEST(FloodRuleAuthentication, CacheKeysCompareOnlyTheirActiveBytes) {
uint8_t first[32] = {0};
uint8_t same[32] = {0};
uint8_t different[32] = {0};
first[0] = same[0] = different[0] = 0xA5;
first[15] = same[15] = different[15] = 0x5A;
first[31] = 0x11;
same[31] = 0x22;
different[15] = 0x5B;
EXPECT_TRUE(FloodFilterPolicy::sameChannelKey(16, first, 16, same));
EXPECT_FALSE(FloodFilterPolicy::sameChannelKey(
16, first, 16, different));
EXPECT_FALSE(FloodFilterPolicy::sameChannelKey(16, first, 32, first));
EXPECT_FALSE(FloodFilterPolicy::sameChannelKey(0, first, 0, same));
}
TEST(FloodRuleOrder, HighestPriorityWinsAndSlotBreaksTies) {
const uint8_t priorities[] = {10, 30, 30, 5};
uint32_t matches = 0x0F;
uint32_t visited = 0;
int first = FloodFilterPolicy::nextOrderedRule(
matches, visited, priorities, 4);
ASSERT_EQ(1, first);
visited |= (uint32_t)1U << first;
EXPECT_EQ(2, FloodFilterPolicy::nextOrderedRule(
matches, visited, priorities, 4));
}
TEST(FloodRuleOrder, StopRemovesOnlyLowerOrderedMatches) {
const uint8_t priorities[] = {10, 30, 20, 5};
const uint8_t stop_flags[] = {0, 0, 1, 0};
EXPECT_EQ(0x06U, FloodFilterPolicy::truncateRulesAtStop(
0x0F, priorities, stop_flags, 4));
}
TEST(FloodRuleOrder, StopAtEqualPriorityUsesLowerSlotFirst) {
const uint8_t priorities[] = {40, 40, 50};
const uint8_t stop_flags[] = {1, 0, 0};
EXPECT_EQ(0x05U, FloodFilterPolicy::truncateRulesAtStop(
0x07, priorities, stop_flags, 3));
}
TEST(FloodRuleOrder, NoStopPreservesEveryMatch) {
const uint8_t priorities[] = {1, 200, 3, 99};
const uint8_t stop_flags[] = {0, 0, 0, 0};
EXPECT_EQ(0x0BU, FloodFilterPolicy::truncateRulesAtStop(
0x0B, priorities, stop_flags, 4));
}
TEST(FloodRuleOrder, ThirtyOneSlotTableIncludesTheLastSlot) {
uint8_t priorities[31] = {0};
uint8_t stop_flags[31] = {0};
priorities[30] = 200;
stop_flags[30] = 1;
const uint32_t matches = ((uint32_t)1U << 30) | 1U;
EXPECT_EQ(30, FloodFilterPolicy::nextOrderedRule(
matches, 0, priorities, 31));
EXPECT_EQ((uint32_t)1U << 30,
FloodFilterPolicy::truncateRulesAtStop(
matches, priorities, stop_flags, 31));
}
TEST(FloodFilterScope, RegionRequirementHasTheExpectedTruthTable) {
EXPECT_TRUE(FloodFilterPolicy::scopeRuleAllowed(false, false));
EXPECT_TRUE(FloodFilterPolicy::scopeRuleAllowed(false, true));
@@ -193,6 +349,42 @@ TEST(FloodFilterScope, EveryBridgeBucketRoundTripsWithoutChangingSelector) {
}
}
TEST(FloodFilterScope, DirectChannelTargetsRoundTripEveryMatcherClass) {
const uint8_t selectors[] = {0, 1, 2, 16, 32};
for (uint8_t selector : selectors) {
uint8_t direct = FloodFilterPolicy::encodeChannelScopeTargetSelector(
selector, true);
uint8_t stored = FloodFilterPolicy::encodeScopeSelector(
direct, true, FloodFilterPolicy::SCOPE_PATH_BRIDGE_BUCKET_BASE + 5);
EXPECT_NE(FloodFilterPolicy::CHANNEL_SCOPE_INVALID_SELECTOR, direct);
EXPECT_TRUE(FloodFilterPolicy::channelScopeUsesDirectTarget(stored));
EXPECT_EQ(selector,
FloodFilterPolicy::channelScopeMatchSelectorValue(stored));
EXPECT_TRUE(FloodFilterPolicy::scopeUsesSlowTiming(stored));
EXPECT_EQ(FloodFilterPolicy::SCOPE_PATH_BRIDGE_BUCKET_BASE + 5,
FloodFilterPolicy::scopePathSelectorValue(stored));
}
}
TEST(FloodFilterScope, RegionChannelTargetsKeepLegacySelectorValues) {
const uint8_t selectors[] = {0, 1, 2, 16, 32};
for (uint8_t selector : selectors) {
uint8_t region = FloodFilterPolicy::encodeChannelScopeTargetSelector(
selector, false);
EXPECT_EQ(selector, region);
EXPECT_FALSE(FloodFilterPolicy::channelScopeUsesDirectTarget(region));
EXPECT_EQ(selector,
FloodFilterPolicy::channelScopeMatchSelectorValue(region));
}
}
TEST(FloodFilterScope, DirectChannelTargetRejectsUnknownMatcher) {
EXPECT_EQ(FloodFilterPolicy::CHANNEL_SCOPE_INVALID_SELECTOR,
FloodFilterPolicy::encodeChannelScopeTargetSelector(7, true));
}
TEST(FloodFilterScope, OnlyChangedFastRulesReceiveFastTrackTreatment) {
EXPECT_TRUE(FloodFilterPolicy::fastTrackScopeChange(true, false));
EXPECT_FALSE(FloodFilterPolicy::fastTrackScopeChange(true, true));
@@ -0,0 +1,111 @@
#include <gtest/gtest.h>
#include <helpers/LogicalMessageCache.h>
static void makeFingerprint(uint8_t fingerprint[MAX_HASH_SIZE], uint8_t value) {
memset(fingerprint, value, MAX_HASH_SIZE);
}
TEST(LogicalMessageCache, MatchesFingerprintAndSourceTimestamp) {
mesh::LogicalMessageCache<4> cache;
uint8_t first[MAX_HASH_SIZE];
uint8_t second[MAX_HASH_SIZE];
makeFingerprint(first, 0x11);
makeFingerprint(second, 0x22);
ASSERT_TRUE(cache.remember(first, 100U, 500U));
uint32_t mapped = 0;
EXPECT_TRUE(cache.find(first, 100U, &mapped));
EXPECT_EQ(500U, mapped);
EXPECT_FALSE(cache.find(first, 101U));
EXPECT_FALSE(cache.find(second, 100U));
}
TEST(LogicalMessageCache, RepeatedRememberUpdatesMappedTimestamp) {
mesh::LogicalMessageCache<2> cache;
uint8_t fingerprint[MAX_HASH_SIZE];
makeFingerprint(fingerprint, 0x33);
ASSERT_TRUE(cache.remember(fingerprint, 7U, 70U));
ASSERT_TRUE(cache.remember(fingerprint, 7U, 71U));
uint32_t mapped = 0;
EXPECT_TRUE(cache.find(fingerprint, 7U, &mapped));
EXPECT_EQ(71U, mapped);
}
TEST(LogicalMessageCache, RoundRobinReplacementIsBounded) {
mesh::LogicalMessageCache<2> cache;
uint8_t first[MAX_HASH_SIZE];
uint8_t second[MAX_HASH_SIZE];
uint8_t third[MAX_HASH_SIZE];
makeFingerprint(first, 0x41);
makeFingerprint(second, 0x42);
makeFingerprint(third, 0x43);
ASSERT_TRUE(cache.remember(first, 1U));
ASSERT_TRUE(cache.remember(second, 2U));
ASSERT_TRUE(cache.remember(third, 3U));
EXPECT_FALSE(cache.find(first, 1U));
EXPECT_TRUE(cache.find(second, 2U));
EXPECT_TRUE(cache.find(third, 3U));
}
TEST(LogicalMessageCache, ClearForgetsAllEntries) {
mesh::LogicalMessageCache<2> cache;
uint8_t fingerprint[MAX_HASH_SIZE];
makeFingerprint(fingerprint, 0x55);
ASSERT_TRUE(cache.remember(fingerprint, 9U, 90U));
cache.clear();
EXPECT_FALSE(cache.find(fingerprint, 9U));
}
TEST(LogicalMessageCache, ExactOlderRetrySurvivesNewerMessages) {
mesh::LogicalMessageCache<4> cache;
uint8_t first[MAX_HASH_SIZE];
uint8_t second[MAX_HASH_SIZE];
makeFingerprint(first, 0x61);
makeFingerprint(second, 0x62);
uint32_t latest = 0;
EXPECT_EQ(mesh::LogicalMessageCache<4>::ReplayDecision::NewMessage,
cache.classifyAndRemember(first, 100U, latest));
EXPECT_EQ(mesh::LogicalMessageCache<4>::ReplayDecision::NewMessage,
cache.classifyAndRemember(second, 101U, latest));
EXPECT_EQ(101U, latest);
EXPECT_EQ(mesh::LogicalMessageCache<4>::ReplayDecision::ExactRetry,
cache.classifyAndRemember(first, 100U, latest));
EXPECT_EQ(101U, latest);
}
TEST(LogicalMessageCache, RejectsOldUnseenAndSameTimestampMismatches) {
mesh::LogicalMessageCache<4> cache;
uint8_t accepted[MAX_HASH_SIZE];
uint8_t mismatched[MAX_HASH_SIZE];
makeFingerprint(accepted, 0x71);
makeFingerprint(mismatched, 0x72);
uint32_t latest = 200U;
EXPECT_EQ(mesh::LogicalMessageCache<4>::ReplayDecision::StaleOrMismatched,
cache.classifyAndRemember(mismatched, 199U, latest));
EXPECT_EQ(mesh::LogicalMessageCache<4>::ReplayDecision::StaleOrMismatched,
cache.classifyAndRemember(mismatched, 200U, latest));
EXPECT_EQ(mesh::LogicalMessageCache<4>::ReplayDecision::NewMessage,
cache.classifyAndRemember(accepted, 201U, latest));
}
TEST(LogicalMessageCache, RejectsNullFingerprint) {
mesh::LogicalMessageCache<2> cache;
EXPECT_FALSE(cache.remember(NULL, 1U, 2U));
EXPECT_FALSE(cache.find(NULL, 1U));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,85 @@
#include <gtest/gtest.h>
#include <helpers/RemoteCliReplyCache.h>
TEST(RemoteCliReplyCache, ReplaysOnlyTheSameAuthenticatedRequest) {
mesh::RemoteCliReplyCache cache;
uint8_t alice[PUB_KEY_SIZE] = {};
uint8_t bob[PUB_KEY_SIZE] = {};
alice[0] = 0xA1;
bob[0] = 0xB2;
const char command[] = "set repeat off";
const uint32_t fingerprint =
mesh::RemoteCliReplyCache::fingerprint(command, strlen(command));
EXPECT_FALSE(cache.matches(alice, 123U, fingerprint));
ASSERT_TRUE(cache.remember(alice, 123U, fingerprint, "OK"));
EXPECT_TRUE(cache.matches(alice, 123U, fingerprint));
EXPECT_STREQ("OK", cache.response());
EXPECT_TRUE(cache.hasResponse());
EXPECT_FALSE(cache.matches(bob, 123U, fingerprint));
EXPECT_FALSE(cache.matches(alice, 124U, fingerprint));
EXPECT_FALSE(cache.matches(
alice, 123U,
mesh::RemoteCliReplyCache::fingerprint("set repeat on", 13)));
}
TEST(RemoteCliReplyCache, OwnsAndReplacesTheRememberedResponse) {
mesh::RemoteCliReplyCache cache;
uint8_t first_sender[PUB_KEY_SIZE] = {};
uint8_t sender[PUB_KEY_SIZE] = {};
first_sender[0] = 0x11;
memcpy(sender, first_sender, sizeof(sender));
char response[] = "first";
ASSERT_TRUE(cache.remember(sender, 1U, 10U, response));
response[0] = 'X';
sender[0] = 0x22;
EXPECT_STREQ("first", cache.response());
ASSERT_TRUE(cache.remember(sender, 2U, 20U, "second"));
EXPECT_FALSE(cache.matches(first_sender, 1U, 10U));
EXPECT_TRUE(cache.matches(sender, 2U, 20U));
EXPECT_STREQ("second", cache.response());
}
TEST(RemoteCliReplyCache, EmptyResponseStillMarksRequestComplete) {
mesh::RemoteCliReplyCache cache;
uint8_t sender[PUB_KEY_SIZE] = {};
ASSERT_TRUE(cache.remember(sender, 9U, 99U, ""));
EXPECT_TRUE(cache.matches(sender, 9U, 99U));
EXPECT_TRUE(cache.isValid());
EXPECT_FALSE(cache.hasResponse());
EXPECT_STREQ("", cache.response());
}
TEST(RemoteCliReplyCache, TruncatesToTheOnAirReplyLimit) {
mesh::RemoteCliReplyCache cache;
uint8_t sender[PUB_KEY_SIZE] = {};
char response[mesh::RemoteCliReplyCache::MAX_REPLY_TEXT + 20];
memset(response, 'x', sizeof(response));
response[sizeof(response) - 1] = 0;
ASSERT_TRUE(cache.remember(sender, 7U, 77U, response));
EXPECT_EQ(mesh::RemoteCliReplyCache::MAX_REPLY_TEXT,
strlen(cache.response()));
}
TEST(RemoteCliReplyCache, ClearForgetsTheRequestAndResponse) {
mesh::RemoteCliReplyCache cache;
uint8_t sender[PUB_KEY_SIZE] = {};
ASSERT_TRUE(cache.remember(sender, 1U, 2U, "OK"));
cache.clear();
EXPECT_FALSE(cache.isValid());
EXPECT_FALSE(cache.matches(sender, 1U, 2U));
EXPECT_STREQ("", cache.response());
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+3
View File
@@ -17,10 +17,13 @@ build_src_filter = ${stm32_base.build_src_filter}
[env:RAK_3x72_repeater]
extends = rak3x72
board_upload.maximum_size = 245760 ; 240 KiB app, 16 KiB LittleFS
build_unflags = -Os
build_flags = ${rak3x72.build_flags}
-Oz ; strongest size profile preserves the deployed 16 KiB filesystem boundary
-flto
-fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary
-fipa-pta
-fmerge-all-constants
-fno-semantic-interposition
-fno-unwind-tables
-fno-asynchronous-unwind-tables
+3
View File
@@ -18,10 +18,13 @@ build_src_filter = ${stm32_base.build_src_filter}
[env:Tiny_Relay_repeater]
extends = Tiny_Relay
board_upload.maximum_size = 245760 ; 240 KiB app, 16 KiB LittleFS
build_unflags = -Os
build_flags = ${Tiny_Relay.build_flags}
-Oz ; strongest size profile preserves the deployed 16 KiB filesystem boundary
-flto ; retain headroom for current repeater safety features
-fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary
-fipa-pta
-fmerge-all-constants
-fno-semantic-interposition
-fno-unwind-tables
-fno-asynchronous-unwind-tables
+3
View File
@@ -18,10 +18,13 @@ build_src_filter = ${stm32_base.build_src_filter}
[env:wio-e5_repeater]
extends = lora_e5
board_upload.maximum_size = 245760 ; 240 KiB app, 16 KiB LittleFS
build_unflags = -Os
build_flags = ${lora_e5.build_flags}
-Oz ; strongest size profile preserves the deployed 16 KiB filesystem boundary
-flto
-fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary
-fipa-pta
-fmerge-all-constants
-fno-semantic-interposition
-fno-unwind-tables
-fno-asynchronous-unwind-tables
+1
View File
@@ -26,6 +26,7 @@ build_flags = ${lora_e5_mini.build_flags}
-flto
-fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary
-fipa-pta
-fmerge-all-constants
-fno-semantic-interposition
-fno-unwind-tables
-fno-asynchronous-unwind-tables