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