Extend repeater flood control and logging reliability

This commit is contained in:
mikecarper
2026-07-31 01:01:59 -07:00
parent 683fa64c8b
commit a05acd72be
12 changed files with 2132 additions and 192 deletions
+219 -46
View File
@@ -236,6 +236,18 @@ refresh is already in flight, the scope pass is queued behind it.
## Logging
Builds compiled with `MESH_PACKET_LOGGING` emit one `RAW:` line for every
received radio frame. Serial output uses backpressure: if a connected host
temporarily stops reading, packet processing waits for USB transmit space
instead of silently omitting the record. A disconnected host cannot retain an
unbounded capture, so logging deployments should keep the reader attached and
draining the serial port.
Ordinary `-logging-` artifacts keep packet logging separate from LoRa OTA.
Use the separately named `-ota-` artifact when LoRa OTA is required. A
`-full-logging-ota-` artifact is intentionally the exception: FULL profiles
include every supported feature.
### Begin capture of rx log to node storage
**Usage:** `log start`
@@ -958,6 +970,15 @@ get clock.sync.status
**Description:** When enabled, the radio performs a hardware Channel Activity Detection scan before transmitting and defers if the channel is busy. Runs independently of `int.thresh` - either, both, or none may be active.
The Cascade firmware profile defaults CAD to `on`; target-default builds continue to default it to `off`.
The repeater applies the saved toggle to the radio during its periodic
noise-floor service. With one runnable packet, a busy result uses the normal
CAD retry delay and allows roughly four seconds of continuous busy results.
As the runnable transmit queue grows, both delays are divided by its depth:
retry spacing will not fall below 50 ms and the busy ceiling will not fall
below 500 ms. Reaching the busy ceiling records a CAD-timeout error and
attempts the next queued transmission rather than waiting indefinitely.
Future-scheduled packets do not accelerate CAD. `get cad` includes the
hardware busy-result count.
**Parameters:**
- `on|off`: Enable or disable hardware CAD
@@ -1188,12 +1209,12 @@ del flood.channel.block.2
---
#### Force a transport scope onto unscoped floods
#### Force a transport scope onto floods
**Usage:**
- `get flood.channel.scope`
- `get flood.channel.scope.<n>`
- `set flood.channel.scope <channel|txt:*|login:*|other:*> <region>`
- `set flood.channel.scope.<n> <channel|txt:*|login:*|other:*> <region>`
- `set flood.channel.scope <channel|txt:*|login:*|other:*> <region> [tx=slow]`
- `set flood.channel.scope.<n> <channel|txt:*|login:*|other:*> <region> [tx=slow]`
- `del flood.channel.scope.<n>`
- `del flood.channel.scope all`
@@ -1214,6 +1235,11 @@ del flood.channel.block.2
raw custom. TRACE is deliberately exempt from forced-scope wildcards.
- `region`: Existing named region with a usable transport key. A unique region
name prefix is accepted; wildcard region `*` is not a scope target.
- `tx=slow`: Optional. Use an effective inbound `rxdelay` base of
`max(2, configured rxdelay * 2)`, keep normal outbound queue priority, and
schedule retransmission with the maximum supported `txdelay` factor of
`2.0` after changing the scope. The default is fast; `tx=fast` may be
supplied explicitly when replacing a slow row.
**Default:** No forced scopes.
@@ -1228,42 +1254,56 @@ slot. The three wildcard classes are independent and consume one slot each.
form for row detail. Keyed rows are displayed by the first four bytes of their
derived channel hash because channel secrets are never returned.
This acts on received, unscoped `ROUTE_TYPE_FLOOD` packets. For `GRP_TXT` and
`GRP_DATA`, all exact channel-key rows are tried first and must validate the
packet MAC/decryption. A row whose target region is missing or unusable is
skipped; later exact rows and then `txt:*` are tried. Exact keyed rows with a
usable target therefore beat `txt:*` regardless of slot number. `login:*` and
`other:*` select their non-overlapping outer-type families without decrypting
the payload. Duplicate rows within the same class are permitted with numbered
slots; the lowest usable slot wins.
This acts on received `ROUTE_TYPE_FLOOD` and
`ROUTE_TYPE_TRANSPORT_FLOOD` packets. An unscoped packet gains the configured
scope; an already-scoped packet has its existing transport codes replaced. For
`GRP_TXT` and `GRP_DATA`, all exact channel-key rows are tried first and must
validate the packet MAC/decryption. A row whose target region is missing or
unusable is skipped; later exact rows and then `txt:*` are tried. Exact keyed
rows with a usable target therefore beat `txt:*` regardless of slot number.
`login:*` and `other:*` select their non-overlapping outer-type families
without decrypting the payload. Duplicate rows within the same class are
permitted with numbered slots; the lowest usable slot wins.
Standard traceroute is direct-routed and is therefore outside this flood-only
table. A custom flood-form `TRACE` is also left unchanged: no wildcard adds a
scope, an existing transport code is preserved, and region/unknown-code gates
do not block it.
On a match, the repeater changes the route to
`ROUTE_TYPE_TRANSPORT_FLOOD`, computes transport code 0 from the selected
region key and packet payload, and leaves transport code 1 as zero. This occurs
before region enforcement, forwarding filters, and the seen-packet lookup.
Already-scoped packets and direct routes are never rewritten. The rewritten
packet is no longer subject to `flood.max.unscoped`, but remains subject to
normal payload handling, `flood.max`, region allow/deny, `flood.filter`,
On a match, the repeater sets the route to `ROUTE_TYPE_TRANSPORT_FLOOD`,
computes transport code 0 from the selected region key and packet payload, and
sets transport code 1 to zero. This occurs before region enforcement,
forwarding filters, and the seen-packet lookup. For an already-scoped packet,
the selected code replaces both incoming transport-code fields. Direct routes
are never rewritten. A packet converted from unscoped is no longer subject to
`flood.max.unscoped`; all rewritten packets remain subject to normal payload
handling, `flood.max`, region allow/deny, `flood.filter`,
`flood.channel.block`, loop detection, and moderation. Assigning a scope does
not make a packet type forwardable if the core would otherwise reject it.
not make a packet type forwardable if the core would otherwise reject it. By
default, if the selected scope differs and the rewritten packet is accepted
for forwarding, its initial retransmission uses zero `txdelay` and the highest
outbound queue priority so the newly scoped copy can win at the next hop.
Adding `tx=slow` uses an effective inbound `rxdelay` base of
`max(2, configured rxdelay * 2)`, keeps the ordinary queue priority, and uses
the maximum `txdelay` factor of `2.0` for the retransmission. As with ordinary
`txdelay`, the actual transmit delay is randomized from zero through the
resulting window; factor `2.0` gives a maximum of ten packet airtimes.
Neither mode preempts an active radio transmission or bypasses CAD and
airtime-budget limits. Selecting the scope already carried by the packet is a
no-op and does not grant special transmit treatment.
If a row's target region has been removed or has no usable key, the repeater
tries the next applicable row. For group packets this means later authenticated
exact rows followed by `txt:*`; wildcard duplicates likewise fall through to
the next usable slot. The packet remains unscoped only when no usable mapping
exists.
the next usable slot. When no usable mapping exists, the packet retains its
original unscoped or scoped route.
LoRa OTA remains functional when `other:*` is configured. OTA packets are
given that region's transport code, but the OTA handler still accepts and
re-floods them during the temporary-radio window. The target region must allow
flooding. The OTA core itself is dormant outside that window; no default flood
filter row is needed for that behavior. Forced scope does not make OTA operate
outside the window.
given that region's transport code, replacing an existing code when necessary,
but the OTA handler still accepts and re-floods them during the temporary-radio
window. The target region must allow flooding. The OTA core itself is dormant
outside that window; no default flood filter row is needed for that behavior.
Forced scope does not make OTA operate outside the window.
**Capacity cost:** Each slot uses 36 bytes of runtime RAM and persistent
storage, plus a 5-byte file header. The four-slot minimum uses 144 bytes RAM and
@@ -1283,9 +1323,9 @@ same payload later arrives scoped, unscoped, or through a different region, it
is still the same seen packet. `TRACE` is the exception only in that its
encoded `path_len` byte is also hashed.
While equivalent non-TRACE flood copies are waiting in `rxdelay`, the normal receive-quality
timing still selects the packet to process, but that winner receives a scope
from the queued scoped copies with the same dedupe identity. If the copies
While equivalent non-TRACE flood copies are waiting in `rxdelay`, the normal
receive-quality timing still selects the packet to process, but that winner
receives a scope from the queued scoped copies with the same dedupe identity. If the copies
carry different locally allowed scopes, the scope from the shortest received
path wins. Unknown and denied transport codes are not candidates and therefore
cannot overwrite an unscoped winner. With equal path lengths, the deeper child
@@ -1299,12 +1339,17 @@ scope and path for arbitration. It can only use copies still present in
is excluded from scope arbitration entirely, so rxdelay never adds or replaces
a trace transport code.
A packet that matches a fast `flood.channel.scope` or `flood.filter scope=`
action and needs its scope changed bypasses the inbound `rxdelay` queue. A
`tx=slow` row remains in that queue with twice the configured base, floored at
`2.0`, and participates in normal queued-copy scope arbitration.
**Examples:**
```text
region put west
region save
set flood.channel.scope #local west
set flood.channel.scope.2 txt:* west
set flood.channel.scope.2 txt:* west tx=slow
set flood.channel.scope.3 login:* west
set flood.channel.scope.4 other:* west
get flood.channel.scope
@@ -1314,7 +1359,54 @@ del flood.channel.scope.2
---
#### Filter flood packets by payload type and hop
#### Require valid incoming scopes only on selected channels
**Usage:**
- `get flood.channel.scope.require`
- `get flood.channel.scope.require.<n>`
- `set flood.channel.scope.require <public|#channel|128/256-bit-key>`
- `set flood.channel.scope.require.<n> <public|#channel|128/256-bit-key>`
- `del flood.channel.scope.require.<n>`
- `del flood.channel.scope.require all`
**Default:** Empty; normal global region enforcement remains active.
Once this table contains a row, received flood `GRP_TXT` and `GRP_DATA`
packets use selective region enforcement. A packet authenticating against a
listed channel key must already carry a transport scope matching a locally
flood-allowed region. Listed channels arriving unscoped, with an unknown code,
or with a denied region are not retransmitted. This tests the original
incoming scope before any `flood.channel.scope` or `flood.filter scope=`
rewrite. Those rewrite actions are skipped for a rejected listed channel, so
they cannot rescue it or grant special receive/transmit timing.
Other group channels bypass the region/unknown-code gate while the table is
active. They remain subject to every other forwarding control, including
`repeat`, `flood.max*`, packet filters, channel blocks, loop detection, payload
validation, and moderation. Non-channel flood payload types retain normal
global region enforcement.
Channel matching validates the packet MAC/decryption with the configured key;
the visible one-byte channel hash is only a prefilter. Public hashtag channels
use their derived public key. Without `.n`, setting an existing key updates it
and a new key uses the first empty slot. Numbered `set` replaces that slot.
`get ...<n>` reports a four-byte derived prefix and key size without exposing
the key. The table uses the same build-dependent slot count as
`flood.channel.scope`.
Remote ACL permission `4` can manage this table. Deleting its final row
restores normal global region enforcement for group channels.
**Example:**
```text
set flood.channel.scope.require #bot
get flood.channel.scope.require
get flood.channel.scope.require.1
```
---
#### Filter flood packets by payload type, hop count, and path
For setup guidance, interactions with the existing forwarding controls, and
worked moderation examples, see [Repeater Flood Filtering and Moderation](flood_filtering.md).
@@ -1322,8 +1414,14 @@ worked moderation examples, see [Repeater Flood Filtering and Moderation](flood_
**Usage:**
- `get flood.filter`
- `get flood.filter.<n>`
- `set flood.filter <type> [hops] [suspend=tempradio]`
- `set flood.filter.<n> <type> [hops] [suspend=tempradio]`
- `get flood.filter.blacklist`
- `get flood.filter.blacklist.<n>`
- `set flood.filter.blacklist <ID[,ID...]>`
- `set flood.filter.blacklist.<n> <ID[,ID...]>`
- `del flood.filter.blacklist`
- `del flood.filter.blacklist.<n>`
- `set flood.filter <type> [hops] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]`
- `set flood.filter.<n> <type> [hops] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]`
- `del flood.filter.<n>`
- `del flood.filter all`
@@ -1340,6 +1438,27 @@ worked moderation examples, see [Repeater Flood Filtering and Moderation](flood_
the saved range as `all`.
- `suspend=tempradio`: Optional. Skip this row only while the temporary radio
is actually active.
- `scope=<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
region gate. An incoming transport scope must resolve to a locally allowed
region; an unscoped flood must be allowed by the wildcard region. The check
occurs before any scope rewrite during this receive pass.
- `tx=slow`: Optional and valid only with `scope=`. Use an effective inbound
`rxdelay` base of `max(2, configured rxdelay * 2)`, keep normal outbound
queue priority, and retransmit with the maximum supported `txdelay` factor
of `2.0`. Scope rows default to fast; `tx=fast` explicitly restores that
default when replacing a slow row.
- `path=blacklist`: Optional unordered path condition. The persistent blacklist
contains up to 255 unique 3-byte repeater IDs on ESP32 builds and 18 on other
builds, each written as six hexadecimal digits. A packet with 3-byte path
hashes matches after one exact ID hit. A packet with 2-byte path hashes
matches after two path entries match the first two bytes of listed IDs.
Packets with 1-byte path hashes never match this condition. Each received
path entry is counted at most once.
The payload names follow the [MeshCore packet-format allocation](https://docs.meshcore.io/packet_format/):
@@ -1367,12 +1486,39 @@ The payload names follow the [MeshCore packet-format allocation](https://docs.me
`ROUTE_TYPE_FLOOD` (`0x01`, unscoped flood). Direct routes `0x02` and `0x03`
are never affected.
**Behavior:** A match prevents retransmission by this repeater. The packet is
still received and can still be logged. Rules are persistent. While the
temporary radio is active, only rows explicitly marked `suspend=tempradio` are
skipped. `tempradio` is a radio state, not an OTA mode; normal payload types can
also use the temporary channel. Other rows remain in force. A malformed
persisted table fails open (no general rules are applied).
**Behavior:** A row with `path=blacklist` must meet the path condition as well
as its payload-type and hop-range conditions. Blacklist IDs can occur anywhere
in the received path and their configured order is irrelevant. A matching row
without `scope=` prevents retransmission by this
repeater. A matching row with `scope=` instead sets or replaces the packet's
transport scope and does not block it. The lowest-numbered matching scope row
wins; matching drop rows remain independent and can still block the rewritten
packet. Scope rewriting happens before region enforcement and is trusted even
when its name is absent from the local region list. It does not bypass
`repeat`, `flood.max`, other drop rows, channel blocking, loop detection, or
moderation.
With `require=region`, a failed check makes that scope row ineligible. It leaves
the packet unchanged and does not set the filter-scope trust bypass, so an
unknown or denied incoming region is rejected normally unless another
independent scope rule rewrites it. Later eligible filter scope rows may still
match.
By default, when a scope row will change the packet's transport codes, the
packet bypasses inbound `rxdelay`; its retransmission then uses zero `txdelay`
and the highest outbound queue priority. With `tx=slow`, the rewrite instead
uses an effective inbound `rxdelay` base of
`max(2, configured rxdelay * 2)`, normal queue priority, and the maximum
`txdelay` factor of `2.0`. The actual randomized transmit wait ranges from zero
to ten packet airtimes. Selecting the scope already carried is a no-op and
does not grant special treatment. An active radio transmission is not
preempted, and CAD and airtime-budget limits still apply.
The packet is still received and can still be logged. Rules are persistent.
While the temporary radio is active, only rows explicitly marked
`suspend=tempradio` are skipped. `tempradio` is a radio state, not an OTA mode;
normal payload types can also use the temporary channel. Other rows remain in
force. A malformed persisted table fails open (no general rules are applied).
**Default row:** Repeater firmware seeds a new flood-filter table with
`ota all suspend=tempradio` in slot 1. This blocks repeated LoRa OTA (`0x0C`)
@@ -1388,20 +1534,38 @@ set flood.filter.1 0x0C all suspend=tempradio
Omitting `all` is equivalent. Omit `.1` as well to reuse an identical rule or
the first empty slot instead of replacing slot 1.
**Remote-admin protection:** `flood.filter` cannot block `anon_req`, `path`, or
**Remote-admin protection:** Drop rows cannot block `anon_req`, `path`, or
`response` at received hop counts `0-6`; those login-capable types become
filterable at hop `7`. Flood `txt_msg` cannot be blocked at hops `0-4` and
becomes filterable at hop `5`. `req`, `ack`, and multipart ACK have no special
blockable at hop `7`. Flood `txt_msg` cannot be blocked at hops `0-4` and
becomes blockable at hop `5`. Scope-setting rows are non-blocking and may apply
inside these protected ranges. `req`, `ack`, and multipart ACK have no special
protection. Transit repeaters cannot decrypt these packets to distinguish an
admin exchange from ordinary peer traffic, so each floor necessarily covers
all flood packets of that outer type. These exceptions apply only to
`flood.filter`; `repeat`, `flood.max*`, region, loop-detection, and other
`flood.filter` drop actions; `repeat`, `flood.max*`, loop-detection, and other
forwarding gates remain authoritative.
Without `.n`, `set` returns an identical existing rule, including its suspension
setting, or uses the first empty slot. With `.n`, it replaces that slot.
Without `.n`, `set` reuses an existing rule with the same match, scope,
requirement, and suspension settings, or uses the first empty slot. This lets
`tx=slow` or `tx=fast` change that rule's timing without creating a duplicate.
With `.n`, it replaces that slot.
`get flood.filter` gives a compact list. Use `get flood.filter.<n>` for full
details, including `suspend=tempradio`.
details, including `path=blacklist`, `scope=`, and `suspend=tempradio`.
The detail output also includes `require=region` and `tx=slow` when enabled.
The blacklist and filter rows are persisted separately. Replacing or deleting
the blacklist does not delete rows containing `path=blacklist`; such rows
remain dormant while the list is empty. Path hashes are truncated routing
identifiers, not authenticated identities, so this is a forwarding signal
rather than proof that a particular repeater handled a packet.
The unnumbered blacklist `set` replaces the whole list and accepts up to 18 IDs
so it fits every CLI transport. Numbered `set` writes up to 18 consecutive
entries beginning at an existing slot or exactly the next slot, allowing an
ESP32 list to grow to 255 entries in batches. Numbered deletion compacts
subsequent slots. The unnumbered `get` reports the total and as many leading
IDs as fit in one reply; use numbered `get` to inspect entries beyond that
reply.
Standard traceroute is direct-routed and therefore outside `flood.filter`
entirely. For a custom flood-form trace, catch-all `any` rows are deliberately
@@ -1415,6 +1579,15 @@ set flood.filter.2 PAYLOAD_TYPE_ADVERT 6+
set flood.filter ota 2-4
set flood.filter.1 0x0C all suspend=tempradio
set flood.filter grp_data all suspend=tempradio
set flood.filter grp_txt all scope=local
set flood.filter grp_data all scope=local require=region
set flood.filter grp_data all path=blacklist scope=local tx=slow
set flood.filter.blacklist A1B2C3,D4E5F6,112233
set flood.filter.blacklist.4 445566
set flood.filter.blacklist.19 778899,AABBCC,DDEEFF
set flood.filter any all path=blacklist
get flood.filter.blacklist
get flood.filter.blacklist.4
set flood.filter any 12+
get flood.filter
get flood.filter.2
+213 -57
View File
@@ -1,8 +1,9 @@
# Repeater Flood Filtering and Moderation
This guide explains the Keymind repeater forwarding filters. The filters decide
whether this repeater retransmits a packet. They do not stop local reception,
packet logging, or MQTT observation.
whether this repeater retransmits a packet and can assign a transport scope
before that decision. They do not stop local reception, packet logging, or MQTT
observation.
Only flood routes are filtered:
@@ -28,6 +29,7 @@ get flood.channel.data
get flood.channel.data.hops
get flood.channel.block
get flood.channel.scope
get flood.channel.scope.require
get flood.filter
get flood.moderation
```
@@ -39,14 +41,14 @@ temporary radio is not synonymous with OTA and can carry normal packet types
too. A corrupt or truncated table fails open, so corrupt storage does not
silently enable blocking.
## Force unscoped floods into a transport scope
## Force floods into a transport scope
`flood.channel.scope` can convert a received unscoped flood into a
transport-scoped flood before this repeater forwards it:
`flood.channel.scope` can add a scope to a received unscoped flood or replace
the scope of a transport-scoped flood before this repeater forwards it:
```text
set flood.channel.scope <channel|txt:*|login:*|other:*> <region>
set flood.channel.scope.<slot> <channel|txt:*|login:*|other:*> <region>
set flood.channel.scope <channel|txt:*|login:*|other:*> <region> [tx=slow]
set flood.channel.scope.<slot> <channel|txt:*|login:*|other:*> <region> [tx=slow]
get flood.channel.scope
get flood.channel.scope.<slot>
del flood.channel.scope.<slot>
@@ -74,29 +76,41 @@ number. A missing or unusable target is skipped, so later exact rows and then
the applicable wildcard are tried. Within a wildcard class, the lowest usable
duplicate row wins.
On a successful match, the route changes from `ROUTE_TYPE_FLOOD` to
`ROUTE_TYPE_TRANSPORT_FLOOD`. Transport code 0 is calculated with the target
region key over the payload type and payload; code 1 remains zero. The change
happens before region enforcement, forwarding filters, and deduplication.
Consequently `flood.max.unscoped` no longer applies to the rewritten packet,
but `flood.max`, target-region permissions, `flood.filter`, channel blocking,
loop detection, and moderation still do.
On a successful match, an unscoped route changes from `ROUTE_TYPE_FLOOD` to
`ROUTE_TYPE_TRANSPORT_FLOOD`; an already-scoped route remains transport-flood
but receives replacement codes. Transport code 0 is calculated with the target
region key over the payload type and payload, and code 1 becomes zero. The
change happens before region enforcement, forwarding filters, and
deduplication. Consequently `flood.max.unscoped` no longer applies to a packet
converted from unscoped, while `flood.max`, target-region permissions,
`flood.filter`, channel blocking, loop detection, and moderation still apply
to every rewritten packet. By default, if the selected scope differs and the
rewritten packet passes those checks, its initial retransmission uses zero
`txdelay` and the highest outbound queue priority so the newly scoped copy can
win at the next hop. Add `tx=slow` to use an effective inbound `rxdelay` base
of `max(2, configured rxdelay * 2)`, retain normal outbound queue priority, and
force the maximum `txdelay` factor of `2.0`. The actual transmit delay is still
randomized, from zero through ten packet airtimes.
It does not preempt an active radio transmission or bypass CAD and
airtime-budget limits. Selecting the scope already present is a no-op and does
not grant special treatment.
Already-scoped floods and direct routes are never rewritten. TRACE is never
rewritten even in flood form; its existing code, if any, is preserved and it
bypasses region/unknown-code enforcement. Scope assignment also does not
override normal payload validation or make an otherwise non-forwardable packet
type forwardable.
Direct routes are never rewritten. TRACE is never rewritten even in flood
form; its existing code, if any, is preserved and it bypasses
region/unknown-code enforcement. Scope assignment also does not override
normal payload validation or make an otherwise non-forwardable packet type
forwardable.
LoRa OTA (`0x0C`) falls under `other:*`. A matching row adds the selected
transport code, but OTA still operates normally during the temporary-radio
window because the OTA handler accepts both unscoped and transport-scoped flood
routes. The target region must allow flooding. A new repeater also seeds
`ota all suspend=tempradio` in flood-filter slot 1. That visible rule blocks OTA
forwarding at every received hop outside temporary-radio operation and is
skipped while temporary radio is active. Independently, the OTA core refuses
OTA receive, relay, and transmit outside an actually active temporary-radio
window, even if the seeded row is deleted or replaced.
transport code or replaces the existing one, but OTA still operates normally
during the temporary-radio window because the OTA handler accepts both
unscoped and transport-scoped flood routes. The target region must allow
flooding. A new repeater also seeds `ota all suspend=tempradio` in flood-filter
slot 1. That visible rule blocks OTA forwarding at every received hop outside
temporary-radio operation and is skipped while temporary radio is active.
Independently, the OTA core refuses OTA receive, relay, and transmit outside an
actually active temporary-radio window, even if the seeded row is deleted or
replaced.
Capacity is selected at build time:
@@ -112,6 +126,52 @@ The region map still has 32 named-region entries. Large ESP32 tables can map
many channels to the same targets, but cannot reference more than 32 distinct
configured region names.
## Require valid incoming scopes only on selected channels
`flood.channel.scope.require` changes region enforcement for received flood
`GRP_TXT` and `GRP_DATA` packets from a global policy to a channel opt-in
policy:
```text
set flood.channel.scope.require <public|#channel|128/256-bit-key>
set flood.channel.scope.require.<slot> <public|#channel|128/256-bit-key>
get flood.channel.scope.require
get flood.channel.scope.require.<slot>
del flood.channel.scope.require.<slot>
del flood.channel.scope.require all
```
An empty table preserves the normal global region behavior. Once at least one
row exists, a group-channel packet that authenticates against a listed key must
arrive as `ROUTE_TYPE_TRANSPORT_FLOOD` with a transport code matching a locally
flood-allowed region. An unscoped packet, an unknown transport code, or a code
for a denied region is not retransmitted. The check uses the original incoming
scope before `flood.channel.scope` or `flood.filter scope=` can rewrite it.
Those rewrite actions are skipped for a rejected listed channel, so they
cannot rescue it or grant special receive/transmit timing.
Group-channel packets that do not authenticate against any listed key bypass
the region/unknown-code forwarding gate. They still pass through `repeat`,
`flood.max*`, `flood.filter`, `flood.channel.block`, loop detection, payload
validation, and moderation. Non-channel flood payload types retain the normal
global region behavior. A one-byte channel-hash collision is only a prefilter;
the packet must also pass MAC validation/decryption with the configured key.
Without `.slot`, setting an existing key updates its row and a new key uses the
first empty row. Numbered `set` replaces that slot. Detail output displays only
the first four derived hash bytes and key size, never the secret. The table has
the same build-dependent slot count as `flood.channel.scope`; each row consumes
34 bytes of RAM and storage, plus a five-byte file header. ACL permission `4`
can manage it.
For example, this requires an allowed incoming scope on `#bot`, while every
other group channel bypasses region enforcement:
```text
set flood.channel.scope.require #bot
get flood.channel.scope.require
```
### Interaction with duplicate detection
The seen-packet hash contains the payload type and exact payload bytes. It does
@@ -121,9 +181,10 @@ unscoped packet and the same packet after this repeater adds a transport code
are the same duplicate. A later copy with a different scope is also the same
duplicate; changing or adding scope cannot evade the seen table.
When equivalent non-TRACE flood copies overlap in `rxdelay`, the normal receive-quality timing
still chooses the packet to process, but that winner takes a scope from the
queued copies whose transport code matches an allowed region in this repeater.
When equivalent non-TRACE flood copies overlap in `rxdelay`, the normal
receive-quality timing still chooses the packet to process, but that winner
takes a scope from the queued copies whose transport code matches an allowed
region in this repeater.
Unknown and denied scopes are ignored. If eligible copies have different
scopes, the shortest received path supplies the scope. Equal path lengths
prefer the deepest matching child region (the narrowest configured scope). A
@@ -136,23 +197,94 @@ comparison. It applies only while copies are queued and cannot alter a copy
already processed into the seen table. TRACE is excluded from scope arbitration
entirely, so rxdelay never adds or replaces a trace transport code.
## Filter by payload type and received hop count
A packet that already matches a configured fast `flood.channel.scope` or
`flood.filter scope=` action and needs its scope changed bypasses this inbound
`rxdelay` queue entirely. A `tx=slow` row remains in the queue with twice the
configured base, floored at `2.0`, and participates in normal queued-copy
scope arbitration.
Use `flood.filter` when the packet type and its current path length are enough
to make the decision:
## Filter by payload type, received hop count, and path
Use `flood.filter` when the packet type, current path length, or listed path
identifiers are enough to make the decision:
```text
set flood.filter <type> [hops] [suspend=tempradio]
set flood.filter.<slot> <type> [hops] [suspend=tempradio]
set flood.filter.blacklist <ID[,ID...]>
set flood.filter.blacklist.<slot> <ID[,ID...]>
get flood.filter.blacklist
get flood.filter.blacklist.<slot>
del flood.filter.blacklist
del flood.filter.blacklist.<slot>
set flood.filter <type> [hops] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]
set flood.filter.<slot> <type> [hops] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]
get flood.filter
get flood.filter.<slot>
del flood.filter.<slot>
del flood.filter all
```
Without a slot number, `set` reuses an identical rule, including its suspension
setting, or selects the first empty slot. With a slot number, it replaces that
slot. Omitting the hop expression means `all` (`0-63`).
The blacklist holds up to 255 unique 3-byte repeater IDs on ESP32 builds and
18 on other builds. Each is written as six hexadecimal digits. For example:
```text
set flood.filter.blacklist A1B2C3,D4E5F6,112233
set flood.filter.blacklist.4 445566
set flood.filter any all path=blacklist
```
An unnumbered `set` replaces the list with up to 18 IDs, the largest command
that fits every CLI transport. A numbered `set` writes a batch of up to 18 IDs
beginning at an existing slot or the next consecutive slot. This is how an
ESP32 list grows beyond 18. Deleting a numbered entry compacts the entries
after it. Unnumbered `get` reports the total and prints the leading IDs that
fit; numbered `get` retrieves one specific entry.
`path=blacklist` is an unordered precondition on that row. With 3-byte path
hashes, one or more exact blacklist hits qualifies the packet. With 2-byte
path hashes, two or more received path entries must match the first two bytes
of listed IDs. Each received entry is counted at most once. A 1-byte path
never qualifies. The IDs may occur anywhere in the received path; neither
their list order nor their path order matters.
Without a slot number, `set` reuses a rule with the same match, scope,
requirement, and suspension settings, or selects the first empty slot. This
lets `tx=slow` or `tx=fast` change that rule's timing without creating a
duplicate. With a slot number, it replaces that slot. Omitting the hop
expression means `all` (`0-63`).
A row without `scope=` is the existing drop action. A row with `scope=` is a
scope-setting action instead: it adds transport scope to an unscoped packet or
replaces the codes on an already-scoped packet. The scope name is normalized
with a leading `#`, and the 128-bit transport key is derived directly from that
hashtag. The name does not need to exist in the region list and is not added to
it. Public names up to 30 characters are accepted; private `$` scopes are not.
Add `require=region` to a scope row when rewriting must not rescue a packet
that the incoming-region gate would reject. The repeater evaluates the packet's
original route before any rewrite in that receive pass. An incoming transport
scope must match a locally allowed region; an unscoped flood must be allowed by
the wildcard region. If the check fails, that scope row is skipped, the filter
does not grant its region bypass, and the unchanged packet is allowed to fail
normal region enforcement. Other independently configured scope rows still
apply in their normal order.
When multiple scope rows match, the lowest-numbered row wins. Scope rows do not
approve a packet: any matching drop row and every remaining forwarding gate can
still reject it. A filter-assigned scope is trusted without local region-list
validation, but `repeat`, `flood.max`, channel blocking, loop detection, and
moderation still apply. By default, a changed scope bypasses inbound `rxdelay`,
then is retransmitted with zero `txdelay` and the highest outbound queue
priority. Add `tx=slow` to use an effective inbound `rxdelay` base of
`max(2, configured rxdelay * 2)`, retain normal queue priority, and force the
maximum `txdelay` factor of `2.0`; the randomized transmit delay ranges from
zero through ten packet airtimes. `tx=fast` explicitly restores the default.
Selecting the scope already present does not grant special treatment. Active
radio transmission, CAD, and airtime-budget limits are unchanged.
The blacklist and rule table are persisted separately. Deleting the blacklist
leaves `path=blacklist` rows in place but dormant until IDs are configured
again. Path hashes are truncated routing identifiers and are not authenticated
proof that a particular repeater handled a packet.
On first initialization, flood-filter slot 1 is seeded with:
@@ -169,10 +301,10 @@ on a temporary channel.
Suspension does not approve a packet or bypass the rest of the filter table. It
skips that row, then evaluation continues with the next row and the remaining
forwarding gates. An ordinary `any` row therefore still applies during the
temporary-radio window, subject to the short-path remote-admin protection
below. `repeat`, `flood.max*`, region, loop-detection, and the OTA subsystem's
own hop limit also remain in force.
forwarding gates. An ordinary drop `any` row therefore still applies during
the temporary-radio window, subject to the short-path remote-admin protection
below. `repeat`, `flood.max*`, region handling, loop detection, and the OTA
subsystem's own hop limit also remain in force.
Standard traceroute uses direct routing and never enters `flood.filter`. For a
custom flood-form trace, catch-all `any` rows are deliberately ignored; only an
@@ -181,7 +313,7 @@ flood-forward TRACE packets.
### Remote administration cannot be type-filtered on short paths
`flood.filter` uses two minimum filterable hop counts:
`flood.filter` drop actions use two minimum filterable hop counts:
- `anon_req`, `path`, and `response` cannot be blocked at received hops `0-6`;
configured rules begin applying at hop `7`.
@@ -189,7 +321,8 @@ flood-forward TRACE packets.
begin applying at hop `5`.
`req`, `ack`, and multipart ACK have no special floor and remain filterable from
hop `0`.
hop `0`. Scope-setting rows do not block traffic and may apply within the
protected ranges.
A flooded login starts as `ANON_REQ`; its reply is commonly a `PATH` packet
carrying an encrypted `RESPONSE`. Before a direct return path is established,
@@ -199,9 +332,8 @@ from ordinary peer packets with the same outer type. Each hop floor therefore
covers the complete outer packet class, not only packets that ultimately
authenticate as administrators.
This protects only against the configurable `flood.filter` table. It does not
override `repeat`, `flood.max*`, region enforcement, loop detection, or other
forwarding gates.
This protects only against configurable `flood.filter` drop actions. It does
not override `repeat`, `flood.max*`, loop detection, or other forwarding gates.
Hop expressions are based on the path count when this repeater receives the
packet:
@@ -225,6 +357,19 @@ set flood.filter.2 advert 6+
# Keep LoRa OTA floods from crossing this repeater at path counts 2 through 4.
set flood.filter.3 ota 2-4
# Assign #local scope to group text without requiring #local in the region map.
set flood.filter grp_txt all scope=local
# Rewrite only packets whose incoming region was already acceptable.
set flood.filter grp_data all scope=local require=region
# Rewrite matching blacklisted paths without fast-tracking their retransmission.
set flood.filter grp_data all path=blacklist scope=local tx=slow
# Drop matching flood types after the unordered path blacklist qualifies.
set flood.filter.blacklist A1B2C3,D4E5F6,112233
set flood.filter any all path=blacklist
# Apply a hard ceiling to flood payload types at 12 or more received hops.
set flood.filter any 12+
```
@@ -362,13 +507,21 @@ the packet contains all path entries listed by the rule.
A flood packet is retransmitted only if it passes every applicable control. In
other words, the controls combine as deny rules:
1. `flood.channel.scope` first rewrites a matching unscoped flood packet.
2. `repeat`, `flood.max*`, and the channel-data gate are checked.
3. `flood.filter` checks payload type and hop range, subject to the login floor
of `7` and flood-text floor of `5` described above.
4. `flood.channel.block` checks keyed channels.
5. Region and loop-detection rules are checked.
6. `flood.moderation` checks decrypted group text, username, rate, hops, and path.
1. `flood.channel.scope.require` evaluates a listed group channel against the
original incoming scope; unlisted group channels bypass the later region
gate while the table is active.
2. `flood.channel.scope` adds or replaces the scope of a matching flood packet.
3. A matching `flood.filter scope=` row may replace that result; its scope does
not require a region-list entry.
4. `repeat`, `flood.max*`, and the channel-data gate are checked.
5. `flood.filter` drop rows check payload type and hop range, subject to the
login floor of `7` and flood-text floor of `5` described above.
6. `flood.channel.block` checks keyed channels.
7. Region and loop-detection rules are checked; a filter-assigned scope is
already trusted when it has no region-list match, except that it cannot
rescue a channel rejected by `flood.channel.scope.require`.
8. `flood.moderation` checks decrypted group text, username, rate, hops, and
path.
The first denial is enough to prevent retransmission. A packet that is denied
can still appear in local logs or MQTT output. Moderation runs last because its
@@ -388,11 +541,14 @@ A filter manager can read non-secret operational status and manage `repeat`,
`flood.filter*`, and `flood.moderation*`. Delegated `get` access uses an
explicit allowlist: it cannot retrieve guest, WiFi, MQTT, bridge, or other
credentials, and it cannot change regions, ACL entries, radio settings, or
unrelated administrator settings.
unrelated administrator settings. Because `flood.filter scope=` derives a
public hashtag key directly, a filter manager can configure that action without
region-manager permission; it still cannot edit the region hierarchy.
ACL permission `4`, the region/scope-manager role, can read, add, replace, and
delete `flood.channel.scope` rows and manage regions. This lets the same
delegate create target regions and assign them to forced-scope rows.
delete `flood.channel.scope` and `flood.channel.scope.require` rows and manage
regions. This lets the same delegate create target regions, assign forced
scopes, and select the channels that require valid incoming scopes.
## Security limitations
+3 -2
View File
@@ -83,8 +83,9 @@ set flood.retry.ignore none
| `flood.channel.data.hops` | Separate hop gate used only when `flood.channel.data` is `off`; `all` blocks `GRP_DATA` at any hop count, `1`-`7` repeats at that hop count or lower and blocks longer paths. | `get flood.channel.data.hops`, `set flood.channel.data.hops <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` | Assigns a transport-region scope to received unscoped floods. Exact channel keys beat `txt:*`; `login:*` covers the remote-login family, and `other:*` covers every remaining flood type except TRACE, including OTA. TRACE remains unchanged across scope boundaries. ACL permission `4` can manage the table. | `get flood.channel.scope[.n]`, `set flood.channel.scope[.n] <channel|txt:*|login:*|other:*> <region>`, `del flood.channel.scope.<n>|all` | `set flood.channel.scope login:* west` |
| `flood.filter` | Persistent repeater-only forwarding rules for flood routes `0x00`/`0x01`, selected by payload type and optional received hop count/range (omitted means `all`). New tables seed slot 1 with `ota all suspend=tempradio`; only rows marked `suspend=tempradio` are skipped during temporary-radio operation. Login-capable `anon_req`/`path`/`response` types become filterable at hop `7`; flood `txt_msg` becomes filterable at hop `5`. Standard direct traceroute, other direct routing, and local receive/logging are unchanged. | `get flood.filter[.n]`, `set flood.filter[.n] <type> [N|N+|N-M|all] [suspend=tempradio]`, `del flood.filter.<n>|all` | `set flood.filter.1 0x0C all suspend=tempradio` |
| `flood.channel.scope` | Adds a transport-region scope to received unscoped floods or replaces the scope of already-scoped floods. By default, a changed packet bypasses inbound `rxdelay` and is forwarded at the highest outbound queue priority with zero initial `txdelay`, so the selected scope can win at the next hop. `tx=slow` uses an effective inbound `rxdelay` base of `max(2, configured rxdelay * 2)`, retains normal queue priority, and forces the maximum `txdelay` factor of `2.0`; its actual randomized transmit delay ranges from zero through ten packet airtimes. An already-matching scope is a no-op. Exact channel keys beat `txt:*`; `login:*` covers the remote-login family, and `other:*` covers every remaining flood type except TRACE, including OTA. TRACE remains unchanged across scope boundaries. ACL permission `4` can manage the table. | `get flood.channel.scope[.n]`, `set flood.channel.scope[.n] <channel|txt:*|login:*|other:*> <region> [tx=slow]`, `del flood.channel.scope.<n>|all` | `set flood.channel.scope login:* west tx=slow` |
| `flood.channel.scope.require` | Switches group-channel region enforcement to opt-in when the table has entries. Listed authenticated `GRP_TXT`/`GRP_DATA` channels must arrive already scoped to a locally allowed region; unscoped, unknown, or denied incoming scopes are dropped before any rewrite can rescue them. Unlisted group channels bypass only the region gate and retain all other forwarding controls. An empty table preserves global region behavior; non-channel payloads are unchanged. ACL permission `4` can manage the table. | `get flood.channel.scope.require[.n]`, `set flood.channel.scope.require[.n] <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.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` |
File diff suppressed because it is too large Load Diff
+62 -2
View File
@@ -127,6 +127,14 @@ struct NeighbourInfo {
#endif
#define FLOOD_PACKET_FILTER_ANY_TYPE 0xFF
#define FLOOD_PACKET_FILTER_MAX_HOPS 63
#define FLOOD_PACKET_FILTER_SCOPE_NAME_LEN 32
#if defined(ESP32)
#define FLOOD_PACKET_FILTER_BLACKLIST_MAX 255
#else
#define FLOOD_PACKET_FILTER_BLACKLIST_MAX 18
#endif
#define FLOOD_PACKET_FILTER_BLACKLIST_REPLACE_MAX 18
#define FLOOD_PACKET_FILTER_PATH_ID_SIZE 3
#ifndef FLOOD_CHANNEL_SCOPE_SLOTS
#if defined(ESP32)
@@ -140,6 +148,9 @@ struct NeighbourInfo {
#define FLOOD_CHANNEL_SCOPE_TXT_ANY 0
#define FLOOD_CHANNEL_SCOPE_LOGIN_ANY 1
#define FLOOD_CHANNEL_SCOPE_OTHER_ANY 2
#ifndef FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS
#define FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS FLOOD_CHANNEL_SCOPE_SLOTS
#endif
#ifndef FLOOD_GROUP_MODERATION_SLOTS
#define FLOOD_GROUP_MODERATION_SLOTS 16
@@ -230,6 +241,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
RegionMap region_map, temp_map;
RegionEntry* load_stack[8];
RegionEntry* recv_pkt_region;
bool recv_pkt_filter_scope_set;
bool recv_pkt_channel_scope_bypass;
bool recv_pkt_channel_scope_rejected;
TransportKey default_scope;
RateLimiter discover_limiter, anon_limiter;
struct FloodRetryBridgeState {
@@ -259,6 +273,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
uint8_t min_hops;
uint8_t max_hops;
bool suspend_on_temp_radio;
char scope_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN];
bool match_blacklisted_path;
bool scope_requires_region_match;
bool scope_uses_slow_timing;
};
struct FloodChannelScopeEntry {
uint16_t region_id; // zero means unused
@@ -267,6 +285,11 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
uint8_t channel_hash;
uint8_t secret[PUB_KEY_SIZE];
};
struct FloodChannelScopeRequireEntry {
uint8_t key_len; // zero means unused
uint8_t channel_hash;
uint8_t secret[PUB_KEY_SIZE];
};
struct FloodGroupModerationEntry {
bool active;
uint8_t key_len;
@@ -295,7 +318,12 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
FloodRetryBridgeReachability flood_retry_bridge_reachability[FLOOD_RETRY_BRIDGE_BUCKETS + 1];
FloodChannelBlockEntry flood_channel_blocks[FLOOD_CHANNEL_BLOCK_SLOTS];
FloodPacketFilterEntry flood_packet_filters[FLOOD_PACKET_FILTER_SLOTS];
uint8_t flood_packet_filter_blacklist_count;
uint8_t flood_packet_filter_blacklist[FLOOD_PACKET_FILTER_BLACKLIST_MAX]
[FLOOD_PACKET_FILTER_PATH_ID_SIZE];
FloodChannelScopeEntry flood_channel_scopes[FLOOD_CHANNEL_SCOPE_SLOTS];
FloodChannelScopeRequireEntry
flood_channel_scope_requirements[FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS];
FloodGroupModerationEntry flood_group_moderation[FLOOD_GROUP_MODERATION_SLOTS];
ClockSyncSample clock_sync_samples[CLOCK_SYNC_SAMPLE_SLOTS];
bool clock_sync_mesh_enabled;
@@ -489,22 +517,45 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
bool shouldBlockFloodChannelForward(const mesh::Packet* packet) const;
void loadFloodPacketFilters();
bool saveFloodPacketFilters();
void loadFloodPacketFilterBlacklist();
bool saveFloodPacketFilterBlacklist();
void seedDefaultFloodPacketFilters();
bool floodPacketFilterBlacklistMatches(const mesh::Packet* packet) const;
bool floodPacketFilterMatches(const FloodPacketFilterEntry& entry,
const mesh::Packet* packet) const;
bool applyFloodPacketFilterScope(mesh::Packet* packet, bool incoming_region_allowed,
bool& scope_set, bool& fast_track,
bool log_change = true);
bool shouldBlockFloodPacketForward(const mesh::Packet* packet) const;
void formatFloodPacketFilters(const char* args, char* reply) const;
void formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len) const;
void setFloodPacketFilter(const char* args, char* reply);
void deleteFloodPacketFilter(const char* args, char* reply);
void formatFloodPacketFilterBlacklist(const char* args, char* reply) const;
void setFloodPacketFilterBlacklist(const char* args, char* reply);
void deleteFloodPacketFilterBlacklist(const char* args, char* reply);
void loadFloodChannelScopes();
bool saveFloodChannelScopes(bool empty_table = false);
bool applyFloodChannelScopeTarget(mesh::Packet* packet, const FloodChannelScopeEntry& entry);
bool applyFloodChannelScope(mesh::Packet* packet);
bool applyFloodChannelScopeTarget(mesh::Packet* packet, const FloodChannelScopeEntry& entry,
bool& scope_changed, bool& fast_track,
bool log_change = true);
bool applyFloodChannelScope(mesh::Packet* packet, bool& fast_track,
bool log_change = true);
static uint8_t scoreFloodTransportScope(const mesh::Packet* packet, void* context);
uint8_t getFloodTransportScopeDepth(const mesh::Packet* packet);
void formatFloodChannelScopes(const char* args, char* reply);
void formatFloodChannelScopeDetail(int index, char* reply, size_t reply_len);
void setFloodChannelScope(const char* args, char* reply);
void deleteFloodChannelScope(const char* args, char* reply);
void loadFloodChannelScopeRequirements();
bool saveFloodChannelScopeRequirements(bool empty_table = false);
bool findFloodChannelScopeRequirementMatch(const mesh::Packet* packet,
bool& table_active) const;
void formatFloodChannelScopeRequirements(const char* args, char* reply);
void formatFloodChannelScopeRequirementDetail(int index, char* reply,
size_t reply_len) const;
void setFloodChannelScopeRequirement(const char* args, char* reply);
void deleteFloodChannelScopeRequirement(const char* args, char* reply);
void loadFloodGroupModeration();
bool saveFloodGroupModeration();
bool shouldBlockFloodGroupTextForward(const mesh::Packet* packet);
@@ -554,6 +605,9 @@ protected:
float getAirtimeBudgetFactor() const override {
return _prefs.airtime_factor;
}
bool getCADEnabled() const override {
return _prefs.cad_enabled;
}
bool allowPacketForward(const mesh::Packet* packet) override;
const char* getLogDateTime() override;
@@ -563,8 +617,14 @@ protected:
void logTx(mesh::Packet* pkt, int len) override;
void logTxFail(mesh::Packet* pkt, int len) override;
int calcRxDelay(float score, uint32_t air_time) const override;
int calcRxDelayForPacket(const mesh::Packet* packet, float score,
uint32_t air_time) override;
bool shouldBypassRxDelay(const mesh::Packet* packet) override;
bool evaluateScopeRewriteTiming(const mesh::Packet* packet,
bool& fast_track);
uint32_t getRetransmitDelay(const mesh::Packet* packet) override;
uint32_t getSlowScopeRetransmitDelay(const mesh::Packet* packet);
uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override;
bool supportsBasicRetryConfig() const override { return true; }
bool supportsAdvancedRetryConfig() const override { return true; }
+5 -6
View File
@@ -220,12 +220,11 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t
void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
#if MESH_PACKET_LOGGING
if (Serial.availableForWrite() > 0) {
Serial.print(getLogDateTime());
Serial.print(" RAW: ");
mesh::Utils::printHex(Serial, raw, len);
Serial.println();
}
// Logging builds prefer backpressure over silently losing a packet record.
Serial.print(getLogDateTime());
Serial.print(" RAW: ");
mesh::Utils::printHex(Serial, raw, len);
Serial.println();
#endif
#ifdef WITH_MQTT_BRIDGE
+22 -4
View File
@@ -23,6 +23,18 @@ namespace mesh {
#define RADIO_LIVENESS_HARD_MS (12UL * 60UL * 60UL * 1000UL)
#endif
#define MIN_CAD_FAIL_RETRY_DELAY_MS 50UL
#define MIN_CAD_FAIL_MAX_DURATION_MS 500UL
static uint32_t scaleCADDelayForQueue(uint32_t normal_delay, int ready_count,
uint32_t minimum_delay) {
if (ready_count <= 1) return normal_delay;
uint32_t scaled = normal_delay / (uint32_t)ready_count;
uint32_t floor = normal_delay < minimum_delay ? normal_delay : minimum_delay;
return scaled < floor ? floor : scaled;
}
void Dispatcher::begin() {
n_sent_flood = n_sent_direct = 0;
n_recv_flood = n_recv_direct = 0;
@@ -427,7 +439,7 @@ void Dispatcher::checkRecv() {
if (pkt->isRouteFlood()) {
n_recv_flood++;
int _delay = calcRxDelay(score, air_time);
int _delay = calcRxDelayForPacket(pkt, score, air_time);
if (_delay < 50) {
MESH_DEBUG_PRINTLN("%s Dispatcher::checkRecv(), score delay below threshold (%d)", getLogDateTime(), _delay);
processRecvPacket(pkt); // is below the score delay threshold, so process immediately
@@ -492,18 +504,24 @@ void Dispatcher::checkSend() {
? _radio->isReceivingPassive(getRetryInterferenceMargin())
: _radio->isReceiving();
if (channel_busy) {
const uint32_t cad_now = _ms->getMillis();
const int ready_count = _mgr->getOutboundCount(cad_now);
if (cad_busy_start == 0) {
cad_busy_start = _ms->getMillis(); // record when CAD busy state started
cad_busy_start = cad_now; // record when CAD busy state started
}
if (_ms->getMillis() - cad_busy_start > getCADFailMaxDuration()) {
const uint32_t max_busy_duration = scaleCADDelayForQueue(
getCADFailMaxDuration(), ready_count, MIN_CAD_FAIL_MAX_DURATION_MS);
if (cad_now - cad_busy_start > max_busy_duration) {
_err_flags |= ERR_EVENT_CAD_TIMEOUT;
MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): CAD busy max duration reached!", getLogDateTime());
// channel activity has gone on too long... (Radio might be in a bad state)
// force the pending transmit below...
} else {
next_tx_time = futureMillis(getCADFailRetryDelay());
const uint32_t retry_delay = scaleCADDelayForQueue(
getCADFailRetryDelay(), ready_count, MIN_CAD_FAIL_RETRY_DELAY_MS);
next_tx_time = futureMillis(retry_delay);
return;
}
}
+8
View File
@@ -293,6 +293,14 @@ protected:
virtual float getAirtimeBudgetFactor() const;
virtual int calcRxDelay(float score, uint32_t air_time) const;
virtual bool shouldBypassRxDelay(const Packet* packet) {
(void)packet;
return false;
}
virtual int calcRxDelayForPacket(const Packet* packet, float score,
uint32_t air_time) {
return shouldBypassRxDelay(packet) ? 0 : calcRxDelay(score, air_time);
}
virtual uint32_t getCADFailRetryDelay() const;
virtual uint32_t getCADFailMaxDuration() const;
virtual uint8_t getDefaultTxCodingRate() const { return 0; }
+118
View File
@@ -0,0 +1,118 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include <Packet.h>
namespace FloodFilterPolicy {
static constexpr uint8_t BLACKLIST_ID_SIZE = 3;
static constexpr uint8_t SLOW_SCOPE_FLAG = 0x80;
static constexpr uint8_t SLOW_SCOPE_TX_DELAY_FACTOR = 2;
static constexpr float SLOW_SCOPE_RX_DELAY_MIN = 2.0f;
static constexpr uint32_t MAX_DISPATCH_DELAY = 0xFFFFFF;
enum ChannelScopeGate {
CHANNEL_SCOPE_USE_GLOBAL,
CHANNEL_SCOPE_BYPASS,
CHANNEL_SCOPE_REQUIRED_ALLOWED,
CHANNEL_SCOPE_REQUIRED_REJECTED,
};
inline ChannelScopeGate channelScopeGate(bool table_active,
bool is_group_channel_packet,
bool channel_requires_scope,
bool incoming_is_scoped,
bool incoming_region_allowed) {
if (!table_active || !is_group_channel_packet) {
return CHANNEL_SCOPE_USE_GLOBAL;
}
if (!channel_requires_scope) return CHANNEL_SCOPE_BYPASS;
return incoming_is_scoped && incoming_region_allowed
? CHANNEL_SCOPE_REQUIRED_ALLOWED
: CHANNEL_SCOPE_REQUIRED_REJECTED;
}
inline uint8_t encodeScopeSelector(uint8_t selector, bool slow) {
return slow ? (uint8_t)(selector | SLOW_SCOPE_FLAG) : selector;
}
inline uint8_t scopeSelectorValue(uint8_t stored_selector) {
return stored_selector & (uint8_t)~SLOW_SCOPE_FLAG;
}
inline bool scopeUsesSlowTiming(uint8_t stored_selector) {
return (stored_selector & SLOW_SCOPE_FLAG) != 0;
}
inline bool fastTrackScopeChange(bool scope_changed, bool slow) {
return scope_changed && !slow;
}
inline float slowScopeRxDelayBase(float configured_base) {
float doubled = configured_base * 2.0f;
return doubled < SLOW_SCOPE_RX_DELAY_MIN
? SLOW_SCOPE_RX_DELAY_MIN : doubled;
}
inline uint32_t slowScopeMaxDelay(uint32_t airtime_millis) {
const uint64_t delay = (uint64_t)airtime_millis
* SLOW_SCOPE_TX_DELAY_FACTOR * 5U;
return delay > MAX_DISPATCH_DELAY
? MAX_DISPATCH_DELAY : (uint32_t)delay;
}
inline uint8_t blacklistMatchThreshold(uint8_t path_hash_size) {
if (path_hash_size == BLACKLIST_ID_SIZE) return 1;
if (path_hash_size == 2) return 2;
return 0;
}
inline bool pathMatchesBlacklist(const mesh::Packet* packet,
const uint8_t* blacklist,
uint8_t blacklist_count) {
if (packet == NULL || blacklist == NULL || blacklist_count == 0) return false;
const uint8_t hash_size = packet->getPathHashSize();
const uint8_t required = blacklistMatchThreshold(hash_size);
if (required == 0) return false;
uint8_t matches = 0;
const uint8_t path_hops = packet->getPathHashCount();
for (uint8_t hop = 0; hop < path_hops; hop++) {
const uint8_t* path_id = &packet->path[hop * hash_size];
for (uint8_t i = 0; i < blacklist_count; i++) {
const uint8_t* listed_id = &blacklist[i * BLACKLIST_ID_SIZE];
if (memcmp(path_id, listed_id, hash_size) == 0) {
matches++;
if (matches >= required) return true;
break;
}
}
}
return false;
}
inline bool scopeRuleAllowed(bool requires_region_match,
bool incoming_region_allowed) {
return !requires_region_match || incoming_region_allowed;
}
inline bool setTransportScope(mesh::Packet* packet, uint16_t transport_code) {
if (packet == NULL || !packet->isRouteFlood()) return false;
const bool changed = packet->getRouteType() != ROUTE_TYPE_TRANSPORT_FLOOD
|| packet->transport_codes[0] != transport_code
|| packet->transport_codes[1] != 0;
if (!changed) return false;
packet->header =
(packet->header & (uint8_t)~PH_ROUTE_MASK) | ROUTE_TYPE_TRANSPORT_FLOOD;
packet->transport_codes[0] = transport_code;
packet->transport_codes[1] = 0;
return true;
}
} // namespace FloodFilterPolicy
+1
View File
@@ -34,6 +34,7 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_mqtt_prefs_codec` | `src/helpers/MQTTPrefsStorage.h`, `src/helpers/MQTTPrefsCodec.h` | binary pre-slot/3-slot/6-slot migration fixtures; v1 header integrity; downgrade preservation |
| `test_mqtt_prefs_atomic_store` | `src/helpers/MQTTPrefsAtomicStore.h` | transactional MQTT writes and legacy `/node_prefs` handoff; exact short-write detection; begin/finish/rename failure cleanup; original-file preservation |
| `test_mqtt_payload_builder` | `src/helpers/MQTTPayloadBuilder.cpp` | status/packet/raw JSON contracts; optional fields; escaping; RX metrics and path; score handling; exact buffer bounds; maximum representative payloads |
| `test_flood_filter_policy` | `src/helpers/FloodFilterPolicy.h` | unordered 3-byte and 2-byte-prefix blacklist matching; match thresholds; repeated path-entry semantics; `require=region` and per-channel scope-gate truth tables; fast/slow scope timing; adding, replacing, and preserving packet scope |
| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) |
## Conventions (and how to add a suite)
@@ -0,0 +1,256 @@
#include <gtest/gtest.h>
#include <helpers/FloodFilterPolicy.h>
static mesh::Packet makeFloodPacket(uint8_t hash_size,
const uint8_t* path,
uint8_t path_hops) {
mesh::Packet packet;
packet.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT);
packet.setPathHashSizeAndCount(hash_size, path_hops);
memcpy(packet.path, path, hash_size * path_hops);
packet.payload_len = 0;
return packet;
}
TEST(FloodFilterBlacklist, ThreeBytePathMatchesOneExactIdInAnyPosition) {
const uint8_t blacklist[][3] = {
{0x10, 0x20, 0x30},
{0xAA, 0xBB, 0xCC},
{0x70, 0x80, 0x90},
};
const uint8_t path[] = {
0x01, 0x02, 0x03,
0x70, 0x80, 0x90,
0x04, 0x05, 0x06,
};
mesh::Packet packet = makeFloodPacket(3, path, 3);
EXPECT_TRUE(FloodFilterPolicy::pathMatchesBlacklist(
&packet, &blacklist[0][0], 3));
}
TEST(FloodFilterBlacklist, ThreeBytePathRequiresAnExactThirdByte) {
const uint8_t blacklist[][3] = {{0xAA, 0xBB, 0xCC}};
const uint8_t path[] = {0xAA, 0xBB, 0xCD};
mesh::Packet packet = makeFloodPacket(3, path, 1);
EXPECT_FALSE(FloodFilterPolicy::pathMatchesBlacklist(
&packet, &blacklist[0][0], 1));
}
TEST(FloodFilterBlacklist, TwoBytePathRequiresTwoMatchingEntries) {
const uint8_t blacklist[][3] = {
{0xAA, 0xBB, 0x01},
{0x11, 0x22, 0x02},
};
const uint8_t one_match_path[] = {
0xAA, 0xBB,
0x33, 0x44,
};
const uint8_t two_match_path[] = {
0x11, 0x22,
0x33, 0x44,
0xAA, 0xBB,
};
mesh::Packet one_match = makeFloodPacket(2, one_match_path, 2);
mesh::Packet two_matches = makeFloodPacket(2, two_match_path, 3);
EXPECT_FALSE(FloodFilterPolicy::pathMatchesBlacklist(
&one_match, &blacklist[0][0], 2));
EXPECT_TRUE(FloodFilterPolicy::pathMatchesBlacklist(
&two_matches, &blacklist[0][0], 2));
}
TEST(FloodFilterBlacklist, OnePathEntryCountsOnceWhenIdsSharePrefix) {
const uint8_t blacklist[][3] = {
{0xAA, 0xBB, 0x01},
{0xAA, 0xBB, 0x02},
};
const uint8_t path[] = {0xAA, 0xBB};
mesh::Packet packet = makeFloodPacket(2, path, 1);
EXPECT_FALSE(FloodFilterPolicy::pathMatchesBlacklist(
&packet, &blacklist[0][0], 2));
}
TEST(FloodFilterBlacklist, RepeatedTwoBytePathEntriesCountSeparately) {
const uint8_t blacklist[][3] = {{0xAA, 0xBB, 0x01}};
const uint8_t path[] = {
0xAA, 0xBB,
0xAA, 0xBB,
};
mesh::Packet packet = makeFloodPacket(2, path, 2);
EXPECT_TRUE(FloodFilterPolicy::pathMatchesBlacklist(
&packet, &blacklist[0][0], 1));
}
TEST(FloodFilterBlacklist, OneBytePathsNeverMatch) {
const uint8_t blacklist[][3] = {{0xAA, 0xBB, 0xCC}};
const uint8_t path[] = {0xAA, 0xAA, 0xAA};
mesh::Packet packet = makeFloodPacket(1, path, 3);
EXPECT_FALSE(FloodFilterPolicy::pathMatchesBlacklist(
&packet, &blacklist[0][0], 1));
}
TEST(FloodFilterBlacklist, Esp32MaximumListIncludesTheLastEntry) {
uint8_t blacklist[255][3];
for (uint16_t i = 0; i < 255; i++) {
blacklist[i][0] = (uint8_t)i;
blacklist[i][1] = (uint8_t)(i ^ 0xA5);
blacklist[i][2] = (uint8_t)(i ^ 0x5A);
}
const uint8_t path[] = {
blacklist[254][0],
blacklist[254][1],
blacklist[254][2],
};
mesh::Packet packet = makeFloodPacket(3, path, 1);
EXPECT_TRUE(FloodFilterPolicy::pathMatchesBlacklist(
&packet, &blacklist[0][0], 255));
}
TEST(FloodFilterScope, RegionRequirementHasTheExpectedTruthTable) {
EXPECT_TRUE(FloodFilterPolicy::scopeRuleAllowed(false, false));
EXPECT_TRUE(FloodFilterPolicy::scopeRuleAllowed(false, true));
EXPECT_FALSE(FloodFilterPolicy::scopeRuleAllowed(true, false));
EXPECT_TRUE(FloodFilterPolicy::scopeRuleAllowed(true, true));
}
TEST(FloodFilterScope, SlowTimingFlagRoundTripsWithoutChangingSelector) {
const uint8_t selector = 16;
const uint8_t stored =
FloodFilterPolicy::encodeScopeSelector(selector, true);
EXPECT_EQ(selector, FloodFilterPolicy::scopeSelectorValue(stored));
EXPECT_TRUE(FloodFilterPolicy::scopeUsesSlowTiming(stored));
EXPECT_FALSE(FloodFilterPolicy::scopeUsesSlowTiming(
FloodFilterPolicy::encodeScopeSelector(selector, false)));
}
TEST(FloodFilterScope, OnlyChangedFastRulesReceiveFastTrackTreatment) {
EXPECT_TRUE(FloodFilterPolicy::fastTrackScopeChange(true, false));
EXPECT_FALSE(FloodFilterPolicy::fastTrackScopeChange(true, true));
EXPECT_FALSE(FloodFilterPolicy::fastTrackScopeChange(false, false));
EXPECT_FALSE(FloodFilterPolicy::fastTrackScopeChange(false, true));
}
TEST(FloodFilterScope, SlowTimingUsesMaximumTxDelayFactor) {
EXPECT_EQ(2, FloodFilterPolicy::SLOW_SCOPE_TX_DELAY_FACTOR);
EXPECT_EQ(1000U, FloodFilterPolicy::slowScopeMaxDelay(100));
EXPECT_EQ(FloodFilterPolicy::MAX_DISPATCH_DELAY,
FloodFilterPolicy::slowScopeMaxDelay(0xFFFFFFFFU));
}
TEST(FloodFilterScope, SlowTimingDoublesRxDelayBaseWithMinimumOfTwo) {
EXPECT_FLOAT_EQ(2.0f, FloodFilterPolicy::slowScopeRxDelayBase(0.0f));
EXPECT_FLOAT_EQ(2.0f, FloodFilterPolicy::slowScopeRxDelayBase(0.5f));
EXPECT_FLOAT_EQ(2.0f, FloodFilterPolicy::slowScopeRxDelayBase(1.0f));
EXPECT_FLOAT_EQ(4.0f, FloodFilterPolicy::slowScopeRxDelayBase(2.0f));
EXPECT_FLOAT_EQ(20.0f, FloodFilterPolicy::slowScopeRxDelayBase(10.0f));
EXPECT_FLOAT_EQ(24.0f, FloodFilterPolicy::slowScopeRxDelayBase(12.0f));
EXPECT_FLOAT_EQ(40.0f, FloodFilterPolicy::slowScopeRxDelayBase(20.0f));
}
TEST(FloodFilterScope, ChannelRequirementTableKeepsLegacyGlobalBehaviorWhenEmpty) {
EXPECT_EQ(
FloodFilterPolicy::CHANNEL_SCOPE_USE_GLOBAL,
FloodFilterPolicy::channelScopeGate(false, true, true, false, false));
}
TEST(FloodFilterScope, ChannelRequirementTableDoesNotChangeOtherPayloadTypes) {
EXPECT_EQ(
FloodFilterPolicy::CHANNEL_SCOPE_USE_GLOBAL,
FloodFilterPolicy::channelScopeGate(true, false, false, false, false));
}
TEST(FloodFilterScope, UnlistedGroupChannelsBypassOnlyTheScopeGate) {
EXPECT_EQ(
FloodFilterPolicy::CHANNEL_SCOPE_BYPASS,
FloodFilterPolicy::channelScopeGate(true, true, false, false, false));
EXPECT_EQ(
FloodFilterPolicy::CHANNEL_SCOPE_BYPASS,
FloodFilterPolicy::channelScopeGate(true, true, false, true, false));
}
TEST(FloodFilterScope, ListedChannelsRequireAnAllowedIncomingScope) {
EXPECT_EQ(
FloodFilterPolicy::CHANNEL_SCOPE_REQUIRED_REJECTED,
FloodFilterPolicy::channelScopeGate(true, true, true, false, true));
EXPECT_EQ(
FloodFilterPolicy::CHANNEL_SCOPE_REQUIRED_REJECTED,
FloodFilterPolicy::channelScopeGate(true, true, true, true, false));
EXPECT_EQ(
FloodFilterPolicy::CHANNEL_SCOPE_REQUIRED_ALLOWED,
FloodFilterPolicy::channelScopeGate(true, true, true, true, true));
}
TEST(FloodFilterScope, AddsScopeToUnscopedFloodWithoutChangingPayloadType) {
mesh::Packet packet;
packet.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT);
packet.transport_codes[0] = 0;
packet.transport_codes[1] = 0;
EXPECT_TRUE(FloodFilterPolicy::setTransportScope(&packet, 0x1234));
EXPECT_EQ(ROUTE_TYPE_TRANSPORT_FLOOD, packet.getRouteType());
EXPECT_EQ(PAYLOAD_TYPE_GRP_TXT, packet.getPayloadType());
EXPECT_EQ(0x1234, packet.transport_codes[0]);
EXPECT_EQ(0, packet.transport_codes[1]);
}
TEST(FloodFilterScope, ReplacesExistingScopeAndClearsSecondaryCode) {
mesh::Packet packet;
packet.header =
ROUTE_TYPE_TRANSPORT_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT);
packet.transport_codes[0] = 0x1111;
packet.transport_codes[1] = 0x2222;
EXPECT_TRUE(FloodFilterPolicy::setTransportScope(&packet, 0x3333));
EXPECT_EQ(0x3333, packet.transport_codes[0]);
EXPECT_EQ(0, packet.transport_codes[1]);
}
TEST(FloodFilterScope, ExistingExactScopeIsANoOp) {
mesh::Packet packet;
packet.header =
ROUTE_TYPE_TRANSPORT_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT);
packet.transport_codes[0] = 0x3333;
packet.transport_codes[1] = 0;
EXPECT_FALSE(FloodFilterPolicy::setTransportScope(&packet, 0x3333));
EXPECT_EQ(0x3333, packet.transport_codes[0]);
EXPECT_EQ(0, packet.transport_codes[1]);
}
TEST(FloodFilterScope, MatchingPrimaryStillChangesWhenSecondaryIsPresent) {
mesh::Packet packet;
packet.header =
ROUTE_TYPE_TRANSPORT_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT);
packet.transport_codes[0] = 0x3333;
packet.transport_codes[1] = 0x4444;
EXPECT_TRUE(FloodFilterPolicy::setTransportScope(&packet, 0x3333));
EXPECT_EQ(0x3333, packet.transport_codes[0]);
EXPECT_EQ(0, packet.transport_codes[1]);
}
TEST(FloodFilterScope, DirectPacketsAreNeverConvertedToFlood) {
mesh::Packet packet;
packet.header =
ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT);
packet.transport_codes[0] = 0x1111;
packet.transport_codes[1] = 0x2222;
EXPECT_FALSE(FloodFilterPolicy::setTransportScope(&packet, 0x3333));
EXPECT_EQ(ROUTE_TYPE_DIRECT, packet.getRouteType());
EXPECT_EQ(0x1111, packet.transport_codes[0]);
EXPECT_EQ(0x2222, packet.transport_codes[1]);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -9,15 +9,25 @@ public:
class TestRadio : public mesh::Radio {
public:
uint8_t pending_rx[MAX_TRANS_UNIT];
int pending_rx_len = 0;
int send_starts = 0;
bool receiving = false;
bool in_recv_mode = true;
int soft_recoveries = 0;
int hard_recoveries = 0;
int cad_set_calls = 0;
bool cad_enabled = false;
unsigned long last_irq = 0;
bool recovery_result = true;
int recvRaw(uint8_t*, int) override { return 0; }
int recvRaw(uint8_t* dest, int max_len) override {
if (pending_rx_len == 0) return 0;
int len = pending_rx_len < max_len ? pending_rx_len : max_len;
memcpy(dest, pending_rx, len);
pending_rx_len = 0;
return len;
}
uint32_t getEstAirtimeFor(int) override { return 1; }
float packetScore(float, int) override { return 0; }
bool startSendRaw(const uint8_t*, int) override { send_starts++; return true; }
@@ -25,6 +35,10 @@ public:
void onSendFinished() override { }
bool isInRecvMode() const override { return in_recv_mode; }
bool isReceiving() override { return receiving; }
void setCADEnabled(bool enable) override {
cad_set_calls++;
cad_enabled = enable;
}
unsigned long getLastRadioInterruptMillis() const override { return last_irq; }
bool recoverRadio(bool hard) override {
if (hard) {
@@ -34,13 +48,35 @@ public:
}
return recovery_result;
}
void queueRx(const uint8_t* raw, int len) {
ASSERT_NE(raw, nullptr);
ASSERT_GT(len, 0);
ASSERT_LE(len, MAX_TRANS_UNIT);
memcpy(pending_rx, raw, len);
pending_rx_len = len;
}
};
class TestDispatcher : public mesh::Dispatcher {
RxReservePacketManager& manager;
protected:
mesh::DispatcherAction onRecvPacket(mesh::Packet*) override { return ACTION_RELEASE; }
mesh::DispatcherAction onRecvPacket(mesh::Packet*) override {
received_packets++;
return ACTION_RELEASE;
}
int calcRxDelay(float score, uint32_t air_time) const override {
return forced_rx_delay >= 0
? forced_rx_delay
: mesh::Dispatcher::calcRxDelay(score, air_time);
}
bool shouldBypassRxDelay(const mesh::Packet*) override {
return bypass_rx_delay;
}
bool getCADEnabled() const override {
return configured_cad_enabled;
}
void onSendFail(mesh::Packet* packet) override {
failed_packet = packet;
free_count_during_failure = manager.getFreeCount();
@@ -49,6 +85,10 @@ protected:
public:
mesh::Packet* failed_packet = nullptr;
int free_count_during_failure = -1;
int received_packets = 0;
int forced_rx_delay = -1;
bool bypass_rx_delay = false;
bool configured_cad_enabled = false;
TestDispatcher(TestRadio& radio, TestClock& clock, RxReservePacketManager& mgr)
: mesh::Dispatcher(radio, clock, mgr), manager(mgr) { }
@@ -124,6 +164,71 @@ TEST(Dispatcher, ParserAcceptsACompletePacketWithEmptyPayload) {
EXPECT_EQ(0U, packet.payload_len);
}
TEST(Dispatcher, FloodPacketWaitsForConfiguredRxDelayWithoutBypass) {
RxReservePacketManager manager(4, 1);
TestClock clock;
TestRadio radio;
TestDispatcher dispatcher(radio, clock, manager);
dispatcher.forced_rx_delay = 1000;
dispatcher.begin();
const uint8_t raw[] = {
ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT), 0, 0x42
};
clock.now = 100;
radio.queueRx(raw, sizeof(raw));
dispatcher.loop();
EXPECT_EQ(0, dispatcher.received_packets);
clock.now = 1099;
dispatcher.loop();
EXPECT_EQ(0, dispatcher.received_packets);
clock.now = 1100;
dispatcher.loop();
EXPECT_EQ(1, dispatcher.received_packets);
}
TEST(Dispatcher, ScopeRewriteHookBypassesConfiguredRxDelay) {
RxReservePacketManager manager(4, 1);
TestClock clock;
TestRadio radio;
TestDispatcher dispatcher(radio, clock, manager);
dispatcher.forced_rx_delay = 1000;
dispatcher.bypass_rx_delay = true;
dispatcher.begin();
const uint8_t raw[] = {
ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT), 0, 0x42
};
clock.now = 100;
radio.queueRx(raw, sizeof(raw));
dispatcher.loop();
EXPECT_EQ(1, dispatcher.received_packets);
EXPECT_EQ(4, manager.getFreeCount());
}
TEST(Dispatcher, ConfiguredCadStateIsPropagatedToTheRadio) {
RxReservePacketManager manager(4, 1);
TestClock clock;
TestRadio radio;
TestDispatcher dispatcher(radio, clock, manager);
dispatcher.configured_cad_enabled = true;
dispatcher.begin();
clock.now = 1;
dispatcher.loop();
EXPECT_EQ(1, radio.cad_set_calls);
EXPECT_TRUE(radio.cad_enabled);
dispatcher.configured_cad_enabled = false;
clock.now = 2002;
dispatcher.loop();
EXPECT_EQ(2, radio.cad_set_calls);
EXPECT_FALSE(radio.cad_enabled);
}
TEST(StaticPoolPacketManager, ReportsEarliestQueueTimesWithoutDequeuing) {
StaticPoolPacketManager manager(8);
mesh::Packet* later = manager.allocNew();
@@ -366,7 +471,7 @@ TEST(StaticPoolPacketManager, RxDelayEqualPathsPreferNarrowerLocalScope) {
manager.free(manager.getNextInbound(200));
}
TEST(Dispatcher, QueueWakeDelayIncludesSchedulesAndChannelBackoff) {
TEST(Dispatcher, QueueWakeDelayIncludesSchedulesAndAdaptiveChannelBackoff) {
RxReservePacketManager manager(8, 4);
TestClock clock;
clock.now = 100;
@@ -393,11 +498,62 @@ TEST(Dispatcher, QueueWakeDelayIncludesSchedulesAndChannelBackoff) {
EXPECT_EQ(200U, delay_millis);
EXPECT_FALSE(dispatcher.queuedWorkDue());
clock.now = 800;
mesh::Packet* second = dispatcher.obtainNewPacket();
ASSERT_NE(second, nullptr);
second->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT);
second->payload[0] = 0x43;
second->payload_len = 1;
ASSERT_TRUE(dispatcher.sendPacket(second, 0));
clock.now = 801;
dispatcher.loop();
ASSERT_TRUE(dispatcher.nextQueueWakeDelay(delay_millis));
EXPECT_EQ(100U, delay_millis);
EXPECT_FALSE(dispatcher.queuedWorkDue());
clock.now = 901;
radio.receiving = false;
EXPECT_TRUE(dispatcher.queuedWorkDue());
}
TEST(Dispatcher, GrowingQueueShortensCADBusyCeiling) {
RxReservePacketManager manager(8, 1);
TestClock clock;
clock.now = 100;
TestRadio radio;
radio.receiving = true;
TestDispatcher dispatcher(radio, clock, manager);
dispatcher.begin();
mesh::Packet* first = dispatcher.obtainNewPacket();
ASSERT_NE(first, nullptr);
first->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT);
first->payload[0] = 0x40;
first->payload_len = 1;
ASSERT_TRUE(dispatcher.sendPacket(first, 0));
clock.now = 101;
dispatcher.loop();
EXPECT_EQ(0, radio.send_starts);
for (uint8_t i = 1; i < 4; i++) {
mesh::Packet* packet = dispatcher.obtainNewPacket();
ASSERT_NE(packet, nullptr);
packet->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT);
packet->payload[0] = (uint8_t)(0x40 + i);
packet->payload_len = 1;
ASSERT_TRUE(dispatcher.sendPacket(packet, 0));
}
EXPECT_EQ(4, manager.getOutboundCount(clock.now));
// Four ready packets reduce the normal four-second CAD ceiling to one
// second, measured from the original busy observation.
clock.now = 1102;
dispatcher.loop();
EXPECT_EQ(1, radio.send_starts);
EXPECT_EQ(3, manager.getOutboundCount(clock.now));
EXPECT_TRUE(dispatcher.getErrFlags() & ERR_EVENT_CAD_TIMEOUT);
}
TEST(Dispatcher, SilentRadioEscalatesFromSoftToHardRecovery) {
RxReservePacketManager manager(8, 4);
TestClock clock;