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