Add per-profile TX routing and bridge filtering
Persist Companion TX preferences per contact and channel. Default repeater, room, and sensor replies to both active TX profiles, with a reply-only force option for an RX-only secondary profile. Track both reply copies before a temporary-radio handoff and account for OTA copies under queue backpressure. Add bridge/crossover filter modes and the third built-in wardriving filter, plus compact filter CLI syntax, documentation, and CI coverage. Fix quoted target names being interpreted as slot/key selectors, partial recovery of invalid saved reply settings, filter suspension during tempradio2, and unnecessary packet allocation while OTA traffic is throttled. Validation: 1,477 native tests, 52 Python integration tests, 29 filter UI tests, and five firmware builds covering ESP32, nRF52, and STM32 passed.
This commit is contained in:
1 parent
e9a2326c4f
commit
a07f39c6dd
60 files changed
+3188
-362
No files matched your search
@@ -70,6 +70,19 @@ jobs:
|
||||
- name: Run Unit Tests
|
||||
run: pio test -e native -e native_kiss_modem -vv
|
||||
|
||||
- name: Verify filter playground modes and built-in defaults
|
||||
run: node scripts/test_filter_tool.js
|
||||
|
||||
- name: Verify bridge filter wiring and shorthand permissions
|
||||
run: python3 -B test/test_transport_filter_wiring.py -v
|
||||
|
||||
- name: Verify Companion contact and channel TX routing
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libssl-dev
|
||||
python3 -B test/test_companion_tx_routing.py -v
|
||||
python3 -B test/test_contact_cache.py -v
|
||||
|
||||
- name: Verify receive recovery and pinned radio status transport
|
||||
run: |
|
||||
python3 -B test/test_radio_receive_contract.py
|
||||
@@ -92,6 +105,7 @@ jobs:
|
||||
python3 -B test/test_radio_profile_scan.py
|
||||
python3 -B test/test_repeater_radio_timing_integration.py
|
||||
python3 -B test/test_temp_radio_reply_delivery_contract.py
|
||||
python3 -B test/test_reply_tx_integration.py
|
||||
|
||||
- name: Verify dual-profile switching and chirp timing
|
||||
working-directory: test
|
||||
|
||||
@@ -214,6 +214,7 @@
|
||||
pathPrefix: "",
|
||||
sender: "",
|
||||
tempRadio: "any",
|
||||
via: "radio",
|
||||
verdict: "continue",
|
||||
scopeGate: "unchanged",
|
||||
targetKind: "scope",
|
||||
@@ -257,6 +258,8 @@
|
||||
pathKind: enumValue(input.pathKind, PATH_KINDS, "Path matcher", "none"),
|
||||
pathPrefix: "",
|
||||
sender: clean(input.sender),
|
||||
via: enumValue(input.via === "cross,bridge" ? "bridge,cross" : input.via,
|
||||
["radio", "bridge", "cross", "bridge,cross"], "Traffic path", "radio"),
|
||||
tempRadio: enumValue(input.tempRadio, ["any", "active", "inactive"], "Temporary-radio matcher", "any"),
|
||||
verdict: enumValue(input.verdict, ["continue", "drop"], "Forwarding verdict", "continue"),
|
||||
scopeGate: enumValue(input.scopeGate, ["unchanged", "require_allowed", "bypass_global"], "Scope-gate action", "unchanged"),
|
||||
@@ -356,6 +359,7 @@
|
||||
if (rule.pathKind === "prefix") matches.push(`path=prefix:${rule.pathPrefix}`);
|
||||
else if (rule.pathKind !== "none") matches.push(`path=${rule.pathKind}`);
|
||||
if (rule.sender) matches.push(`sender=${quoteDsl(rule.sender)}`);
|
||||
if (rule.via !== "radio") matches.push(`via=${rule.via}`);
|
||||
if (rule.tempRadio !== "any") matches.push(`tempradio=${rule.tempRadio}`);
|
||||
const actions = [];
|
||||
if (rule.verdict === "drop") actions.push("drop");
|
||||
@@ -464,6 +468,7 @@
|
||||
rule.pathPrefix = value.slice(7);
|
||||
} else rule.pathKind = value;
|
||||
} else if (name === "sender") rule.sender = value;
|
||||
else if (name === "via") rule.via = value;
|
||||
else if (name === "tempradio") rule.tempRadio = value;
|
||||
else throw new FilterToolError(`Unsupported receive-time matcher: ${token}`);
|
||||
});
|
||||
@@ -564,6 +569,7 @@
|
||||
else if (rule.pathKind.startsWith("bucket:")) conditions.push(`a path matching ${rule.pathKind}`);
|
||||
else if (rule.pathKind.startsWith("loop:")) conditions.push(`the ${rule.pathKind.slice(5)} own-ID loop threshold`);
|
||||
if (rule.sender) conditions.push(`decrypted sender "${rule.sender}"`);
|
||||
conditions.push(`traffic path ${rule.via}`);
|
||||
if (rule.tempRadio !== "any") conditions.push(`temporary radio ${rule.tempRadio}`);
|
||||
const mode = rule.mode === "shadow"
|
||||
? "In shadow mode, report that it would "
|
||||
@@ -970,6 +976,7 @@
|
||||
regionName: clean(input.regionName),
|
||||
sender,
|
||||
tempRadio: Boolean(input.tempRadio),
|
||||
via: enumValue(input.via, ["radio", "bridge", "cross"], "Packet traffic path", "radio"),
|
||||
blacklist: Boolean(input.blacklist),
|
||||
buckets: Array.from(new Set(buckets)),
|
||||
loopLevel: requiredInteger(input.loopLevel == null ? 0 : input.loopLevel, 0, 3, "Loop result"),
|
||||
@@ -1002,6 +1009,7 @@
|
||||
const rule = normalizeRule(inputRule);
|
||||
const packet = normalizePacket(inputPacket);
|
||||
const misses = [];
|
||||
if (!rule.via.split(",").includes(packet.via)) misses.push(`traffic path is ${packet.via}`);
|
||||
if (rule.route === "flood" && !["unscoped_flood", "scoped_flood"].includes(packet.route)) misses.push("route is not flood");
|
||||
else if (rule.route !== "flood" && rule.route !== packet.route) misses.push(`route is ${packet.route}`);
|
||||
if (!typeMatches(rule.type, packet.type)) misses.push(`payload type ${packet.type} is outside ${rule.type}`);
|
||||
@@ -1228,6 +1236,12 @@
|
||||
owner: "system",
|
||||
channel: "#wardriving",
|
||||
},
|
||||
{
|
||||
...documentedDropRule("wardriving-bridge-cross", "any", "all", 220),
|
||||
owner: "system",
|
||||
channel: "#wardriving",
|
||||
via: "bridge,cross",
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
@@ -1302,6 +1316,7 @@
|
||||
pathPrefix: field("path-prefix").value,
|
||||
sender: field("sender").value,
|
||||
tempRadio: field("temp-radio").value,
|
||||
via: field("via").value,
|
||||
verdict: field("verdict").value,
|
||||
scopeGate: field("scope-gate").value,
|
||||
targetKind: field("target-kind").value,
|
||||
@@ -1335,6 +1350,7 @@
|
||||
"path-prefix": rule.pathPrefix,
|
||||
sender: rule.sender,
|
||||
"temp-radio": rule.tempRadio,
|
||||
via: rule.via,
|
||||
verdict: rule.verdict,
|
||||
"scope-gate": rule.scopeGate,
|
||||
"target-kind": rule.targetKind,
|
||||
@@ -1564,6 +1580,7 @@
|
||||
regionName: packetField("region-name").value,
|
||||
sender: packetField("sender").value,
|
||||
tempRadio: packetField("temp-radio").checked,
|
||||
via: packetField("via").value,
|
||||
blacklist: packetField("blacklist").value === "yes",
|
||||
buckets: packetField("buckets").value,
|
||||
loopLevel: packetField("loop-level").value,
|
||||
|
||||
+166
-24
@@ -743,6 +743,54 @@ set flag bit 0.
|
||||
|
||||
---
|
||||
|
||||
## Set Companion TX routing per contact or channel
|
||||
|
||||
```text
|
||||
set tx.user "Alice Jones" radio2
|
||||
get tx.user "Alice Jones"
|
||||
set tx.channel 0 both
|
||||
get tx.channel 0
|
||||
set tx.channel #wardriving radio
|
||||
```
|
||||
|
||||
Both command families accept `auto`, `radio`, `radio2`, `both`, or `off`.
|
||||
`auto` uses the global crossing rules. Explicit choices override
|
||||
`radio2.cross`, but radio2 must still be enabled in `rxtx` to transmit there.
|
||||
`radio2` never falls back to the primary profile; `both` uses whichever of its
|
||||
selected profiles can transmit. Set a target back to `auto` to remove its
|
||||
override.
|
||||
|
||||
Use an exact contact/channel name, a zero-based channel index, or
|
||||
`key:<12-64 hex digits>` for a unique contact public-key prefix. Duplicate
|
||||
names/prefixes are rejected. `get tx.user` and `get tx.channel` show override
|
||||
counts; queries with a target show its saved choice and current effective
|
||||
profile selection. Choices are saved with the contact/channel and work for
|
||||
phone-app and terminal messages. A contact's `off` setting also blocks its
|
||||
ACKs and addressed responses.
|
||||
|
||||
See [Companion routing details and examples](radio_profiles.md#companion-tx-routing-by-contact-or-channel)
|
||||
for temporary profiles, persistence, and the traffic covered by each setting.
|
||||
|
||||
## Set repeater, room-server, and sensor reply profiles
|
||||
|
||||
```text
|
||||
get tx.reply
|
||||
set tx.reply both
|
||||
set tx.reply both force
|
||||
set tx.reply radio
|
||||
set tx.reply auto
|
||||
```
|
||||
|
||||
Persistent choices: `auto`, `radio`, `radio2`, `both` (default), and `off`.
|
||||
Explicit choices override `radio2.cross` for local replies, including CLI,
|
||||
ACKs, telemetry, room subscription posts, and served LoRa OTA responses.
|
||||
Append `force` to `both` or `radio2` to permit reply TX on an active RX-only
|
||||
second profile. It never enables a disabled profile or ordinary forwarding.
|
||||
Omitting `force` removes that override. `off` suppresses remote replies too.
|
||||
|
||||
See [reply routing and OTA examples](radio_profiles.md#repeater-room-server-and-sensor-replies)
|
||||
for temporary profiles, receive-only exceptions, and queued reply handling.
|
||||
|
||||
## Set Companion display rotation
|
||||
|
||||
SSD1306 Full Companion builds support a persisted runtime orientation:
|
||||
@@ -2428,6 +2476,42 @@ setting.
|
||||
|
||||
---
|
||||
|
||||
#### Bridge and radio crossover filters
|
||||
|
||||
`mode=` selects where a generalized `flood.rule` / `flood.filter` row applies:
|
||||
|
||||
| Mode | Where the rule applies |
|
||||
| --- | --- |
|
||||
| `radio` | Ordinary LoRa flood forwarding. This is the default when `mode=` is omitted. |
|
||||
| `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. |
|
||||
|
||||
The third built-in repeater rule blocks authenticated `#wardriving` text and
|
||||
data at all hops on bridge and crossover paths, including temporary-radio
|
||||
sessions. It leaves same-profile traffic subject to the separate four-hop
|
||||
rule. `set radio2.cross on` does not override this filter.
|
||||
|
||||
```text
|
||||
# Factory slot: inspect before replacing an existing rule.
|
||||
get flood.rule.3
|
||||
set flood.rule.3 type=any channel=#wardriving hops=all mode=bridge,cross drop
|
||||
# On an existing device, add it without overwriting an occupied slot.
|
||||
set flood.rule type=any channel=#wardriving hops=all mode=bridge,cross drop
|
||||
```
|
||||
|
||||
Saved tables are preserved on upgrade; new defaults are seeded only when the
|
||||
table is first initialized. Bridge/cross modes accept drop, rate, priority and
|
||||
stop actions; scope rewrites, retry actions and TX timing are rejected.
|
||||
These modes cover direct as well as flood packets, in both crossing directions.
|
||||
The default authenticates the channel key, avoiding false drops from short
|
||||
channel-hash collisions. FULL room servers support explicit transport rules;
|
||||
companions and compact FPF6 profiles do not expose this table.
|
||||
|
||||
See [filtering documentation](flood_filtering.md#filter-bridge-and-radio-crossover-traffic)
|
||||
for persistence, queue/rate semantics, exceptions, and how to remove one or
|
||||
both blocks.
|
||||
|
||||
#### Block selected flood channels with FPF7
|
||||
|
||||
The separate `flood.channel.block` command and 15-row table have been retired.
|
||||
@@ -2714,9 +2798,13 @@ For setup guidance, interactions with the existing forwarding controls, and
|
||||
worked moderation examples, see [Flood Filtering and Moderation](flood_filtering.md).
|
||||
|
||||
**Usage:**
|
||||
|
||||
- `get flood.rule`
|
||||
- `get flood.rule.<n>`
|
||||
- `set flood.rule[.<n>] type=<type> [hops=<range>] [channel=<channel>] [prefix=<path-prefix>] [in=<input-scope>] <drop|scope=<name>|region=<name>|rate=<N>/min|retry|stop> [priority=<0-255>] [tx=slow] [suspend=tempradio]`
|
||||
- `get fr[.<n>]` (numbered form returns a complete short setter for copying)
|
||||
- `set fr[.<n>] <type> [h=<range>] [m=r|b|c|bc] [c=<channel>] <d|s=<scope>|r=<region>|q=<N>|r|s> [...]`
|
||||
- `del fr.<n>` or `del fr all`
|
||||
- `set flood.rule[.<n>] type=<type> [mode=radio|bridge|cross|bridge,cross] [hops=<range>] [channel=<channel>] [prefix=<path-prefix>] [in=<input-scope>] <drop|scope=<name>|region=<name>|rate=<N>/min|retry|stop> [priority=<0-255>] [tx=slow] [suspend=tempradio]`
|
||||
- `del flood.rule.<n>`
|
||||
- `del flood.rule all`
|
||||
- `get flood.filter`
|
||||
@@ -2733,16 +2821,21 @@ worked moderation examples, see [Flood Filtering and Moderation](flood_filtering
|
||||
- `del flood.filter all`
|
||||
|
||||
The extended table is available on repeaters with the rule engine enabled and
|
||||
on FULL-profile ESP32 room servers. A FULL room server exposes both
|
||||
`flood.rule` and `flood.filter`, has 31 slots, and requires an administrator
|
||||
on FULL-profile ESP32 room servers. A FULL room server exposes
|
||||
`fr`, `flood.rule`, and `flood.filter`, has 31 slots, and requires an administrator
|
||||
for remote changes. It does not have the repeater's passive path blacklist, so
|
||||
`flood.filter.blacklist*` and `path=blacklist` are repeater-only; use the
|
||||
ordered `prefix=` match on a room server. Standard room-server profiles do not
|
||||
compile this table.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `n`: Forward-rule slot in the build's compiled table (`1-63` on generalized
|
||||
repeaters and `1-31` on FULL room servers; compact profiles may use fewer).
|
||||
- `mode`: Optional; omitted means `radio`. `bridge` filters bridge admission,
|
||||
`cross` filters copies to the other radio profile, and `bridge,cross` covers
|
||||
both with one row. `cross,bridge` is an alias. See the
|
||||
[mode matrix](#bridge-and-radio-crossover-filters) for scope and supported actions.
|
||||
- `type`: Payload type name, full `PAYLOAD_TYPE_*` name, decimal value `0-15`,
|
||||
hexadecimal value `0x00-0x0F`, or `any`.
|
||||
- `hops`: Optional; omitted means `all`.
|
||||
@@ -2806,8 +2899,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.
|
||||
- `suspend=tempradio`: Optional. Skip this row only while the temporary radio
|
||||
is actually active.
|
||||
- `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
|
||||
original incoming packet already passes this repeater's
|
||||
region gate. An incoming transport scope must resolve to a locally allowed
|
||||
@@ -2847,14 +2940,17 @@ The payload names follow the [MeshCore packet-format allocation](https://docs.me
|
||||
| `0x0E` | `14` | reserved |
|
||||
| `0x0F` | `raw_custom` | `PAYLOAD_TYPE_RAW_CUSTOM` |
|
||||
|
||||
**Route scope:** Rules are evaluated only for the two flood route values:
|
||||
**Route scope:** `mode=radio` rules are evaluated only for 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 never affected.
|
||||
are unaffected by radio rules. Bridge/cross rules also cover direct routes at
|
||||
their respective transport boundaries.
|
||||
|
||||
**Behavior:** Match fields within one row are ANDed. Every FPF7 row is matched
|
||||
**Behavior:** Match fields within one row are ANDed. Radio rows are matched
|
||||
against the same immutable receive-time packet, before any rule changes its
|
||||
scope. Matching rows are processed in descending `priority`. At equal numeric
|
||||
scope. Bridge/cross rows match the packet at their admission boundary; a TX
|
||||
packet can already have an appended hop or rewritten scope. Each mode evaluates
|
||||
only its applicable rows, processed in descending `priority`. At equal numeric
|
||||
priority, authenticated channel matches precede raw hashes, which precede an
|
||||
unrestricted channel matcher; lower slot wins after that. The first matching
|
||||
`stop` row is included and all lower-order FPF7 matches are discarded. A stop
|
||||
@@ -2903,10 +2999,11 @@ region names rather than transient numeric region
|
||||
IDs. Removing, reordering, or reusing a region ID therefore cannot silently
|
||||
retarget a rule. If a saved input or target region name is absent, that input
|
||||
match or rewrite is inert; restoring the same region name reactivates it.
|
||||
While the temporary radio is active, only rows explicitly marked
|
||||
`suspend=tempradio` are skipped. `tempradio` is a radio state, not an OTA mode;
|
||||
normal payload types can also use the temporary channel. Other rows remain in
|
||||
force. A malformed persisted table fails open (no general rules are applied).
|
||||
While either `tempradio` or `tempradio2` is active, only rows explicitly marked
|
||||
`suspend=tempradio` are skipped. Temporary radio is a radio state, not an OTA
|
||||
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
|
||||
@@ -2951,10 +3048,10 @@ Deleting or replacing the last active `retry` row restores the legacy global
|
||||
retry eligibility. Firmware that predates the `retry`/`hash:XX` FPF7 extension
|
||||
cannot preserve tables containing those rows; remove them before downgrading.
|
||||
|
||||
**Default row:** Repeater firmware and FULL ESP32 room-server firmware seed a
|
||||
**Default rows:** Repeater firmware and FULL ESP32 room-server firmware seed a
|
||||
new flood-filter table with
|
||||
`ota all suspend=tempradio` in slot 1. This blocks repeated LoRa OTA (`0x0C`)
|
||||
floods at every received hop unless temporary radio is actually active. The OTA
|
||||
floods at every received hop unless either temporary radio profile is active. The OTA
|
||||
core independently refuses OTA receive, relay, and transmit outside temporary
|
||||
radio. The row is editable and deletable; once the table is saved, deletion is
|
||||
persistent. Restore the exact seeded row with:
|
||||
@@ -2966,6 +3063,18 @@ set flood.filter.1 0x0C all suspend=tempradio
|
||||
Omitting `all` is equivalent. Omit `.1` as well to reuse an identical rule or
|
||||
the first empty slot instead of replacing slot 1.
|
||||
|
||||
Generalized repeaters also seed slot 2 with the authenticated `#wardriving`
|
||||
radio hop limit and slot 3 with the bridge/crossover block:
|
||||
|
||||
```text
|
||||
set flood.rule.2 type=any channel=#wardriving hops=5+ drop
|
||||
set flood.rule.3 type=any channel=#wardriving hops=all mode=bridge,cross drop
|
||||
```
|
||||
|
||||
These numbered commands replace those slots. Saved tables are preserved on
|
||||
upgrade; use the unnumbered third command to add the new rule without replacing
|
||||
an occupied slot. See [restoring the three defaults](flood_filtering.md#restore-the-factory-seeded-rows).
|
||||
|
||||
**Remote-admin lockout warning:** There are no hidden payload-type or short-hop
|
||||
exceptions. FPF7 drop and rate rows may block `req`, `response`, `txt_msg`,
|
||||
`anon_req`, `path`, ACK, and multipart traffic beginning at hop `0` when their
|
||||
@@ -2980,14 +3089,47 @@ or action.
|
||||
form for full details, including channel, prefix, original-scope condition,
|
||||
action, timing, rate, and temporary-radio suspension.
|
||||
|
||||
If all of those fields plus long names would exceed one CLI reply, the
|
||||
numbered form automatically switches to a non-truncating compact spelling.
|
||||
The compact aliases are also accepted by `set`: `c=` means `channel=`, `p=`
|
||||
means `prefix=`, `i=*|n|s|a|u|s:<scope>|r:<region>` means the corresponding
|
||||
`in=` condition, `q=N` means `rate=N/min`, `pri=N` means priority, and `f=str`
|
||||
combines slow timing (`s`), temporary-radio suspension (`t`), and retry
|
||||
allowance (`r`). Packet type is shown numerically
|
||||
in that fallback. Normal-sized rows keep the descriptive spelling above.
|
||||
`fr` is a shorter alias for `flood.rule`, with the same explicit-action
|
||||
requirement. Short and long field names can be mixed in either command:
|
||||
|
||||
```text
|
||||
set flood.rule.3 type=any channel=#wardriving hops=all mode=bridge,cross drop
|
||||
set fr.3 any c=#wardriving m=bc d
|
||||
get fr.3
|
||||
```
|
||||
|
||||
The two setters above save the same row. `get fr.3` returns the complete
|
||||
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.
|
||||
|
||||
| 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=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` |
|
||||
| `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.
|
||||
|
||||
The numbered long getters use this complete short setter when descriptive
|
||||
fields would exceed a reply. Defaults are omitted and specific payload types
|
||||
are printed numerically. If it still cannot fit, the reply is
|
||||
`Err - compact command exceeds reply size`, never a partial setter.
|
||||
Private channel keys are shown as `key:XXXXXXXX`; that reference can be
|
||||
reused while an active rule on the same device holds the key. Copying it to
|
||||
another device requires the original key, unless that device already has a
|
||||
matching rule. Unknown or ambiguous references are rejected. See the
|
||||
[shorthand guide](flood_filtering.md#shorthand-for-copying-rules) for examples.
|
||||
|
||||
On generalized repeaters, filter rows, scope-rewrite rows, the shared
|
||||
blacklist, and `flood.channel.data` compatibility state are committed in one
|
||||
@@ -3135,7 +3277,7 @@ del flood.moderation.3
|
||||
**Filter manager scope:** Permission `5` can use an explicit allowlist of
|
||||
non-secret operational/filter status commands and can change the forwarding
|
||||
controls `repeat`, `loop.detect`, `flood.max*`, `flood.channel.data*`,
|
||||
`flood.filter*`, `flood.rule*`, and
|
||||
`flood.filter*`, `flood.rule*`, `fr[.N]`, and
|
||||
`flood.moderation*`. It cannot read
|
||||
guest, WiFi, MQTT, bridge, or other credentials, and it cannot change regions,
|
||||
ACL entries, radio settings, or other admin configuration. Permission `4`
|
||||
|
||||
@@ -131,3 +131,29 @@ For every command's full details, see the [bridge CLI reference](cli_commands.md
|
||||
|
||||
For a broader comparison with a Linux-based bridge, see the
|
||||
[MeshCore Nexus bridging guide](https://meshcore.nexus/guides/lora-bridging#method-2-meshcore-esp-now-bridge).
|
||||
|
||||
## Keep wardriving traffic off the bridge
|
||||
|
||||
New generalized repeater filter tables include a third built-in rule that
|
||||
blocks authenticated `#wardriving` text and data in both bridge directions,
|
||||
at every hop count. The same row blocks crossover between radio profiles:
|
||||
|
||||
```text
|
||||
get flood.rule.3
|
||||
set flood.rule type=any channel=#wardriving hops=all mode=bridge,cross drop
|
||||
```
|
||||
|
||||
The same rule can be added with the shorter command:
|
||||
|
||||
```text
|
||||
set fr any c=#wardriving m=bc d
|
||||
```
|
||||
|
||||
Both forms use the same table. `m=b` means bridge only, `m=c` means crossover
|
||||
only, and `get fr.N` returns a short command for copying a numbered rule.
|
||||
|
||||
The unnumbered command adds the rule safely on existing devices whose saved
|
||||
tables predate this default. It works with either bridge packet source and
|
||||
both ESP-NOW formats. Nearby same-profile LoRa forwarding retains the separate
|
||||
four-hop default. Use `mode=bridge` for a bridge-only block, or `mode=cross` for
|
||||
a crossover-only block. See the [mode matrix and examples](flood_filtering.md#filter-bridge-and-radio-crossover-traffic).
|
||||
+38
-4
@@ -13,7 +13,7 @@ drafts are not uploaded anywhere.
|
||||
The phases and core conditions model current FPF7 behavior, including its
|
||||
forward rows, scope rewrites, and shared blacklist. The readable policy
|
||||
language, JSON, and Base64 bundle are still a prototype: current firmware
|
||||
is configured with <code>set flood.*</code> commands and cannot install a
|
||||
is configured with <code>set flood.*</code> or <code>set fr</code> commands and cannot install a
|
||||
bundle from this page.
|
||||
</p>
|
||||
</div>
|
||||
@@ -111,8 +111,8 @@ in the simulator below. The examples draw from
|
||||
<code>when type=any hops=all path=blacklist do drop</code>
|
||||
</button>
|
||||
<button type="button" data-example="factory">
|
||||
<span>Drop OTA outside temporary-radio mode and distant #wardriving</span>
|
||||
<code>when type=ota hops=all tempradio=inactive do drop<br>when type=any channel=#wardriving hops=5+ do drop</code>
|
||||
<span>Three defaults: OTA, distant #wardriving, and bridge/crossover #wardriving</span>
|
||||
<code>when type=ota hops=all tempradio=inactive do drop<br>when type=any channel=#wardriving hops=5+ do drop<br>when type=any channel=#wardriving hops=all via=bridge,cross do drop</code>
|
||||
</button>
|
||||
<button type="button" data-example="wildcards">
|
||||
<span>Set #BlackHole86 scope on login and bucket-matched other traffic</span>
|
||||
@@ -131,6 +131,12 @@ in the simulator below. The examples draw from
|
||||
<button type="button" data-role="reset-form">Reset form</button>
|
||||
</div>
|
||||
|
||||
<p class="filter-field-help">The factory example contains all three repeater defaults.
|
||||
The playground's <code>via=</code> traffic-path matcher corresponds to firmware
|
||||
<code>mode=radio|bridge|cross|bridge,cross</code>; the advanced Active/Shadow/Disabled
|
||||
mode is a separate playground setting. See the
|
||||
<a href="../flood_filtering/#filter-bridge-and-radio-crossover-traffic">firmware mode guide</a>.
|
||||
This simulator models flood packets; firmware bridge/cross rules also cover direct routes.</p>
|
||||
<h3>Common match settings</h3>
|
||||
<div class="filter-form-grid">
|
||||
<label>
|
||||
@@ -204,6 +210,15 @@ in the simulator below. The examples draw from
|
||||
Decrypted sender (optional)
|
||||
<input data-field="sender" maxlength="31" placeholder="Noisy User">
|
||||
</label>
|
||||
<label>
|
||||
Traffic path (firmware mode)
|
||||
<select data-field="via">
|
||||
<option value="radio">Normal radio forwarding</option>
|
||||
<option value="bridge">Bridge entry/exit</option>
|
||||
<option value="cross">Radio profile crossover</option>
|
||||
<option value="bridge,cross">Bridge and crossover</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Temporary-radio state
|
||||
<select data-field="temp-radio">
|
||||
@@ -439,6 +454,14 @@ in the simulator below. The examples draw from
|
||||
</div>
|
||||
<div class="filter-form-grid filter-simulator-facts">
|
||||
<label>
|
||||
Packet traffic path
|
||||
<select data-packet="via">
|
||||
<option value="radio">Normal radio forwarding</option>
|
||||
<option value="bridge">Bridge entry/exit</option>
|
||||
<option value="cross">Radio profile crossover</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Received route
|
||||
<select data-packet="route">
|
||||
<option value="unscoped_flood">Unscoped flood</option>
|
||||
@@ -609,7 +632,7 @@ The class column shows which broader selector also matches it.
|
||||
|
||||
| Firmware command | FPF7 role |
|
||||
| --- | --- |
|
||||
| `flood.rule` / `flood.filter` | Forward-phase match and action rows |
|
||||
| `fr` / `flood.rule` / `flood.filter` | Forward-phase match and action rows |
|
||||
| `flood.channel.data` | Compatibility view over one visible `type=grp_data` forward drop row |
|
||||
| `flood.channel.scope` | Scope-rewrite phase rows |
|
||||
| `flood.filter.blacklist` | One shared unordered path-ID set referenced by `path=blacklist` rows |
|
||||
@@ -620,6 +643,17 @@ for refusing to retransmit floods associated with internet gateways dumping
|
||||
bulk traffic, but a path ID is truncated and unauthenticated; it identifies a
|
||||
routing pattern, not a person.
|
||||
|
||||
The firmware accepts both full and short CLI forms for the same forward row:
|
||||
|
||||
```text
|
||||
set flood.rule.3 type=any channel=#wardriving hops=all mode=bridge,cross drop
|
||||
set fr.3 any c=#wardriving m=bc d
|
||||
```
|
||||
|
||||
Use `get fr.3` on the device to obtain a short setter ready to copy. This CLI
|
||||
syntax is separate from the playground's proposed policy/bundle format.
|
||||
See the [shorthand reference](flood_filtering.md#shorthand-for-copying-rules).
|
||||
|
||||
## Proposed evaluation contract
|
||||
|
||||
The simulator uses these rules:
|
||||
|
||||
+188
-24
@@ -158,8 +158,9 @@ 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
|
||||
repeater FPF7 table starts with `ota all suspend=tempradio` in slot 1 and an
|
||||
authenticated `#wardriving hops=5+` drop in slot 2; FULL room servers seed only
|
||||
the OTA row. `flood.moderation` starts empty. A row can opt into
|
||||
authenticated `#wardriving hops=5+` radio drop in slot 2, and an authenticated
|
||||
`#wardriving hops=all mode=bridge,cross` drop in slot 3. FULL room servers seed
|
||||
only the OTA row; their generalized engine also accepts transport-mode rules. `flood.moderation` starts empty. A row can opt into
|
||||
`suspend=tempradio`;
|
||||
temporary radio is not synonymous with OTA and can carry normal packet types
|
||||
too. A corrupt or truncated table fails open, so corrupt storage does not
|
||||
@@ -400,14 +401,17 @@ scope arbitration.
|
||||
## Runtime flood rules
|
||||
|
||||
On repeaters with the rule engine enabled and on FULL-profile ESP32 room
|
||||
servers, `flood.rule` and `flood.filter` are two names for the same persistent
|
||||
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.
|
||||
Older firmware cannot preserve an FPF7 table containing the packed `retry`
|
||||
action or one-byte `hash:XX` matcher; remove those rows before downgrading.
|
||||
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,
|
||||
which disables its other filter rows too. New firmware loads older rows as
|
||||
`mode=radio` without changing their behavior.
|
||||
|
||||
The former `flood.channel.block` table is now represented by ordinary FPF7
|
||||
rows. On a generalized repeater, an existing FCB2 file is imported once into
|
||||
@@ -434,7 +438,8 @@ reactivates the rule.
|
||||
The extended form is:
|
||||
|
||||
```text
|
||||
set flood.rule[.<slot>] type=<type> [hops=<range>] [channel=<channel>]
|
||||
set flood.rule[.<slot>] type=<type> [mode=radio|bridge|cross|bridge,cross]
|
||||
[hops=<range>] [channel=<channel>]
|
||||
[prefix=<ID[,ID...]>] [in=<input-scope>] <action> [rate=<N>/min]
|
||||
[priority=<0-255>] [stop] [tx=fast|slow] [suspend=tempradio]
|
||||
get flood.rule
|
||||
@@ -444,13 +449,18 @@ del flood.rule all
|
||||
```
|
||||
|
||||
The command must be entered on one line. Match fields in one row are ANDed.
|
||||
Every row is matched against the same immutable packet state captured on
|
||||
receive, before any rule rewrites its scope. Matching rows are then processed
|
||||
Radio rows are matched against the same immutable packet state captured on
|
||||
receive, before any rule rewrites its scope. Bridge/cross rows match at their
|
||||
transport boundary. Only rows for the current mode participate in ordering
|
||||
and stop processing. Matching rows are then processed
|
||||
by descending numeric `priority`. At an equal numeric priority, authenticated
|
||||
channel matches run before raw `hash:XX` matches, raw hashes run before
|
||||
`channel=*`, and lower slot number breaks the remaining tie. Priority defaults
|
||||
to `0`; an explicitly higher numeric priority overrides specificity.
|
||||
|
||||
Numbered examples replace the selected slots, including factory defaults.
|
||||
Omit the slot number to add a rule without replacing an occupied slot.
|
||||
|
||||
The first matching `stop` row ends the FPF7 forward phase after that row. Higher-order
|
||||
matches and the stop row still apply; lower-order matches do not. A stop-only
|
||||
row is therefore an exception to lower-priority FPF7 rows. It cannot undo a
|
||||
@@ -464,6 +474,8 @@ Match fields:
|
||||
`flood.filter`. The positional form remains accepted.
|
||||
- `hops=` accepts `all`, `N`, `N+`, or `N-M`. The positional form remains
|
||||
accepted. Received hops over 3 are written as `hops=4+`.
|
||||
- `mode=radio|bridge|cross|bridge,cross` selects the admission phase; omitted
|
||||
means `radio`. See [bridge and crossover filtering](#filter-bridge-and-radio-crossover-traffic).
|
||||
- `channel=*|public|#name|hash:XX|128-bit-key|256-bit-key` optionally narrows by
|
||||
channel. `channel=*` is an unconstrained wildcard: it performs no channel
|
||||
authentication and matches every payload selected by `type=`. Thus
|
||||
@@ -636,13 +648,77 @@ Without a slot number, `set` reuses an identical row or selects the first empty
|
||||
slot. Use a slot number to replace a row whose match or action is changing.
|
||||
Omitting the hop expression means `all` (`0-63`).
|
||||
|
||||
Numbered `get` normally uses the long field names. If a rule containing
|
||||
several maximum-length names would exceed one CLI reply, it switches to a
|
||||
non-truncating compact spelling that `set` also accepts: `c=` is `channel=`,
|
||||
`p=` is `prefix=`, `i=*|n|s|a|u|s:<scope>|r:<region>` represents `in=`, `q=N`
|
||||
is `rate=N/min`, and `f=str` combines slow timing (`s`), temporary-radio
|
||||
suspension (`t`), and retry allowance (`r`). The fallback prints packet type
|
||||
numerically.
|
||||
Numbered `get flood.rule.N` and `get flood.filter.N` normally use descriptive
|
||||
field names. On generalized builds, a reply that would be too long uses the
|
||||
complete `set fr.N ...` spelling below. `get fr.N` always requests that spelling.
|
||||
If even the short command exceeds one reply, it returns
|
||||
`Err - compact command exceeds reply size` instead of a partial command.
|
||||
Saving such a rule still succeeds and reports that its details exceed the
|
||||
reply size.
|
||||
|
||||
### Shorthand for copying rules
|
||||
|
||||
On generalized repeaters and FULL ESP32 room servers, `fr` is a shorter name
|
||||
for `flood.rule`. Both forms use the same parser, validation, permissions, and
|
||||
saved rows. Short and full field names can be mixed in either command.
|
||||
`fr` requires an explicit action, just like `flood.rule`.
|
||||
|
||||
These commands set exactly the same rule:
|
||||
|
||||
```text
|
||||
set flood.rule.3 type=any channel=#wardriving hops=all mode=bridge,cross drop
|
||||
set fr.3 any c=#wardriving m=bc d
|
||||
```
|
||||
|
||||
| Full form | Short form | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `flood.rule[.N]` | `fr[.N]` | Same rule table for `get`, `set`, and `del`. |
|
||||
| `type=any` | `any` or `*` | First token: any payload type. Named/numeric types also work, with optional `t=`. |
|
||||
| `hops=N`, `N+`, `N-M`, or `all` | `h=N`, `h=N+`, `h=N-M`, or `h=*` | Hop condition; a bare range also works. Omitted means all hops. |
|
||||
| `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=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. |
|
||||
| `in=scope:name`, `in=region:name` | `i=s:name`, `i=r:name` | Match a specific incoming scope or region. |
|
||||
| `drop` | `d` | Drop matching traffic. |
|
||||
| `scope=name`, `region=name` | `s=name`, `r=name` | Rewrite to a public scope or configured region. |
|
||||
| `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. |
|
||||
| `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`
|
||||
(region) from `r` (retry). Short forms keep the same action restrictions;
|
||||
for example, `m=bc r` is rejected because transport rules cannot enable retries.
|
||||
|
||||
```text
|
||||
get fr.3
|
||||
```
|
||||
|
||||
Returns a complete command ready to copy:
|
||||
|
||||
```text
|
||||
set fr.3 any m=bc c=#wardriving d
|
||||
```
|
||||
|
||||
The compact reply omits default values and prints specific payload types as
|
||||
numbers. `get fr` lists the table. `set fr ...` without a slot reuses an
|
||||
identical rule or adds it to the first empty slot. `del fr.3` removes slot 3;
|
||||
`del fr all` clears the forward rules.
|
||||
|
||||
Private keys are still hidden in replies. A displayed `c=key:XXXXXXXX` is a
|
||||
local reference to the exact key in an active rule on that device, so it can
|
||||
be pasted to edit or copy a rule there. On another device, or after deleting
|
||||
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`.
|
||||
|
||||
### Scope actions and legacy rules
|
||||
|
||||
A legacy row without `scope=` is the existing drop action. On extended builds,
|
||||
an explicit `drop` has the same result, while `rate=` by itself creates a
|
||||
@@ -695,8 +771,9 @@ This is a normal editable row. After the table has been saved, deleting it
|
||||
remains persistent across reboot; the firmware does not recreate it. Run the
|
||||
same command to restore the exact seeded row, or omit `.1` to preserve existing
|
||||
slot assignments and use the first empty slot. Operators may add
|
||||
`suspend=tempradio` to any other row that should be skipped while the radio is
|
||||
on a temporary channel.
|
||||
`suspend=tempradio` to any other row that should be skipped while either
|
||||
`tempradio` or `tempradio2` is active. This includes scheduled temporary
|
||||
profiles. The row resumes once both temporary sessions have ended.
|
||||
|
||||
Suspension does not approve a packet or bypass the rest of the filter table. It
|
||||
skips that row, then evaluation continues with the next row and the remaining
|
||||
@@ -705,7 +782,9 @@ the temporary-radio window unless an earlier matching stop row ends FPF7
|
||||
processing. `repeat`, `flood.max*`, region handling, loop detection, and the OTA
|
||||
subsystem's own hop limit also remain in force.
|
||||
|
||||
Standard traceroute uses direct routing and never enters `flood.filter`. For a
|
||||
Standard traceroute uses direct routing and bypasses `mode=radio` filters.
|
||||
Explicit bridge/cross filters can also match direct traffic at those transport
|
||||
boundaries; the built-in channel rule does not match TRACE packets. For a
|
||||
custom flood-form trace, `type=any`, explicit `trace`, scope, region, rate, and
|
||||
drop rows all behave normally. The stock core does not normally flood-forward
|
||||
TRACE packets.
|
||||
@@ -788,13 +867,15 @@ get flood.filter
|
||||
| `path 9+` | 9 or more path entries |
|
||||
| `control 1+` | 1 or more path entries |
|
||||
|
||||
On a new table, the factory OTA rule occupies slot 1, so these unnumbered
|
||||
commands normally fill slots 2 through 7. Existing tables may choose different
|
||||
On a new generalized repeater table, the three factory rules occupy slots
|
||||
1 through 3, so these unnumbered commands normally fill slots 4 through 9.
|
||||
FULL room servers and compact tables seed only OTA and use slots 2 through 7.
|
||||
Existing tables may choose different
|
||||
free slots. All six rules take effect at the thresholds shown; there are no
|
||||
hidden short-hop exceptions. The Control rule allows a flood received with
|
||||
path count `0` to be forwarded
|
||||
once, then stops it at the next repeater. Normal node-discovery Control packets
|
||||
are direct zero-hop packets and never enter `flood.filter`. These rules affect
|
||||
are direct zero-hop packets and bypass these `mode=radio` rules. These rules affect
|
||||
only retransmission by the repeater; local reception and logging remain
|
||||
unchanged.
|
||||
|
||||
@@ -922,7 +1003,8 @@ other words, the controls combine as deny rules:
|
||||
charged immediately before retransmission is approved.
|
||||
|
||||
The first denial is enough to prevent retransmission. A packet that is denied
|
||||
can still appear in local logs or MQTT output. Moderation runs last because its
|
||||
can still appear in local logs or MQTT output, unless a separate
|
||||
`mode=bridge` rule blocks MQTT packet publication. Moderation runs last because its
|
||||
rate counters are charged only for packets that pass every other forwarding
|
||||
control and will actually be retransmitted.
|
||||
|
||||
@@ -936,7 +1018,7 @@ 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*`, and `flood.moderation*`. Delegated `get` access uses an
|
||||
`flood.filter*`, `flood.rule*`, `fr[.N]`, and `flood.moderation*`. Delegated `get` access uses an
|
||||
explicit allowlist: it cannot retrieve guest, WiFi, MQTT, bridge, or other
|
||||
credentials, and it cannot change regions, ACL entries, radio settings, or
|
||||
unrelated administrator settings. Because `flood.filter scope=` derives a
|
||||
@@ -944,7 +1026,7 @@ public hashtag key directly, a filter manager can configure that action without
|
||||
region-manager permission; it still cannot edit the region hierarchy.
|
||||
|
||||
FULL ESP32 room servers use their existing administrator check for remote
|
||||
`flood.rule` and `flood.filter` commands; they do not grant this table through
|
||||
`fr`, `flood.rule`, and `flood.filter` commands; they do not grant this table through
|
||||
permission `5`.
|
||||
|
||||
ACL permission `4`, the region/scope-manager role, can read, add, replace, and
|
||||
@@ -965,6 +1047,79 @@ Use username and path rules as traffic moderation, not as an authorization
|
||||
boundary. For a strict network boundary, combine these tools with region ACLs,
|
||||
private transport/channel keys, and controlled device access.
|
||||
|
||||
## Filter bridge and radio crossover traffic
|
||||
|
||||
The `mode=` matcher selects a separate admission phase within the same
|
||||
`flood.rule` / `flood.filter` table:
|
||||
|
||||
| Mode | Where the rule applies |
|
||||
| --- | --- |
|
||||
| `radio` | Ordinary LoRa flood forwarding. This is the default when `mode=` is omitted. |
|
||||
| `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. |
|
||||
|
||||
The third built-in repeater filter is:
|
||||
|
||||
```text
|
||||
set flood.rule.3 type=any channel=#wardriving hops=all mode=bridge,cross drop
|
||||
get flood.rule.3
|
||||
```
|
||||
|
||||
The equivalent short command is `set fr.3 any c=#wardriving m=bc d`.
|
||||
Use `m=b` for bridge only or `m=c` for crossover only.
|
||||
|
||||
It authenticates both `GRP_TXT` and `GRP_DATA` against the public `#wardriving`
|
||||
channel key. A different channel with the same one-byte hash is not blocked.
|
||||
It covers every hop count, including zero-hop/local messages, flood routes and
|
||||
direct routes. It remains active during temporary-radio sessions and with
|
||||
`radio2.cross on`: enabling crossover makes a copy eligible, but does not
|
||||
override the filter. The second built-in rule still permits ordinary
|
||||
`#wardriving` forwarding through four received hops; the third rule keeps
|
||||
those messages on their originating radio profile.
|
||||
|
||||
Bridge filtering runs in both directions before bridge deduplication, inbound
|
||||
queueing, serial writes, ESP-NOW queueing, or MQTT packet publication. It
|
||||
applies with either `bridge.source rx` or `bridge.source tx` and with wrapped or raw
|
||||
ESP-NOW framing. MQTT here is the observer packet output; this does not add
|
||||
MQTT-to-LoRa injection or filter MQTT status/telemetry. Local reception and
|
||||
local packet logs remain available. Switching one radio between profiles is
|
||||
covered by `cross`; the bridge's WiFi channel is a separate setting.
|
||||
|
||||
Transport modes support `drop`, `rate=X/min`, `priority=`, and `stop`, with the
|
||||
existing type, channel, hops, prefix, blacklist, incoming-scope and
|
||||
`suspend=tempradio` matchers. Scope/region rewrites, retry actions, and TX
|
||||
timing actions are rejected in these modes. Ordering and `stop` apply only to
|
||||
rules in the current mode: a radio exception cannot bypass a bridge/cross
|
||||
drop. A combined rule shares one rate counter across bridge and cross
|
||||
admission attempts; retries count too. A rejected queue insertion can consume
|
||||
an admission allowance. Queued packets already admitted are not recalled by
|
||||
editing a rule. Same-profile transmissions use the ordinary radio policy.
|
||||
|
||||
`get flood.rule.N` shows `mode=`; the overview marks each row with its mode.
|
||||
Modes are persistent, case-insensitive, and apply immediately to new admission
|
||||
attempts. `radio` cannot be combined with `bridge` or `cross` in a single row.
|
||||
For transport rules, hop and scope matchers see the packet at that boundary:
|
||||
a TX/crossover packet can already contain an appended hop or rewritten scope.
|
||||
The built-in rule uses all hops and any scope, so these changes cannot bypass it.
|
||||
On FULL room servers, add a transport rule explicitly; their factory table
|
||||
still has only the OTA row. Companions and compact FPF6 builds do not expose
|
||||
this generalized filter table.
|
||||
|
||||
Saved tables, occupied slots, and deleted defaults are preserved on upgrade.
|
||||
To add the new default to an existing table without replacing another rule,
|
||||
omit the slot number (repeating the identical command reuses its row):
|
||||
|
||||
```text
|
||||
set flood.rule type=any channel=#wardriving hops=all mode=bridge,cross drop
|
||||
get flood.rule
|
||||
```
|
||||
|
||||
To allow only bridge traffic while retaining the crossover block, replace the
|
||||
third rule with `mode=cross`. Use `mode=bridge` to retain only the bridge block.
|
||||
Use `del flood.rule.3` to remove both, after checking that slot 3 is still the
|
||||
built-in rule. Removing it does not change the separate LoRa hop-limit rule.
|
||||
|
||||
## Restore the factory-seeded rows
|
||||
|
||||
The repeater's factory-seeded forwarding rows can be restored through the CLI:
|
||||
@@ -972,9 +1127,18 @@ The repeater's factory-seeded forwarding rows can be restored through the CLI:
|
||||
```text
|
||||
set flood.rule.1 type=ota hops=all drop suspend=tempradio
|
||||
set flood.rule.2 type=any channel=#wardriving hops=5+ drop
|
||||
set flood.rule.3 type=any channel=#wardriving hops=all mode=bridge,cross drop
|
||||
```
|
||||
|
||||
These commands explicitly replace the two seeded generalized-repeater slots.
|
||||
Or use the equivalent short forms:
|
||||
|
||||
```text
|
||||
set fr.1 ota d f=t
|
||||
set fr.2 any 5+ c=#wardriving d
|
||||
set fr.3 any c=#wardriving m=bc d
|
||||
```
|
||||
|
||||
These commands explicitly replace the three seeded generalized-repeater slots.
|
||||
Inspect them first if they may now contain other rules. Compact FPF6 builds use
|
||||
only the first command's `flood.filter.1 0x0C all suspend=tempradio` form.
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ set flood.retry.ignore none
|
||||
| `flood.channel.data.hops` | Sets the compatibility row's hop range: `all` maps to `hops=all`; `1`-`7` repeats through that hop count and maps to `hops=N+1+`. | `get flood.channel.data.hops`, `set flood.channel.data.hops <all|1-7>` | `set flood.channel.data.hops 7` |
|
||||
| `flood.channel.scope` | FPF7 rewrite-phase rows that add a transport scope to received unscoped floods or replace the scope of already-scoped floods. A bare target uses an existing flood-allowed region; `scope=<name>` derives a public hashtag target directly without creating a region, exactly like `flood.filter scope=`. By default, a changed packet bypasses inbound `rxdelay` and is forwarded at the highest outbound queue priority with zero initial `txdelay`, so the selected scope can win at the next hop. `tx=slow` uses an effective inbound `rxdelay` base of `max(2, configured rxdelay * 2)`, retains normal queue priority, and forces the maximum `txdelay` factor of `2.0`; its actual randomized transmit delay ranges from zero through ten packet airtimes. `path=blacklist` and `path=bucket:<1-6>` make a row path-qualified; bridge buckets remain usable while bridge retry is off. An already-matching scope is a no-op. Exact channel keys beat `txt:*`; path-qualified rows beat the ordinary channel fallback. `login:*` covers the remote-login family, and `other:*` covers every remaining flood type, including flood-form TRACE and OTA. Direct traceroute remains outside the flood table. Generalized builds commit these rows with the forward phase and blacklist; compact FPF6 builds retain separate storage. ACL permission `4` can manage the table. | `get flood.channel.scope[.n]`, `set flood.channel.scope[.n] <channel|txt:*|login:*|other:*> <region|scope=name> [path=blacklist|path=bucket:1-6] [tx=slow]`, `del flood.channel.scope.<n>|all` | `set flood.channel.scope #rgdata scope=BlackHole86` |
|
||||
| `flood.channel.scope.require` | Switches group-channel region enforcement to opt-in when the table has entries. Listed authenticated `GRP_TXT`/`GRP_DATA` channels must arrive already scoped to a locally allowed region; unscoped, unknown, or denied incoming scopes are dropped before any rewrite can rescue them. Unlisted group channels bypass only the region gate and retain all other forwarding controls. An empty table preserves global region behavior; non-channel payloads are unchanged. ACL permission `4` can manage the table. | `get flood.channel.scope.require[.n]`, `set flood.channel.scope.require[.n] <public|#channel|key>`, `del flood.channel.scope.require.<n>|all` | `set flood.channel.scope.require #bot` |
|
||||
| `flood.filter` | Persistent flood-route rules selected by payload type and optional hop range. Generalized repeaters have 63 FPF7 forward slots; FULL ESP32 room servers have 31. Repeaters store their scope-rewrite phase, shared unordered blacklist, and channel-data compatibility state in the same atomic FPF7 image. `path=blacklist` is intended for forwarding abuse containment, including bulk internet-to-mesh dumping, but truncated path IDs are not authenticated identities. Fixed 240 KB STM32WL repeaters retain compact FPF6 filtering and separate blacklist storage. New generalized repeater tables seed slot 1 with `ota all suspend=tempradio` and slot 2 with an authenticated `#wardriving hops=5+` drop. Direct routes and local receive/logging are unchanged. | `get flood.filter[.n]`, `set flood.filter[.n] <type> [N|N+|N-M|all] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]`, `del flood.filter.<n>|all`; repeater only: `get/set/del flood.filter.blacklist[.n]` | `set flood.filter grp_txt all scope=local tx=slow` |
|
||||
| `flood.filter` | Persistent flood-route rules selected by payload type and optional hop range. Generalized repeaters have 63 FPF7 forward slots; FULL ESP32 room servers have 31. Repeaters store their scope-rewrite phase, shared unordered blacklist, and channel-data compatibility state in the same atomic FPF7 image. `path=blacklist` is intended for forwarding abuse containment, including bulk internet-to-mesh dumping, but truncated path IDs are not authenticated identities. Fixed 240 KB STM32WL repeaters retain compact FPF6 filtering and separate blacklist storage. New generalized repeater tables seed slot 1 with `ota all suspend=tempradio` slot 2 with an authenticated `#wardriving hops=5+` radio drop, and slot 3 with `#wardriving hops=all mode=bridge,cross drop`. `mode=radio` is the default; `bridge`, `cross`, and `bridge,cross` select separate transport admission paths, including direct packets. Local receive/logging is unchanged. See the [mode guide](flood_filtering.md#filter-bridge-and-radio-crossover-traffic). | `get flood.filter[.n]`, `set flood.filter[.n] <type> [N|N+|N-M|all] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]`, `del flood.filter.<n>|all`; repeater only: `get/set/del flood.filter.blacklist[.n]` | `set flood.filter grp_txt all scope=local tx=slow` |
|
||||
| `flood.rule` | Live alias for extended `flood.filter` on rule-engine repeaters and FULL ESP32 room servers. A row can AND packet type, hop range, optional channel match, ordered 1/2/3-byte pbyte source prefix, and original scope/region conditions, then drop, rewrite to a direct scope or configured region, enforce a per-row rate, allow flood retry, and/or stop lower-priority FPF7 rules. `channel=*` means no channel condition; `public`, `#name`, and keys authenticate; `hash:XX` is an unauthenticated one-byte fallback with collision/spoof risk. With no `retry` rows, global retry behavior is unchanged; once any exists, matching `retry` rows allow-list received floods while the global retry gates and `flood.retry.bridge` algorithm still apply. `scope=BlackHole86` directly derives a regionless sink scope; `region=BlackHole86` would require a configured flood-allowed region. All rows match the original receive-time packet. Higher numeric `priority` runs first; equal values automatically order authenticated channels before raw hashes before `channel=*`, then use lower slot as the final tie-break. Repeated rows with one channel key share a per-packet authentication result. Persistent FPF7 stores canonical region names, so region ID reorder or reuse cannot retarget a rule. A missing saved region makes its `region=` rewrite and paired `stop` inert until the name returns, allowing lower safety rows to run. Fixed 240 KB STM32WL profiles keep FPF6 and do not expose this alias; partition sizes are unchanged. | `get flood.rule[.n]`, `set flood.rule[.n] type=<type> [hops=...] [channel=...] [prefix=...] [in=...] <drop|scope=...|region=...|rate=N/min|retry|stop> [priority=0-255]`, `del flood.rule.<n>|all` | `set flood.rule.2 type=any channel=#hamradio retry` |
|
||||
| `flood.moderation` | Decrypts keyed `GRP_TXT` channels and applies drop, per-username messages/minute, and maximum-hop controls, optionally matched against the first 1-3 path hashes. Supports `public`, `#channel`, and 128/256-bit channel keys. Sender names and truncated path hashes are moderation hints, not authenticated identities. | `get flood.moderation[.n]`, `set flood.moderation[.n] <channel> <sender> <drop|rate=X/min|hops=N> [path=...]`, `del flood.moderation.<n>|all` | `set flood.moderation public "Noisy User" rate=5/min hops=4` |
|
||||
| `clock.sync.mesh` | Defaults on for all repeater, sensor, and room-server builds; a saved setting overrides that default. It estimates UTC as soon as the configured number of fresh signed-advert or valid Public-channel sources is collected, with a 30-minute bootstrap/retry timer when evidence is still insufficient, then repeats lazily seven days after each successful estimate. New evidence retriggers evaluation after a no-consensus result. `clock.sync.mesh now` queues an immediate LoRa-only attempt without bypassing quorum or source suppression. Only timestamps from firmware build time through build time plus ten years are recorded. Successful CLI, GPS, or WiFi/NTP clock updates suppress LoRa time collection until reboot; after reboot LoRa is the fallback if NTP cannot sync. Status reports the reason a clock was not set; its `.table` and `.1` through `.16` forms inspect collected samples. | `get clock.sync.mesh`, `set clock.sync.mesh <on|off>`, `clock.sync.mesh now`, `get clock.sync.status[.table|.1-.16]` | `set clock.sync.mesh on` |
|
||||
@@ -98,6 +98,14 @@ set flood.retry.ignore none
|
||||
| `outpath` | Overrides the primary direct route used for replies to the current remote client. | `get outpath`, `set outpath <hops>`, `set outpath direct`, `set outpath clear`, `set outpath flood` | `set outpath A1B2C3,D4E5F6` |
|
||||
| `altpath` | Adds a secondary direct route for repeater replies to the current remote client. | `get altpath`, `set altpath <hops>`, `set altpath direct`, `set altpath clear`, `set altpath flood` | `set altpath 71CE82,BA09F0` |
|
||||
|
||||
The generalized filter table also accepts `fr` as a short alias for
|
||||
`flood.rule`: `set fr.3 any c=#wardriving m=bc d` is the full third built-in
|
||||
rule. Modes can be `m=r` (radio), `m=b` (bridge), `m=c` (crossover), or `m=bc`
|
||||
(both transport paths). `get fr.N` returns a short setter ready to copy;
|
||||
`del fr.N` deletes that row. Full names and short tokens can be mixed, with
|
||||
the same validation and permissions. See the
|
||||
[shorthand reference](flood_filtering.md#shorthand-for-copying-rules).
|
||||
|
||||
## Other Keymind Commands
|
||||
|
||||
| Command | What it does | How to use | Example |
|
||||
|
||||
+148
-5
@@ -29,7 +29,7 @@ denominator, mode, optional preamble in symbols**. `5` means coding rate 4/5.
|
||||
|
||||
| Mode | Receive on profile 2 | Transmit on profile 2 |
|
||||
| --- | --- | --- |
|
||||
| `rx` | Yes | No |
|
||||
| `rx` | Yes | Only infrastructure replies explicitly configured with `tx.reply ... force` |
|
||||
| `rxtx` (also `rx&tx`) | Yes | Yes |
|
||||
| `off` | No | No |
|
||||
|
||||
@@ -53,7 +53,8 @@ get tempradio2
|
||||
```
|
||||
|
||||
This enables the second profile for 120 minutes (2 hours). Locally generated
|
||||
LoRa OTA packets use the temporary second profile. Ordinary local traffic stays
|
||||
LoRa OTA requests use the temporary second profile. Infrastructure OTA responses
|
||||
follow `tx.reply` below (both TX-capable profiles by default). Ordinary local traffic stays
|
||||
on the primary profile by default. Use an OTA-capable build and the usual OTA
|
||||
setup on every participating node; a temporary profile does not add OTA support
|
||||
to firmware built without it.
|
||||
@@ -62,6 +63,67 @@ New settings take effect after a short reply allowance. Temporary periods and
|
||||
schedules live in RAM and disappear on reboot. Timer expiry also uses monotonic
|
||||
time, so setting the clock backwards cannot extend a temporary session.
|
||||
|
||||
## Repeater, room-server, and sensor replies
|
||||
|
||||
These roles default to **`tx.reply both`**: locally generated replies are queued
|
||||
on `radio` and on an active `radio2` that permits TX. This includes replies to
|
||||
remote CLI/login/telemetry requests, ACKs, returned paths, delayed GPIO command
|
||||
completion, room subscription posts, and locally served LoRa OTA catalog,
|
||||
manifest, data, proof, and leaves responses. Each copy has its own retry state.
|
||||
Forwarded packets, periodic adverts, sensor pushes, and OTA requests continue
|
||||
to use the normal crossing rules.
|
||||
|
||||
```text
|
||||
get tx.reply
|
||||
set tx.reply both
|
||||
set tx.reply both force
|
||||
set tx.reply radio
|
||||
set tx.reply auto
|
||||
```
|
||||
|
||||
| Choice | Replies transmit on |
|
||||
| --- | --- |
|
||||
| `both` | Primary plus an active `rxtx` second profile. Default. |
|
||||
| `radio` | Primary only. |
|
||||
| `radio2` | Second only; no primary fallback. |
|
||||
| `auto` | Normal `radio2.cross` behavior, including permanent/temporary isolation. |
|
||||
| `off` | Neither profile. This also suppresses remote CLI replies and ACKs. |
|
||||
|
||||
Append **`force`** to `both` or `radio2` to allow these replies on an active
|
||||
second profile configured as `rx`. Without `force`, `rx` remains receive-only.
|
||||
Sending the command again without `force` removes the override. It never turns
|
||||
on `radio2 off`, changes the saved `rx` mode, or enables ordinary forwarding/TX
|
||||
there. Existing crossover packet filters still apply to the extra copy.
|
||||
|
||||
The explicit choices override `radio2.cross`, including `off` and mixed
|
||||
permanent/temporary profiles. `radio` means the active primary profile,
|
||||
including `tempradio`; `radio2` also includes an active `tempradio2`.
|
||||
For example, an OTA source can retain its normal channel while allowing OTA
|
||||
and management responses on a temporary receive profile:
|
||||
|
||||
```text
|
||||
set tempradio2 910.5,500,8,5,rx,120
|
||||
set tx.reply both force
|
||||
```
|
||||
|
||||
The source must have OTA support and an active temporary-radio session as
|
||||
usual. A node fetching an update still needs `rxtx` for its OTA requests.
|
||||
Forcing replies does not change OTA's receive gates, scan timing, or preambles.
|
||||
|
||||
This setting survives reboot; older saved profiles adopt `both` without force.
|
||||
The getter reports when the chosen second profile cannot transmit. Already
|
||||
queued replies retain their choice; disabling/changing a profile invalidates
|
||||
stale queued packets. A parameterized repeater `tempradio` command waits for
|
||||
both admitted reply copies to drain before changing the primary settings;
|
||||
at least one must transmit successfully. Those tracked replies omit alternate
|
||||
paths and transport retries so no late success copy can outlive the handoff.
|
||||
Queue capacity and the shared transceiver's airtime budget still limit delivery.
|
||||
OTA data/proof admission counts both copies against its queue credit and
|
||||
reserves receive capacity before allowing the response.
|
||||
|
||||
Companions use the per-contact/channel choices below; `tx.reply` is an
|
||||
infrastructure setting.
|
||||
|
||||
## Companion messages on both profiles
|
||||
|
||||
To send ordinary Companion messages on both `radio` and an active `tempradio2`,
|
||||
@@ -83,6 +145,83 @@ ends or the node reboots. Run `set radio2.cross auto` to restore the default
|
||||
isolation between permanent and temporary profiles. The firmware picker's
|
||||
Companion tempradio2 instructions include both choices and the status commands.
|
||||
|
||||
## Companion TX routing by contact or channel
|
||||
|
||||
Companions can save a transmit choice for each contact and each configured
|
||||
channel. The commands apply to messages sent from the phone app, USB/BLE/TCP
|
||||
Companion clients, and the text terminal:
|
||||
|
||||
```text
|
||||
set tx.user "Alice Jones" radio2
|
||||
get tx.user "Alice Jones"
|
||||
set tx.channel 0 both
|
||||
get tx.channel 0
|
||||
set tx.channel #wardriving radio
|
||||
get tx.channel #wardriving
|
||||
```
|
||||
|
||||
Channel indices match the app and the terminal's `channels` list; Public is
|
||||
normally slot `0`. Names must match exactly, including capitalization and any
|
||||
leading `#`. Quotes always select a literal name, so `"1"` is a channel named
|
||||
`1`, while unquoted `1` selects slot 1. Quote contact names beginning with
|
||||
`key:` to distinguish them from public-key selectors. Duplicate names are
|
||||
rejected: use the channel index or a contact's public key instead.
|
||||
|
||||
| Choice | Locally addressed traffic transmits on |
|
||||
| --- | --- |
|
||||
| `auto` | Existing behavior, including `radio2.cross` and temporary-profile isolation. This is the default. |
|
||||
| `radio` | The primary profile only. |
|
||||
| `radio2` | The second profile only; sending fails if it is off or RX-only. |
|
||||
| `both` | The primary profile and the second profile whenever the second is enabled for TX. |
|
||||
| `off` | Neither profile for this contact/channel. Reception stays enabled. |
|
||||
|
||||
Explicit choices override `radio2.cross`, including `off` and the isolation
|
||||
between a permanent and temporary profile. They do not turn on radio2 or
|
||||
change its `rx`/`rxtx` mode. For example, leave `radio2.cross auto` and use
|
||||
`set tx.channel 0 both` to send Public messages on both `radio` and an active
|
||||
`tempradio2`, while other channels keep their default routing.
|
||||
|
||||
`radio` means the currently active primary profile, whether permanent or
|
||||
`tempradio`; `radio2` similarly includes `tempradio2`. There are still at most
|
||||
two active profiles. Getters show the saved choice and `active=` for newly
|
||||
initiated messages with the current profile configuration. A temporarily
|
||||
unavailable second profile is reported explicitly.
|
||||
|
||||
Contact selection uses the full stored public key after resolving a unique
|
||||
name or key prefix. It never routes by the short on-air destination hash.
|
||||
For key selection, prefix 12–64 hexadecimal digits (whole bytes) with `key:`:
|
||||
|
||||
```text
|
||||
set tx.user key:0123456789AB radio2
|
||||
get tx.user key:0123456789AB
|
||||
```
|
||||
|
||||
Replace the example prefix with your contact's key; use more digits if it is
|
||||
ambiguous. `get tx.user` and `get tx.channel` report override counts and query
|
||||
syntax. To return a target to the global behavior:
|
||||
|
||||
```text
|
||||
set tx.user "Alice Jones" auto
|
||||
set tx.channel 0 auto
|
||||
```
|
||||
|
||||
A user's setting covers addressed texts, login/CLI/telemetry requests, replies,
|
||||
ACKs, and returned paths to that contact. Thus `off` also prevents ACKs to
|
||||
that contact. Channel settings cover group text and group data, including
|
||||
an explicitly routed group datagram. Contact choices do not filter other
|
||||
people's messages inside a group channel. Adverts, raw traces, raw packets,
|
||||
and OTA packets without a contact/channel destination retain global routing.
|
||||
Packets and their retries already queued retain the choice used when created;
|
||||
changing or disabling the radio profile still invalidates stale queued work.
|
||||
Message timeout estimates use the selected profiles' airtime, independently
|
||||
of the scanner's current receive profile.
|
||||
|
||||
Choices survive reboot and normal phone-app updates to the same contact or
|
||||
channel key. Replacing a channel's key or deleting/replacing a contact clears
|
||||
its choice. They use reserved bytes in existing saved records, so record
|
||||
sizes and the phone protocol are unchanged. Older firmware ignores these
|
||||
bytes and may reset the choices when it saves the records.
|
||||
|
||||
## Choose whether traffic crosses between profiles
|
||||
|
||||
```text
|
||||
@@ -99,12 +238,16 @@ set radio2.cross off
|
||||
| `radio` | `tempradio2` | Isolated | Cross | Isolated |
|
||||
| `tempradio` | `radio2` | Isolated | Cross | Isolated |
|
||||
|
||||
Crossing always respects `rx`: nothing transmits on an RX-only second profile.
|
||||
These are the global rules used by Companion targets set to `auto`; an explicit
|
||||
contact/channel choice above overrides crossing for its own outbound packets.
|
||||
Ordinary crossing respects `rx`. The infrastructure-only `tx.reply ... force`
|
||||
override above permits locally generated replies on that profile.
|
||||
With crossing allowed, a packet received there may still be forwarded on the
|
||||
primary profile. Existing routing, forwarding and packet-filter settings apply.
|
||||
An RX-only `tempradio2` also blocks locally generated OTA transmissions under
|
||||
An RX-only `tempradio2` also blocks locally generated OTA requests under
|
||||
`auto` or `off`; they do not fall back to the normal primary channel. Use `rxtx`
|
||||
for an update session that needs to exchange requests and data.
|
||||
for a fetching node that needs to exchange requests and data. Infrastructure
|
||||
OTA responses follow `tx.reply` independently of this crossing table.
|
||||
|
||||
## Preamble
|
||||
|
||||
|
||||
@@ -85,6 +85,26 @@ The browser CLI requires station/LAN mode and `wifi.cli on`. Infrastructure
|
||||
uses its admin login; Companion uses the trusted LAN. Configuration forms keep
|
||||
password fields masked; explicit local CLI getters return their values.
|
||||
|
||||
## Companion contact and channel TX profiles
|
||||
|
||||
Choose where the Companion transmits to a contact or group channel:
|
||||
|
||||
```text
|
||||
set tx.user "Alice Jones" radio2
|
||||
get tx.user "Alice Jones"
|
||||
set tx.channel 0 both
|
||||
set tx.channel #wardriving radio
|
||||
get tx.channel 0
|
||||
```
|
||||
|
||||
Values are `auto`, `radio`, `radio2`, `both`, and `off`. `auto` follows
|
||||
`radio2.cross`; explicit choices override it. Radio2 must still be in `rxtx`
|
||||
mode, and a `radio2`-only send fails when it cannot transmit. These settings
|
||||
persist and also apply to messages sent by the phone app. Use exact names,
|
||||
a channel index, or `key:<unique public-key prefix>` for a contact.
|
||||
See the [routing guide](radio_profiles.md#companion-tx-routing-by-contact-or-channel)
|
||||
for which requests/replies are covered and how to reset a choice.
|
||||
|
||||
## Companion USB mode
|
||||
|
||||
Updated 1.17.1.6 USB Companion builds, including ordinary and Full builds, start
|
||||
|
||||
@@ -942,12 +942,11 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou
|
||||
static bool serializeContactRecord(const ContactInfo& c,
|
||||
uint8_t out[mesh::storage::CONTACT_RECORD_SIZE]) {
|
||||
size_t offset = 0;
|
||||
const uint8_t unused = 0;
|
||||
memcpy(&out[offset], c.id.pub_key, 32); offset += 32;
|
||||
memcpy(&out[offset], c.name, 32); offset += 32;
|
||||
out[offset++] = c.type;
|
||||
out[offset++] = c.flags;
|
||||
out[offset++] = unused;
|
||||
out[offset++] = mesh::encodeRadioTxPolicy(c.tx_radio);
|
||||
memcpy(&out[offset], &c.sync_since, 4); offset += 4;
|
||||
out[offset++] = c.out_path_len;
|
||||
memcpy(&out[offset], &c.last_advert_timestamp, 4); offset += 4;
|
||||
@@ -970,7 +969,7 @@ static bool deserializeContactRecord(
|
||||
c.name[sizeof(c.name) - 1] = 0;
|
||||
c.type = in[offset++];
|
||||
c.flags = in[offset++];
|
||||
offset++; // reserved
|
||||
c.tx_radio = mesh::decodeRadioTxPolicy(in[offset++]);
|
||||
memcpy(&c.sync_since, &in[offset], 4); offset += 4;
|
||||
c.out_path_len = in[offset++];
|
||||
memcpy(&c.last_advert_timestamp, &in[offset], 4); offset += 4;
|
||||
@@ -1712,7 +1711,7 @@ bool DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn
|
||||
if (file) {
|
||||
uint32_t idx = 0;
|
||||
ContactInfo c;
|
||||
uint8_t unused = 0;
|
||||
uint8_t tx_radio = 0;
|
||||
|
||||
while (host->getContactForSave(idx, c)) {
|
||||
if (filter && !filter(c)) {
|
||||
@@ -1723,7 +1722,8 @@ bool DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn
|
||||
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
|
||||
success = success && (file.write(&c.type, 1) == 1);
|
||||
success = success && (file.write(&c.flags, 1) == 1);
|
||||
success = success && (file.write(&unused, 1) == 1);
|
||||
tx_radio = mesh::encodeRadioTxPolicy(c.tx_radio);
|
||||
success = success && (file.write(&tx_radio, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
|
||||
@@ -1986,6 +1986,7 @@ void DataStore::loadChannels(DataStoreHost* host) {
|
||||
sizeof(loaded[channel_idx].channel.secret))
|
||||
== sizeof(loaded[channel_idx].channel.secret);
|
||||
if (!success) break;
|
||||
loaded[channel_idx].channel.tx_radio = mesh::decodeRadioTxPolicy(unused[0]);
|
||||
}
|
||||
file.close();
|
||||
if (!success) {
|
||||
@@ -2019,6 +2020,7 @@ void DataStore::loadChannels(DataStoreHost* host) {
|
||||
|
||||
if (!success) break; // EOF
|
||||
|
||||
ch.channel.tx_radio = mesh::decodeRadioTxPolicy(unused[0]);
|
||||
if (host->onChannelLoaded(channel_idx, ch)) {
|
||||
channel_idx++;
|
||||
} else {
|
||||
@@ -2047,6 +2049,7 @@ bool DataStore::saveChannels(DataStoreHost* host) {
|
||||
memset(unused, 0, 4);
|
||||
|
||||
while (success && host->getChannelForSave(channel_idx, ch)) {
|
||||
unused[0] = mesh::encodeRadioTxPolicy(ch.channel.tx_radio);
|
||||
success = (file.write(unused, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)ch.name, 32) == 32);
|
||||
success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include <helpers/ui/DisplayPowerSettings.h>
|
||||
#include "MyMesh.h"
|
||||
#include <helpers/CompanionTxRoutingCLI.h>
|
||||
#include "CompanionBluetooth.h"
|
||||
#include "CompanionWireless.h"
|
||||
#if defined(MESH_SOAK_DIAGNOSTICS)
|
||||
@@ -2298,11 +2299,106 @@ bool MyMesh::handleCadCommand(const char* command, char* reply,
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MyMesh::handleTxRoutingCommand(const char* command, char* reply, size_t reply_size) {
|
||||
const auto parsed = mesh::companion::parseTxRouteCommand(command);
|
||||
if (!parsed.matched) return false;
|
||||
const char* kind = parsed.channel ? "channel" : "user";
|
||||
if (!parsed.valid) {
|
||||
snprintf(reply, reply_size, "Error: use get/set tx.%s <name|%s> [auto|radio|radio2|both|off]",
|
||||
kind, parsed.channel ? "index" : "key:hex");
|
||||
return true;
|
||||
}
|
||||
if (parsed.set && !canMutateContacts()) {
|
||||
snprintf(reply, reply_size, "Error: contact/channel storage unavailable");
|
||||
return true;
|
||||
}
|
||||
|
||||
ContactInfo* contact = nullptr;
|
||||
ChannelDetails channel;
|
||||
int channel_index = -1;
|
||||
unsigned matches = 0, overrides = 0;
|
||||
if (parsed.channel) {
|
||||
uint32_t index = 0;
|
||||
const bool by_index = !parsed.quoted && mesh::cli::parseUnsignedIntegerStrict(parsed.target, index);
|
||||
for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) {
|
||||
ChannelDetails candidate;
|
||||
if (!getChannel(i, candidate) || !candidate.name[0]) continue;
|
||||
if (candidate.channel.tx_radio != mesh::RADIO_TX_AUTO) ++overrides;
|
||||
if (by_index ? index == (uint32_t)i : !strcmp(candidate.name, parsed.target)) {
|
||||
channel = candidate; channel_index = i; ++matches;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uint8_t key[32]; size_t key_bytes = 0;
|
||||
const bool by_key = !parsed.quoted && !strncmp(parsed.target, "key:", 4);
|
||||
if (by_key && !mesh::companion::parseTxRouteKey(parsed.target + 4, key, key_bytes)) {
|
||||
snprintf(reply, reply_size, "Error: key: needs 12-64 hexadecimal digits (whole bytes)");
|
||||
return true;
|
||||
}
|
||||
for (int i = MAX_ANON_CONTACTS; i < getTotalContactSlots(); ++i) {
|
||||
ContactInfo* candidate = getContactPtrByIdx(i);
|
||||
if (!candidate || candidate->type == ADV_TYPE_NONE) continue;
|
||||
if (candidate->tx_radio != mesh::RADIO_TX_AUTO) ++overrides;
|
||||
if (by_key ? !memcmp(candidate->id.pub_key, key, key_bytes) : !strcmp(candidate->name, parsed.target)) {
|
||||
contact = candidate; ++matches;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!parsed.target[0]) {
|
||||
snprintf(reply, reply_size, "> tx.%s: %u overrides; use get tx.%s <name|%s>",
|
||||
kind, overrides, kind, parsed.channel ? "index" : "key:hex");
|
||||
return true;
|
||||
}
|
||||
if (matches != 1) {
|
||||
snprintf(reply, reply_size, "Error: %s %s%s", kind, matches ? "is ambiguous" : "not found",
|
||||
matches ? (parsed.channel ? "; use an index" : "; use a longer key: prefix") : "");
|
||||
return true;
|
||||
}
|
||||
uint8_t policy = parsed.channel ? channel.channel.tx_radio : contact->tx_radio;
|
||||
if (parsed.set && policy != parsed.policy) {
|
||||
bool saved;
|
||||
if (parsed.channel) {
|
||||
const ChannelDetails previous = channel;
|
||||
channel.channel.tx_radio = parsed.policy;
|
||||
saved = setChannel(channel_index, channel) && saveChannels();
|
||||
if (!saved) setChannel(channel_index, previous);
|
||||
} else {
|
||||
contact->tx_radio = parsed.policy;
|
||||
saved = scheduleContactWrite(*contact) && flushContactsBeforeReboot();
|
||||
if (!saved) {
|
||||
contact->tx_radio = policy;
|
||||
scheduleContactWrite(*contact);
|
||||
}
|
||||
}
|
||||
if (!saved) {
|
||||
snprintf(reply, reply_size, "Error: TX routing was not saved");
|
||||
return true;
|
||||
}
|
||||
policy = parsed.policy;
|
||||
}
|
||||
const auto* profiles = _radio->profiles();
|
||||
const uint8_t mask = policy == mesh::RADIO_TX_AUTO ? (profiles ? profiles->transmitMask(0) : 1)
|
||||
: mesh::explicitRadioTxMask(policy, profiles && profiles->canTransmit(1));
|
||||
const char* active = mask ? mesh::radioTxPolicyName(mask) : "off";
|
||||
char label[80];
|
||||
if (parsed.channel) snprintf(label, sizeof(label), "%d \"%s\"", channel_index, channel.name);
|
||||
else {
|
||||
char key_prefix[13]; mesh::Utils::toHex(key_prefix, contact->id.pub_key, 6);
|
||||
snprintf(label, sizeof(label), "\"%s\" key:%s", contact->name, key_prefix);
|
||||
}
|
||||
snprintf(reply, reply_size, "%s tx.%s %s %s; active=%s%s",
|
||||
parsed.set ? "OK -" : ">", kind, label, mesh::radioTxPolicyName(policy), active,
|
||||
(policy == mesh::RADIO_TX_SECONDARY || policy == mesh::RADIO_TX_BOTH) && !(mask & 2)
|
||||
? " (radio2 unavailable for TX)" : "");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
|
||||
size_t reply_size) {
|
||||
if (!command || !reply || reply_size == 0) return false;
|
||||
while (*command == ' ') command++;
|
||||
|
||||
if (handleTxRoutingCommand(command, reply, reply_size)) return true;
|
||||
if (handleCompanionBluetoothCommand(command, reply, reply_size)) return true;
|
||||
if (handleCompanionWirelessCommand(command, reply, reply_size)) return true;
|
||||
|
||||
@@ -5060,7 +5156,12 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32);
|
||||
memset(channel.channel.secret, 0, sizeof(channel.channel.secret));
|
||||
memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // NOTE: only 128-bit supported
|
||||
if (!getChannel(channel_idx, previous)
|
||||
const bool have_previous = getChannel(channel_idx, previous);
|
||||
if (have_previous && channel.name[0] != 0 && previous.name[0] != 0
|
||||
&& !memcmp(channel.channel.secret, previous.channel.secret, sizeof(channel.channel.secret))) {
|
||||
channel.channel.tx_radio = previous.channel.tx_radio;
|
||||
}
|
||||
if (!have_previous
|
||||
|| !setChannel(channel_idx, channel)) {
|
||||
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
|
||||
} else if (saveChannels()) {
|
||||
|
||||
@@ -237,6 +237,7 @@ public:
|
||||
// setup/status commands here; Full builds add TempRadio and OTA commands.
|
||||
bool handleLocalControlCommand(const char* command, char* reply,
|
||||
size_t reply_size);
|
||||
bool handleTxRoutingCommand(const char* command, char* reply, size_t reply_size);
|
||||
|
||||
int getRecentlyHeard(AdvertPath dest[], int max_num);
|
||||
|
||||
|
||||
+177
-128
@@ -14,6 +14,7 @@
|
||||
#include <helpers/ClientPathPersistence.h>
|
||||
#include <helpers/DatagramPayloadLimits.h>
|
||||
#include <helpers/FloodFilterPolicy.h>
|
||||
#include <helpers/FloodRuleCLI.h>
|
||||
#include <helpers/LazyPersistence.h>
|
||||
#include <helpers/RegionNameUtils.h>
|
||||
#if MESH_PACKET_LOGGING
|
||||
@@ -1031,7 +1032,7 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) {
|
||||
}
|
||||
#endif
|
||||
#if !defined(PORTABLE_MQTT_OBSERVER)
|
||||
if (shouldBlockFloodPacketForward(packet)) return false;
|
||||
if (shouldBlockFloodPacketForward(packet, recv_pkt_filter_match_mask)) return false;
|
||||
#endif
|
||||
}
|
||||
if (packet->isRouteFlood() && recv_pkt_channel_scope_rejected) {
|
||||
@@ -1066,7 +1067,7 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) {
|
||||
#if MESH_ENABLE_FLOOD_GROUP_MODERATION
|
||||
if (packet->isRouteFlood() && shouldBlockFloodGroupTextForward(packet)) return false;
|
||||
#endif
|
||||
if (packet->isRouteFlood()) commitFloodPacketFilterRates(packet);
|
||||
if (packet->isRouteFlood()) commitFloodPacketFilterRates(packet, recv_pkt_filter_match_mask);
|
||||
// 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
|
||||
@@ -1087,6 +1088,29 @@ const char *MyMesh::getLogDateTime() {
|
||||
return tmp;
|
||||
}
|
||||
|
||||
#if MESH_ENABLE_FLOOD_RULE_ENGINE
|
||||
bool MyMesh::allowTransportPacket(const mesh::Packet* packet, uint8_t context) {
|
||||
if (!packet) return false;
|
||||
RegionEntry* incoming_region = NULL;
|
||||
bool scoped = packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD
|
||||
|| packet->getRouteType() == ROUTE_TYPE_TRANSPORT_DIRECT;
|
||||
bool incoming_allowed;
|
||||
if (scoped) {
|
||||
incoming_region = region_map.findMatch(packet, REGION_DENY_FLOOD);
|
||||
incoming_allowed = incoming_region != NULL;
|
||||
} else {
|
||||
incoming_allowed = (region_map.getWildcard().flags & REGION_DENY_FLOOD) == 0;
|
||||
}
|
||||
// Use a local mask: bridge RX/TX callbacks can run inside mesh dispatch,
|
||||
// where recv_pkt_* still belongs to the ordinary radio forwarding phase.
|
||||
uint64_t matches = evaluateFloodPacketFilterMatches(
|
||||
packet, incoming_allowed, incoming_region, context);
|
||||
if (shouldBlockFloodPacketForward(packet, matches)) return false;
|
||||
commitFloodPacketFilterRates(packet, matches);
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
|
||||
#if MESH_PACKET_LOGGING
|
||||
if (mesh::isUsbLoggingEnabled()) {
|
||||
@@ -2920,15 +2944,17 @@ bool MyMesh::sendRemoteCliReply(ClientInfo* client, const uint8_t* secret,
|
||||
|
||||
mesh::Packet* packet = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret,
|
||||
reply_data, 5 + text_len);
|
||||
// A caller which requests the exact queued packet is going to make a state
|
||||
// transition depend on that packet's TX result. Keep one authoritative
|
||||
// copy: an untracked alternate or retry could report success after primary
|
||||
// failure cancelled the transition, or still be queued when primary success
|
||||
// lets the radio tuple change. Ordinary replies retain redundant delivery.
|
||||
if (packet) packet->radio_reply = true; // also covers delayed GPIO completion
|
||||
// A state-changing reply tracks its first copy on each selected profile.
|
||||
// Suppress alternate paths and retries, which could outlive the handoff.
|
||||
// Prepare before admission so Dispatcher can register its second copy;
|
||||
// the caller arms the barrier only after admission succeeds.
|
||||
const bool allow_redundant_copies = queued_packet == NULL;
|
||||
if (!allow_redundant_copies) temp_radio_reply_barrier.prepare(packet);
|
||||
const bool queued = sendClientReplyWithFallbackScope(
|
||||
client, packet, CLI_REPLY_DELAY_MILLIS, path_hash_size, fallback_scope,
|
||||
allow_redundant_copies);
|
||||
if (!queued && !allow_redundant_copies) temp_radio_reply_barrier.clear();
|
||||
if (queued && queued_packet != NULL) *queued_packet = packet;
|
||||
return queued;
|
||||
}
|
||||
@@ -3197,9 +3223,8 @@ void __attribute__((noinline)) MyMesh::processDeferredCliCommand() {
|
||||
arms_temp_radio ? &queued_reply : NULL);
|
||||
if (arms_temp_radio) {
|
||||
if (reply_queued && queued_reply != NULL) {
|
||||
// The old two-RTC-second delay could expire while this exact packet was
|
||||
// still parked behind CAD/duty/queue work. Do not change modulation
|
||||
// until Dispatcher confirms that the reply physically transmitted.
|
||||
// A fixed delay can expire while either copy is parked behind queue
|
||||
// work. Wait for both to drain and at least one to physically transmit.
|
||||
temp_radio_reply_barrier.arm(queued_reply);
|
||||
} else {
|
||||
// A command which cannot queue its acknowledgement must never strand
|
||||
@@ -3708,6 +3733,7 @@ void MyMesh::begin(FILESYSTEM *fs) {
|
||||
#endif
|
||||
#endif
|
||||
|
||||
configureBridgeFilter(active_bridge);
|
||||
active_bridge->begin();
|
||||
}
|
||||
#endif
|
||||
@@ -3939,10 +3965,16 @@ void MyMesh::onSendComplete(mesh::Packet* packet) {
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original,
|
||||
uint8_t priority) {
|
||||
temp_radio_reply_barrier.trackCopy(original, packet);
|
||||
mesh::Mesh::onRadioProfileCopyQueued(packet, original, priority);
|
||||
}
|
||||
|
||||
void MyMesh::onSendFail(mesh::Packet* packet) {
|
||||
mesh::Mesh::onSendFail(packet);
|
||||
if (temp_radio_reply_barrier.fail(packet)) {
|
||||
// Failure of the exact acknowledgement packet cancels the unconfirmed
|
||||
// Failure of every acknowledgement copy cancels the unconfirmed
|
||||
// handoff. A later cached-command retry may replay the reply, but it does
|
||||
// not re-run the mutation.
|
||||
scheduleNormalRadio();
|
||||
@@ -4530,7 +4562,7 @@ void MyMesh::processScheduledRadioSettings() {
|
||||
}
|
||||
|
||||
if (temp_radio_reply_barrier.waiting() && !hard_temp_end_due) {
|
||||
// The exact parameterized-TempRadio reply is queued or on air. Unlike a
|
||||
// A parameterized-TempRadio reply copy is queued or on air. Unlike a
|
||||
// fixed RTC delay, this remains correct under CAD, duty throttling, and
|
||||
// unrelated queue pressure. TX completion/failure releases the barrier.
|
||||
scheduled_radio_retry_at = futureMillis(RADIO_APPLY_RETRY_INTERVAL_MILLIS);
|
||||
@@ -5296,7 +5328,7 @@ static bool parseFloodFilterPayloadType(const char* text, uint8_t& type) {
|
||||
return true;
|
||||
}
|
||||
if (floodFilterAsciiStartsWith(text, "payload_type_")) text += strlen("payload_type_");
|
||||
if (floodFilterAsciiEqual(text, "any")) type = FLOOD_PACKET_FILTER_ANY_TYPE;
|
||||
if (floodFilterAsciiEqual(text, "any") || floodFilterAsciiEqual(text, "*")) type = FLOOD_PACKET_FILTER_ANY_TYPE;
|
||||
else if (floodFilterAsciiEqual(text, "req")) type = PAYLOAD_TYPE_REQ;
|
||||
else if (floodFilterAsciiEqual(text, "response") || floodFilterAsciiEqual(text, "resp")) type = PAYLOAD_TYPE_RESPONSE;
|
||||
else if (floodFilterAsciiEqual(text, "txt_msg") || floodFilterAsciiEqual(text, "txt")) type = PAYLOAD_TYPE_TXT_MSG;
|
||||
@@ -5317,7 +5349,7 @@ static bool parseFloodFilterPayloadType(const char* text, uint8_t& type) {
|
||||
|
||||
static bool parseFloodFilterHopSpec(const char* text, uint8_t& min_hops, uint8_t& max_hops) {
|
||||
if (text == NULL || *text == 0) return false;
|
||||
if (floodFilterAsciiEqual(text, "all")) {
|
||||
if (floodFilterAsciiEqual(text, "all") || floodFilterAsciiEqual(text, "*")) {
|
||||
min_hops = 0;
|
||||
max_hops = FLOOD_PACKET_FILTER_MAX_HOPS;
|
||||
return true;
|
||||
@@ -5711,7 +5743,7 @@ static void formatFloodModerationPath(
|
||||
const uint8_t path[FLOOD_GROUP_MODERATION_PATH_BYTES_MAX]);
|
||||
|
||||
void MyMesh::seedDefaultFloodPacketFilters() {
|
||||
// Slots 0 and 1 carry the built-in defaults; without a table there is
|
||||
// Slots 0, 1 and 2 carry the built-in defaults; without a table there is
|
||||
// nothing to seed and the node forwards unfiltered.
|
||||
if (flood_packet_filter_slots == 0) return;
|
||||
auto& entry = flood_packet_filters[0];
|
||||
@@ -5744,6 +5776,15 @@ void MyMesh::seedDefaultFloodPacketFilters() {
|
||||
StrHelper::strncpy(wardriving.channel_name, DEFAULT_WARDRIVING_CHANNEL,
|
||||
sizeof(wardriving.channel_name));
|
||||
wardriving.drop_on_match = true;
|
||||
|
||||
// Bridge admission is independent of the ordinary LoRa hop limit. Use the
|
||||
// same authenticated channel identity, including zero-hop/local messages.
|
||||
if (flood_packet_filter_slots < 3) return;
|
||||
auto& bridge_wardriving = flood_packet_filters[2];
|
||||
bridge_wardriving = wardriving;
|
||||
bridge_wardriving.min_hops = 0;
|
||||
bridge_wardriving.transport_modes = FloodFilterPolicy::RULE_MODE_BRIDGE
|
||||
| FloodFilterPolicy::RULE_MODE_CROSS;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -5883,19 +5924,29 @@ bool MyMesh::loadFloodPacketFilters() {
|
||||
} else {
|
||||
loaded[i].retry_on_match = false;
|
||||
}
|
||||
loaded[i].active = active != 0;
|
||||
if (version_7) {
|
||||
success = success && FloodFilterPolicy::decodeStoredRuleActive(
|
||||
active, loaded[i].active, loaded[i].transport_modes);
|
||||
} else {
|
||||
loaded[i].active = active != 0;
|
||||
success = success && active <= 1;
|
||||
}
|
||||
loaded[i].suspend_on_temp_radio = suspend_on_temp_radio != 0;
|
||||
loaded[i].match_blacklisted_path = match_blacklisted_path != 0;
|
||||
loaded[i].scope_uses_slow_timing = scope_uses_slow_timing != 0;
|
||||
loaded[i].drop_on_match = drop_on_match != 0;
|
||||
loaded[i].rate_limit_enabled = rate_limit_enabled != 0;
|
||||
loaded[i].stop_on_match = stop_on_match != 0;
|
||||
if (success && (active > 1 || suspend_on_temp_radio > 1
|
||||
if (success && (suspend_on_temp_radio > 1
|
||||
|| match_blacklisted_path > 1 || scope_requires_region_match > 1
|
||||
|| scope_uses_slow_timing > 1 || drop_on_match > 1
|
||||
|| rate_limit_enabled > 1 || stop_on_match > 1)) {
|
||||
success = false;
|
||||
}
|
||||
success = success && FloodFilterPolicy::transportActionsSupported(
|
||||
loaded[i].transport_modes, loaded[i].scope_name[0] != 0
|
||||
|| loaded[i].target_region_name[0] != 0,
|
||||
loaded[i].retry_on_match, loaded[i].scope_uses_slow_timing);
|
||||
if (!success) break;
|
||||
if (!loaded[i].active) {
|
||||
memset(&loaded[i], 0, sizeof(loaded[i]));
|
||||
@@ -6204,6 +6255,7 @@ bool MyMesh::isFloodChannelDataRule(
|
||||
bool valid_hops = entry.min_hops == 0
|
||||
|| (entry.min_hops >= 2 && entry.min_hops <= 8);
|
||||
return entry.active
|
||||
&& entry.transport_modes == FloodFilterPolicy::RULE_MODE_RADIO
|
||||
&& entry.payload_type == PAYLOAD_TYPE_GRP_DATA
|
||||
&& valid_hops
|
||||
&& entry.max_hops == FLOOD_PACKET_FILTER_MAX_HOPS
|
||||
@@ -6493,6 +6545,7 @@ bool MyMesh::migrateLegacyFloodChannelBlocks() {
|
||||
continue;
|
||||
}
|
||||
duplicate = entry.payload_type == FLOOD_PACKET_FILTER_ANY_TYPE
|
||||
&& entry.transport_modes == FloodFilterPolicy::RULE_MODE_RADIO
|
||||
&& entry.min_hops == min_hops
|
||||
&& entry.max_hops == FLOOD_PACKET_FILTER_MAX_HOPS
|
||||
&& !entry.suspend_on_temp_radio
|
||||
@@ -6578,7 +6631,8 @@ bool MyMesh::saveFloodPacketFilters(bool empty_scope_phase,
|
||||
&& writeExact(&count, sizeof(count));
|
||||
for (int i = 0; success && i < count; i++) {
|
||||
const auto& entry = flood_packet_filters[i];
|
||||
uint8_t active = entry.active ? 1 : 0;
|
||||
uint8_t active = FloodFilterPolicy::encodeStoredRuleActive(
|
||||
entry.active, entry.transport_modes);
|
||||
uint8_t suspend_on_temp_radio = entry.suspend_on_temp_radio ? 1 : 0;
|
||||
uint8_t match_blacklisted_path = entry.match_blacklisted_path ? 1 : 0;
|
||||
uint8_t scope_requires_region_match =
|
||||
@@ -6846,11 +6900,12 @@ bool MyMesh::floodPacketFilterFieldsMatch(
|
||||
const FloodPacketFilterEntry& entry, const mesh::Packet* packet,
|
||||
bool incoming_is_scoped, uint16_t incoming_transport_code,
|
||||
bool incoming_region_allowed,
|
||||
const RegionEntry* incoming_region) const {
|
||||
if (!entry.active || packet == NULL || !packet->isRouteFlood()) return false;
|
||||
const RegionEntry* incoming_region, uint8_t context) const {
|
||||
if (!entry.active || !FloodFilterPolicy::ruleModeMatches(
|
||||
entry.transport_modes, context, packet)) return false;
|
||||
if (!FloodFilterPolicy::channelKeyLengthSupported(
|
||||
entry.channel_key_len)) return false;
|
||||
if (entry.suspend_on_temp_radio && isTempRadioActive()) return false;
|
||||
if (entry.suspend_on_temp_radio && isAnyTempRadioActive()) return false;
|
||||
if (entry.match_blacklisted_path && !floodPacketFilterBlacklistMatches(packet)) {
|
||||
return false;
|
||||
}
|
||||
@@ -6988,12 +7043,13 @@ uint64_t MyMesh::applyFloodPacketFilterStop(uint64_t match_mask) {
|
||||
|
||||
uint64_t MyMesh::evaluateFloodPacketFilterMatches(
|
||||
const mesh::Packet* packet, bool incoming_region_allowed,
|
||||
const RegionEntry* incoming_region) {
|
||||
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 || !packet->isRouteFlood()) return 0;
|
||||
if (packet == NULL || (context == FloodFilterPolicy::RULE_MODE_RADIO && !packet->isRouteFlood())) return 0;
|
||||
bool incoming_is_scoped =
|
||||
packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD;
|
||||
packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD
|
||||
|| packet->getRouteType() == ROUTE_TYPE_TRANSPORT_DIRECT;
|
||||
uint16_t incoming_transport_code = incoming_is_scoped
|
||||
? packet->transport_codes[0] : 0;
|
||||
bool channel_auth_checked[FLOOD_PACKET_FILTER_SLOTS] = { false };
|
||||
@@ -7003,7 +7059,7 @@ uint64_t MyMesh::evaluateFloodPacketFilterMatches(
|
||||
const auto& entry = flood_packet_filters[i];
|
||||
if (!floodPacketFilterFieldsMatch(
|
||||
entry, packet, incoming_is_scoped, incoming_transport_code,
|
||||
incoming_region_allowed, incoming_region)) {
|
||||
incoming_region_allowed, incoming_region, context)) {
|
||||
continue;
|
||||
}
|
||||
bool authenticated = true;
|
||||
@@ -7079,14 +7135,15 @@ bool MyMesh::applyFloodPacketFilterScope(mesh::Packet* packet,
|
||||
return false;
|
||||
}
|
||||
|
||||
bool MyMesh::shouldBlockFloodPacketForward(const mesh::Packet* packet) const {
|
||||
if (packet == NULL || !packet->isRouteFlood()) return false;
|
||||
bool MyMesh::shouldBlockFloodPacketForward(const mesh::Packet* packet,
|
||||
uint64_t match_mask) const {
|
||||
if (packet == NULL) return false;
|
||||
uint8_t type = packet->getPayloadType();
|
||||
uint8_t hops = packet->getPathHashCount();
|
||||
|
||||
uint64_t visited = 0;
|
||||
while (true) {
|
||||
int i = nextFloodPacketFilterMatch(recv_pkt_filter_match_mask, visited);
|
||||
int i = nextFloodPacketFilterMatch(match_mask, visited);
|
||||
if (i < 0) break;
|
||||
visited |= (uint64_t)1U << i;
|
||||
const auto& entry = flood_packet_filters[i];
|
||||
@@ -7108,12 +7165,13 @@ bool MyMesh::shouldBlockFloodPacketForward(const mesh::Packet* packet) const {
|
||||
return false;
|
||||
}
|
||||
|
||||
void MyMesh::commitFloodPacketFilterRates(const mesh::Packet* packet) {
|
||||
if (packet == NULL || !packet->isRouteFlood()) return;
|
||||
void MyMesh::commitFloodPacketFilterRates(const mesh::Packet* packet,
|
||||
uint64_t match_mask) {
|
||||
if (packet == NULL) return;
|
||||
uint32_t now = _ms->getMillis();
|
||||
for (int i = 0; i < flood_packet_filter_slots; i++) {
|
||||
auto& entry = flood_packet_filters[i];
|
||||
if ((recv_pkt_filter_match_mask & ((uint64_t)1U << i)) == 0
|
||||
if ((match_mask & ((uint64_t)1U << i)) == 0
|
||||
|| !entry.rate_limit_enabled) {
|
||||
continue;
|
||||
}
|
||||
@@ -7131,13 +7189,14 @@ bool MyMesh::floodPacketFilterFieldsMatch(
|
||||
const FloodPacketFilterEntry& entry, const mesh::Packet* packet,
|
||||
bool incoming_is_scoped, uint16_t incoming_transport_code,
|
||||
bool incoming_region_allowed,
|
||||
const RegionEntry* incoming_region) const {
|
||||
const RegionEntry* incoming_region, uint8_t context) const {
|
||||
(void)context;
|
||||
(void)incoming_is_scoped;
|
||||
(void)incoming_transport_code;
|
||||
(void)incoming_region_allowed;
|
||||
(void)incoming_region;
|
||||
if (!entry.active || packet == NULL || !packet->isRouteFlood()) return false;
|
||||
if (entry.suspend_on_temp_radio && isTempRadioActive()) return false;
|
||||
if (entry.suspend_on_temp_radio && isAnyTempRadioActive()) return false;
|
||||
if (entry.match_blacklisted_path
|
||||
&& !floodPacketFilterBlacklistMatches(packet)) return false;
|
||||
|
||||
@@ -7150,7 +7209,8 @@ bool MyMesh::floodPacketFilterFieldsMatch(
|
||||
|
||||
uint64_t MyMesh::evaluateFloodPacketFilterMatches(
|
||||
const mesh::Packet* packet, bool incoming_region_allowed,
|
||||
const RegionEntry* incoming_region) {
|
||||
const RegionEntry* incoming_region, uint8_t context) {
|
||||
(void)context;
|
||||
(void)incoming_region;
|
||||
uint64_t matches = 0;
|
||||
for (int i = 0; i < flood_packet_filter_slots; i++) {
|
||||
@@ -7196,13 +7256,14 @@ bool MyMesh::applyFloodPacketFilterScope(mesh::Packet* packet,
|
||||
return false;
|
||||
}
|
||||
|
||||
bool MyMesh::shouldBlockFloodPacketForward(const mesh::Packet* packet) const {
|
||||
bool MyMesh::shouldBlockFloodPacketForward(const mesh::Packet* packet,
|
||||
uint64_t match_mask) const {
|
||||
if (packet == NULL || !packet->isRouteFlood()) return false;
|
||||
uint8_t type = packet->getPayloadType();
|
||||
uint8_t hops = packet->getPathHashCount();
|
||||
for (int i = 0; i < flood_packet_filter_slots; i++) {
|
||||
const auto& entry = flood_packet_filters[i];
|
||||
if ((recv_pkt_filter_match_mask & ((uint64_t)1U << i)) != 0
|
||||
if ((match_mask & ((uint64_t)1U << i)) != 0
|
||||
&& entry.scope_name[0] == 0) {
|
||||
MESH_DEBUG_PRINTLN("allowPacketForward: flood.filter matched slot=%d type=%d hops=%d range=%d-%d",
|
||||
i + 1, type, hops, entry.min_hops,
|
||||
@@ -7213,7 +7274,9 @@ bool MyMesh::shouldBlockFloodPacketForward(const mesh::Packet* packet) const {
|
||||
return false;
|
||||
}
|
||||
|
||||
void MyMesh::commitFloodPacketFilterRates(const mesh::Packet* packet) {
|
||||
void MyMesh::commitFloodPacketFilterRates(const mesh::Packet* packet,
|
||||
uint64_t match_mask) {
|
||||
(void)match_mask;
|
||||
(void)packet;
|
||||
}
|
||||
#endif
|
||||
@@ -7229,12 +7292,16 @@ static bool floodRuleRegionNamePresent(const RegionMap& map,
|
||||
return false;
|
||||
}
|
||||
|
||||
void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len) const {
|
||||
void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len, bool compact) const {
|
||||
if (index < 0 || index >= flood_packet_filter_slots || !flood_packet_filters[index].active) {
|
||||
snprintf(reply, reply_len, "Err - empty filter slot");
|
||||
return;
|
||||
}
|
||||
const auto& entry = flood_packet_filters[index];
|
||||
if (compact) {
|
||||
FloodRuleCLI::formatCompact(reply, reply_len, index + 1, entry);
|
||||
return;
|
||||
}
|
||||
char hops[12];
|
||||
char prefix[32];
|
||||
char incoming[48];
|
||||
@@ -7302,8 +7369,9 @@ void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_
|
||||
}
|
||||
int written = snprintf(
|
||||
reply, reply_len,
|
||||
"> %d type=%s hops=%s channel=%s prefix=%s in=%s %s%s%s priority=%u%s%s%s",
|
||||
index + 1, floodFilterPayloadTypeName(entry.payload_type), hops,
|
||||
"> %d type=%s mode=%s hops=%s channel=%s prefix=%s in=%s %s%s%s priority=%u%s%s%s",
|
||||
index + 1, floodFilterPayloadTypeName(entry.payload_type),
|
||||
FloodFilterPolicy::ruleModeName(entry.transport_modes), hops,
|
||||
entry.channel_key_len == 0 ? "*" : entry.channel_name,
|
||||
prefix, incoming, action, rate, retry,
|
||||
(unsigned int)entry.priority,
|
||||
@@ -7312,81 +7380,10 @@ void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_
|
||||
entry.suspend_on_temp_radio ? " suspend=tempradio" : "");
|
||||
if (written >= 0 && (size_t)written < reply_len) return;
|
||||
|
||||
// A fully populated row can exceed the 160-byte CLI reply. Fall back to a
|
||||
// compact, accepted-by-set spelling instead of silently hiding tail fields.
|
||||
char compact_type[4];
|
||||
if (entry.payload_type == FLOOD_PACKET_FILTER_ANY_TYPE) {
|
||||
strcpy(compact_type, "any");
|
||||
} else {
|
||||
snprintf(compact_type, sizeof(compact_type), "%u",
|
||||
(unsigned int)entry.payload_type);
|
||||
}
|
||||
char compact_incoming[40];
|
||||
switch (entry.incoming_scope_kind) {
|
||||
case FloodFilterPolicy::RULE_IN_NONE:
|
||||
strcpy(compact_incoming, "n");
|
||||
break;
|
||||
case FloodFilterPolicy::RULE_IN_SCOPED:
|
||||
strcpy(compact_incoming, "s");
|
||||
break;
|
||||
case FloodFilterPolicy::RULE_IN_ALLOWED:
|
||||
strcpy(compact_incoming, "a");
|
||||
break;
|
||||
case FloodFilterPolicy::RULE_IN_UNKNOWN:
|
||||
strcpy(compact_incoming, "u");
|
||||
break;
|
||||
case FloodFilterPolicy::RULE_IN_SCOPE:
|
||||
snprintf(compact_incoming, sizeof(compact_incoming), "s:%s",
|
||||
entry.incoming_scope_name);
|
||||
break;
|
||||
case FloodFilterPolicy::RULE_IN_REGION: {
|
||||
snprintf(compact_incoming, sizeof(compact_incoming), "r:%s",
|
||||
entry.incoming_scope_name);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
strcpy(compact_incoming, "*");
|
||||
break;
|
||||
}
|
||||
|
||||
char compact_action[40];
|
||||
if (entry.drop_on_match) {
|
||||
strcpy(compact_action, " drop");
|
||||
} else if (entry.scope_name[0] != 0) {
|
||||
snprintf(compact_action, sizeof(compact_action), " scope=%s",
|
||||
entry.scope_name);
|
||||
} else if (entry.target_region_name[0] != 0) {
|
||||
snprintf(compact_action, sizeof(compact_action), " region=%s",
|
||||
entry.target_region_name);
|
||||
} else {
|
||||
compact_action[0] = 0;
|
||||
}
|
||||
char compact_rate[12];
|
||||
compact_rate[0] = 0;
|
||||
if (entry.rate_limit_enabled) {
|
||||
snprintf(compact_rate, sizeof(compact_rate), " q=%u",
|
||||
(unsigned int)entry.rate_per_minute);
|
||||
}
|
||||
char compact_flags[8];
|
||||
compact_flags[0] = 0;
|
||||
if (entry.scope_uses_slow_timing || entry.suspend_on_temp_radio
|
||||
|| entry.retry_on_match) {
|
||||
snprintf(compact_flags, sizeof(compact_flags), " f=%s%s%s",
|
||||
entry.scope_uses_slow_timing ? "s" : "",
|
||||
entry.suspend_on_temp_radio ? "t" : "",
|
||||
entry.retry_on_match ? "r" : "");
|
||||
}
|
||||
snprintf(reply, reply_len,
|
||||
">%d %s %s c=%s p=%s i=%s%s%s pri=%u%s%s",
|
||||
index + 1, compact_type, hops,
|
||||
entry.channel_key_len == 0 ? "*" : entry.channel_name,
|
||||
prefix, compact_incoming, compact_action, compact_rate,
|
||||
(unsigned int)entry.priority,
|
||||
entry.stop_on_match ? " stop" : "",
|
||||
compact_flags);
|
||||
FloodRuleCLI::formatCompact(reply, reply_len, index + 1, entry);
|
||||
}
|
||||
|
||||
void MyMesh::formatFloodPacketFilters(const char* args, char* reply) const {
|
||||
void MyMesh::formatFloodPacketFilters(const char* args, char* reply, bool compact) const {
|
||||
const char* selector = skipFloodFilterSpaces(args);
|
||||
if (*selector == '.') selector = skipFloodFilterSpaces(selector + 1);
|
||||
if (*selector != 0) {
|
||||
@@ -7395,7 +7392,7 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply) const {
|
||||
snprintf(reply, 160, "Err - filter slot must be 1-%d", flood_packet_filter_slots);
|
||||
return;
|
||||
}
|
||||
formatFloodPacketFilterDetail(slot - 1, reply, 160);
|
||||
formatFloodPacketFilterDetail(slot - 1, reply, 160, compact);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7426,8 +7423,9 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply) 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",
|
||||
i + 1, floodFilterPayloadTypeName(entry.payload_type), hops,
|
||||
snprintf(item, sizeof(item), " %d=%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" : "",
|
||||
target, priority,
|
||||
entry.stop_on_match ? "~stop" : "",
|
||||
@@ -7508,7 +7506,8 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
|
||||
|
||||
uint8_t payload_type;
|
||||
const char* type_text = floodFilterAsciiStartsWith(tokens[0], "type=")
|
||||
? tokens[0] + strlen("type=") : tokens[0];
|
||||
? tokens[0] + strlen("type=")
|
||||
: floodFilterAsciiStartsWith(tokens[0], "t=") ? tokens[0] + 2 : tokens[0];
|
||||
if (!parseFloodFilterPayloadType(type_text, payload_type)) {
|
||||
strcpy(reply, "Err - packet type must be name, any, 0-15, or 0x00-0x0F");
|
||||
return;
|
||||
@@ -7550,8 +7549,22 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
|
||||
bool priority_set = false;
|
||||
bool stop_on_match = false;
|
||||
bool retry_on_match = false;
|
||||
uint8_t transport_modes = FloodFilterPolicy::RULE_MODE_RADIO;
|
||||
bool mode_set = false;
|
||||
for (int i = 1; i < token_count; i++) {
|
||||
if (floodFilterAsciiEqual(tokens[i], "suspend=tempradio")) {
|
||||
if (floodFilterAsciiStartsWith(tokens[i], "mode=")
|
||||
|| floodFilterAsciiStartsWith(tokens[i], "m=")) {
|
||||
if (mode_set) {
|
||||
strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE);
|
||||
return;
|
||||
}
|
||||
const char* value = strchr(tokens[i], '=') + 1;
|
||||
if (!FloodFilterPolicy::parseRuleModes(value, transport_modes)) {
|
||||
strcpy(reply, "Err - mode is radio, bridge, cross, or bridge,cross");
|
||||
return;
|
||||
}
|
||||
mode_set = true;
|
||||
} else if (floodFilterAsciiEqual(tokens[i], "suspend=tempradio")) {
|
||||
if (suspend_on_temp_radio) {
|
||||
strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE);
|
||||
return;
|
||||
@@ -7589,7 +7602,9 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
|
||||
}
|
||||
flags++;
|
||||
}
|
||||
} else if (floodFilterAsciiEqual(tokens[i], "path=blacklist")) {
|
||||
} else if (floodFilterAsciiEqual(tokens[i], "path=blacklist")
|
||||
|| floodFilterAsciiEqual(tokens[i], "p=blacklist")
|
||||
|| floodFilterAsciiEqual(tokens[i], "p=bl")) {
|
||||
if (path_set) {
|
||||
strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE);
|
||||
return;
|
||||
@@ -7671,6 +7686,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
|
||||
priority = (uint8_t)parsed;
|
||||
priority_set = true;
|
||||
} else if (floodFilterAsciiEqual(tokens[i], "stop")
|
||||
|| floodFilterAsciiEqual(tokens[i], "s")
|
||||
|| floodFilterAsciiEqual(tokens[i], "action=stop")) {
|
||||
if (stop_on_match) {
|
||||
strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE);
|
||||
@@ -7678,6 +7694,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
|
||||
}
|
||||
stop_on_match = true;
|
||||
} else if (floodFilterAsciiEqual(tokens[i], "retry")
|
||||
|| floodFilterAsciiEqual(tokens[i], "r")
|
||||
|| floodFilterAsciiEqual(tokens[i], "retry=on")
|
||||
|| floodFilterAsciiEqual(tokens[i], "retry=allow")
|
||||
|| floodFilterAsciiEqual(tokens[i], "action=retry")) {
|
||||
@@ -7698,24 +7715,26 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
|
||||
return;
|
||||
}
|
||||
scope_timing_set = true;
|
||||
} else if (floodFilterAsciiStartsWith(tokens[i], "scope=")) {
|
||||
} else if (floodFilterAsciiStartsWith(tokens[i], "scope=")
|
||||
|| floodFilterAsciiStartsWith(tokens[i], "s=")) {
|
||||
if (target_set || drop_set) {
|
||||
strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE);
|
||||
return;
|
||||
}
|
||||
if (!normalizeFloodFilterScopeName(tokens[i] + strlen("scope="),
|
||||
if (!normalizeFloodFilterScopeName(strchr(tokens[i], '=') + 1,
|
||||
scope_name, sizeof(scope_name))) {
|
||||
strcpy(reply, "Err - scope must be a public name of at most 30 characters");
|
||||
return;
|
||||
}
|
||||
target_set = true;
|
||||
} else if (floodFilterAsciiStartsWith(tokens[i], "region=")) {
|
||||
} else if (floodFilterAsciiStartsWith(tokens[i], "region=")
|
||||
|| floodFilterAsciiStartsWith(tokens[i], "r=")) {
|
||||
if (target_set || drop_set) {
|
||||
strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE);
|
||||
return;
|
||||
}
|
||||
RegionEntry* region = region_map.findByNamePrefix(
|
||||
tokens[i] + strlen("region="));
|
||||
strchr(tokens[i], '=') + 1);
|
||||
TransportKey target_scope;
|
||||
if (region == NULL || region->isWildcard()
|
||||
|| (region->flags & REGION_DENY_FLOOD) != 0
|
||||
@@ -7728,6 +7747,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
|
||||
sizeof(target_region_name));
|
||||
target_set = true;
|
||||
} else if (floodFilterAsciiEqual(tokens[i], "drop")
|
||||
|| floodFilterAsciiEqual(tokens[i], "d")
|
||||
|| floodFilterAsciiEqual(tokens[i], "action=drop")) {
|
||||
if (drop_set || target_set) {
|
||||
strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE);
|
||||
@@ -7773,7 +7793,14 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
|
||||
const char* value = tokens[i]
|
||||
+ (floodFilterAsciiStartsWith(tokens[i], "c=")
|
||||
? 2 : strlen("channel="));
|
||||
if (!floodFilterAsciiEqual(value, "*")) {
|
||||
if (FloodRuleCLI::isChannelReference(value)) {
|
||||
if (!FloodRuleCLI::copyChannelReference(value, flood_packet_filters,
|
||||
flood_packet_filter_slots, channel_key_len, channel_hash,
|
||||
channel_secret, channel_name, sizeof(channel_name))) {
|
||||
strcpy(reply, "Err - unknown or ambiguous channel key reference; use the original key");
|
||||
return;
|
||||
}
|
||||
} else if (!floodFilterAsciiEqual(value, "*")) {
|
||||
if (value[0] == '#'
|
||||
&& strlen(value) >= sizeof(channel_name)) {
|
||||
strcpy(reply, "Err - channel name is too long");
|
||||
@@ -7806,9 +7833,10 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
|
||||
return;
|
||||
}
|
||||
path_set = true;
|
||||
} else if (floodFilterAsciiStartsWith(tokens[i], "hops=")) {
|
||||
} else if (floodFilterAsciiStartsWith(tokens[i], "hops=")
|
||||
|| floodFilterAsciiStartsWith(tokens[i], "h=")) {
|
||||
if (hops_set || !parseFloodFilterHopSpec(
|
||||
tokens[i] + strlen("hops="), min_hops, max_hops)) {
|
||||
strchr(tokens[i], '=') + 1, min_hops, max_hops)) {
|
||||
strcpy(reply, "Err - hops must be all, N, N+, or N-M (0-63)");
|
||||
return;
|
||||
}
|
||||
@@ -7839,6 +7867,11 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
|
||||
strcpy(reply, "Err - channel matcher requires type=any|grp_txt|grp_data");
|
||||
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");
|
||||
return;
|
||||
}
|
||||
bool action_set = drop_set || target_set || rate_limit_enabled
|
||||
|| stop_on_match || retry_on_match;
|
||||
if (require_explicit_action && !action_set) {
|
||||
@@ -7879,6 +7912,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
|
||||
candidate.priority = priority;
|
||||
candidate.stop_on_match = stop_on_match;
|
||||
candidate.retry_on_match = retry_on_match;
|
||||
candidate.transport_modes = transport_modes;
|
||||
StrHelper::strzcpy(candidate.scope_name, scope_name,
|
||||
sizeof(candidate.scope_name));
|
||||
|
||||
@@ -7927,10 +7961,15 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
|
||||
|
||||
char detail[160];
|
||||
formatFloodPacketFilterDetail(slot, detail, sizeof(detail));
|
||||
if (strncmp(detail, "Err - compact", 13) == 0) {
|
||||
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);
|
||||
}
|
||||
#else
|
||||
void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len) const {
|
||||
void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len, bool compact) const {
|
||||
(void)compact;
|
||||
if (index < 0 || index >= flood_packet_filter_slots || !flood_packet_filters[index].active) {
|
||||
snprintf(reply, reply_len, "Err - empty filter slot");
|
||||
return;
|
||||
@@ -7959,7 +7998,8 @@ void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::formatFloodPacketFilters(const char* args, char* reply) const {
|
||||
void MyMesh::formatFloodPacketFilters(const char* args, char* reply, bool compact) const {
|
||||
(void)compact;
|
||||
const char* selector = skipFloodFilterSpaces(args);
|
||||
if (*selector == '.') selector = skipFloodFilterSpaces(selector + 1);
|
||||
if (*selector != 0) {
|
||||
@@ -11046,6 +11086,7 @@ static bool isFilterMgrAllowed(const char* cmd) {
|
||||
|| commandFamilyMatches(cmd, "get flood.filter")
|
||||
#if MESH_ENABLE_FLOOD_RULE_ENGINE
|
||||
|| commandFamilyMatches(cmd, "get flood.rule")
|
||||
|| commandFamilyMatches(cmd, "get fr")
|
||||
#endif
|
||||
|| commandFamilyMatches(cmd, "get flood.moderation")) return true;
|
||||
// General payload/hop filters plus the remaining flood-hop gates.
|
||||
@@ -11053,7 +11094,9 @@ static bool isFilterMgrAllowed(const char* cmd) {
|
||||
|| commandFamilyMatches(cmd, "del flood.filter")
|
||||
#if MESH_ENABLE_FLOOD_RULE_ENGINE
|
||||
|| commandFamilyMatches(cmd, "set flood.rule")
|
||||
|| commandFamilyMatches(cmd, "set fr")
|
||||
|| commandFamilyMatches(cmd, "del flood.rule")
|
||||
|| commandFamilyMatches(cmd, "del fr")
|
||||
#endif
|
||||
|| commandFamilyMatches(cmd, "set flood.moderation")
|
||||
|| commandFamilyMatches(cmd, "del flood.moderation")
|
||||
@@ -11684,6 +11727,12 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
} else if (commandFamilyMatches(command, "del flood.filter")) {
|
||||
deleteFloodPacketFilter(command + strlen("del flood.filter"), reply);
|
||||
#if MESH_ENABLE_FLOOD_RULE_ENGINE
|
||||
} else if (commandFamilyMatches(command, "get fr")) {
|
||||
formatFloodPacketFilters(command + strlen("get fr"), reply, true);
|
||||
} else if (commandFamilyMatches(command, "set fr")) {
|
||||
setFloodPacketFilter(command + strlen("set fr"), reply, true);
|
||||
} 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);
|
||||
} else if (commandFamilyMatches(command, "set flood.rule")) {
|
||||
|
||||
@@ -422,6 +422,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
uint8_t priority;
|
||||
bool stop_on_match;
|
||||
bool retry_on_match;
|
||||
uint8_t transport_modes;
|
||||
uint32_t rate_window_started;
|
||||
uint16_t rate_window_count;
|
||||
bool rate_window_active;
|
||||
@@ -763,7 +764,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
bool incoming_is_scoped,
|
||||
uint16_t incoming_transport_code,
|
||||
bool incoming_region_allowed,
|
||||
const RegionEntry* incoming_region) const;
|
||||
const RegionEntry* incoming_region, uint8_t context = 0) const;
|
||||
bool authenticateFloodPacketFilterChannel(
|
||||
const FloodPacketFilterEntry& entry,
|
||||
const mesh::Packet* packet) const;
|
||||
@@ -777,14 +778,16 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
uint64_t applyFloodPacketFilterStop(uint64_t match_mask);
|
||||
uint64_t evaluateFloodPacketFilterMatches(
|
||||
const mesh::Packet* packet, bool incoming_region_allowed,
|
||||
const RegionEntry* incoming_region);
|
||||
const RegionEntry* incoming_region, uint8_t context = 0);
|
||||
bool applyFloodPacketFilterScope(mesh::Packet* packet, uint64_t match_mask,
|
||||
bool& scope_set, bool& fast_track,
|
||||
bool log_change = true);
|
||||
bool shouldBlockFloodPacketForward(const mesh::Packet* packet) const;
|
||||
void commitFloodPacketFilterRates(const mesh::Packet* packet);
|
||||
void formatFloodPacketFilters(const char* args, char* reply) const;
|
||||
void formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len) const;
|
||||
bool shouldBlockFloodPacketForward(const mesh::Packet* packet,
|
||||
uint64_t match_mask) const;
|
||||
void commitFloodPacketFilterRates(const mesh::Packet* packet,
|
||||
uint64_t match_mask);
|
||||
void formatFloodPacketFilters(const char* args, char* reply, bool compact = false) const;
|
||||
void formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len, bool compact = false) const;
|
||||
void setFloodPacketFilter(const char* args, char* reply,
|
||||
bool require_explicit_action = false);
|
||||
void deleteFloodPacketFilter(const char* args, char* reply);
|
||||
@@ -940,6 +943,8 @@ protected:
|
||||
|
||||
mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override;
|
||||
void onSendComplete(mesh::Packet* packet) override;
|
||||
void onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original,
|
||||
uint8_t priority) override;
|
||||
void onSendFail(mesh::Packet* packet) override;
|
||||
|
||||
void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override;
|
||||
@@ -1100,9 +1105,27 @@ public:
|
||||
bool handleHostCliSerialReply(const char* command, char* reply);
|
||||
#endif
|
||||
void loop();
|
||||
#if MESH_ENABLE_FLOOD_RULE_ENGINE
|
||||
bool allowTransportPacket(const mesh::Packet* packet, uint8_t context);
|
||||
bool allowRadioProfileCross(const mesh::Packet* packet) override {
|
||||
return allowTransportPacket(packet, 4); // RULE_MODE_CROSS
|
||||
}
|
||||
#endif
|
||||
|
||||
uint32_t getPowerSaveSleepSeconds(uint32_t max_secs) const;
|
||||
|
||||
#if defined(WITH_BRIDGE)
|
||||
|
||||
void configureBridgeFilter(AbstractBridge* active_bridge) {
|
||||
#if MESH_ENABLE_FLOOD_RULE_ENGINE
|
||||
active_bridge->setPacketFilter([](void* context, const mesh::Packet* packet) {
|
||||
return static_cast<MyMesh*>(context)->allowTransportPacket(packet, 2); // RULE_MODE_BRIDGE
|
||||
}, this);
|
||||
#else
|
||||
(void)active_bridge;
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef WITH_RS232_BRIDGE
|
||||
RS232Bridge* createRS232Bridge() {
|
||||
#ifdef WITH_RS232_BRIDGE_ALT
|
||||
@@ -1137,6 +1160,7 @@ public:
|
||||
active_rs232_bridge_uart = selected_uart;
|
||||
}
|
||||
#endif
|
||||
configureBridgeFilter(bridge);
|
||||
bridge->begin();
|
||||
if (!bridge->isRunning()) {
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
@@ -1239,6 +1263,7 @@ public:
|
||||
#ifdef WITH_RS232_BRIDGE
|
||||
const bool started = beginRS232Bridge();
|
||||
#else
|
||||
configureBridgeFilter(active_bridge);
|
||||
active_bridge->begin();
|
||||
const bool started = active_bridge->isRunning();
|
||||
#endif
|
||||
@@ -1297,6 +1322,7 @@ public:
|
||||
mqtt_bridge->setBuildDate(getBuildDate());
|
||||
mqtt_bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms);
|
||||
#endif
|
||||
configureBridgeFilter(active_bridge);
|
||||
active_bridge->begin();
|
||||
return active_bridge->isRunning();
|
||||
#endif
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <string.h>
|
||||
|
||||
#include <helpers/FloodFilterPolicy.h>
|
||||
#include <helpers/FloodRuleCLI.h>
|
||||
#include <helpers/FileRead.h>
|
||||
#include <helpers/RegionNameUtils.h>
|
||||
|
||||
@@ -127,7 +128,7 @@ static bool parsePayloadType(const char* text, uint8_t& type) {
|
||||
return true;
|
||||
}
|
||||
if (asciiStartsWith(text, "payload_type_")) text += strlen("payload_type_");
|
||||
if (asciiEqual(text, "any")) type = FloodRuleEngine::ANY_TYPE;
|
||||
if (asciiEqual(text, "any") || asciiEqual(text, "*")) type = FloodRuleEngine::ANY_TYPE;
|
||||
else if (asciiEqual(text, "req")) type = PAYLOAD_TYPE_REQ;
|
||||
else if (asciiEqual(text, "response") || asciiEqual(text, "resp")) type = PAYLOAD_TYPE_RESPONSE;
|
||||
else if (asciiEqual(text, "txt_msg") || asciiEqual(text, "txt")) type = PAYLOAD_TYPE_TXT_MSG;
|
||||
@@ -149,7 +150,7 @@ static bool parsePayloadType(const char* text, uint8_t& type) {
|
||||
static bool parseHopSpec(const char* text, uint8_t& min_hops,
|
||||
uint8_t& max_hops) {
|
||||
if (text == NULL || *text == 0) return false;
|
||||
if (asciiEqual(text, "all")) {
|
||||
if (asciiEqual(text, "all") || asciiEqual(text, "*")) {
|
||||
min_hops = 0;
|
||||
max_hops = FloodRuleEngine::MAX_HOPS;
|
||||
return true;
|
||||
@@ -503,19 +504,29 @@ void FloodRuleEngine::load() {
|
||||
loaded[i].retry_on_match = false;
|
||||
}
|
||||
|
||||
loaded[i].active = active != 0;
|
||||
if (version_7) {
|
||||
success = success && FloodFilterPolicy::decodeStoredRuleActive(
|
||||
active, loaded[i].active, loaded[i].transport_modes);
|
||||
} else {
|
||||
loaded[i].active = active != 0;
|
||||
success = success && active <= 1;
|
||||
}
|
||||
loaded[i].suspend_on_temp_radio = suspend_on_temp_radio != 0;
|
||||
loaded[i].match_blacklisted_path = match_blacklisted_path != 0;
|
||||
loaded[i].scope_uses_slow_timing = scope_uses_slow_timing != 0;
|
||||
loaded[i].drop_on_match = drop_on_match != 0;
|
||||
loaded[i].rate_limit_enabled = rate_limit_enabled != 0;
|
||||
loaded[i].stop_on_match = stop_on_match != 0;
|
||||
if (success && (active > 1 || suspend_on_temp_radio > 1
|
||||
if (success && (suspend_on_temp_radio > 1
|
||||
|| match_blacklisted_path > 1 || scope_requires_region_match > 1
|
||||
|| scope_uses_slow_timing > 1 || drop_on_match > 1
|
||||
|| rate_limit_enabled > 1 || stop_on_match > 1)) {
|
||||
success = false;
|
||||
}
|
||||
success = success && FloodFilterPolicy::transportActionsSupported(
|
||||
loaded[i].transport_modes, loaded[i].scope_name[0] != 0
|
||||
|| loaded[i].target_region_name[0] != 0,
|
||||
loaded[i].retry_on_match, loaded[i].scope_uses_slow_timing);
|
||||
if (!success) break;
|
||||
if (!loaded[i].active) {
|
||||
memset(&loaded[i], 0, sizeof(loaded[i]));
|
||||
@@ -717,7 +728,8 @@ bool FloodRuleEngine::save() {
|
||||
&& writeExact(&count, sizeof(count));
|
||||
for (int i = 0; success && i < RULE_SLOTS; i++) {
|
||||
const Entry& entry = _entries[i];
|
||||
uint8_t active = entry.active ? 1 : 0;
|
||||
uint8_t active = FloodFilterPolicy::encodeStoredRuleActive(
|
||||
entry.active, entry.transport_modes);
|
||||
uint8_t suspend_on_temp_radio = entry.suspend_on_temp_radio ? 1 : 0;
|
||||
uint8_t match_blacklisted_path = entry.match_blacklisted_path ? 1 : 0;
|
||||
uint8_t scope_requires_region_match =
|
||||
@@ -797,8 +809,9 @@ bool FloodRuleEngine::fieldsMatch(
|
||||
const Entry& entry, const mesh::Packet* packet,
|
||||
bool temp_radio_active, bool incoming_is_scoped,
|
||||
uint16_t incoming_transport_code, bool incoming_region_allowed,
|
||||
const RegionEntry* incoming_region) const {
|
||||
if (!entry.active || packet == NULL || !packet->isRouteFlood()) return false;
|
||||
const RegionEntry* incoming_region, uint8_t context) const {
|
||||
if (!entry.active || !FloodFilterPolicy::ruleModeMatches(
|
||||
entry.transport_modes, context, packet)) return false;
|
||||
if (!FloodFilterPolicy::channelKeyLengthSupported(
|
||||
entry.channel_key_len)) return false;
|
||||
if (entry.suspend_on_temp_radio && temp_radio_active) return false;
|
||||
@@ -920,12 +933,13 @@ uint32_t FloodRuleEngine::applyStop(uint32_t match_mask) {
|
||||
uint32_t FloodRuleEngine::evaluate(
|
||||
const mesh::Packet* packet, bool temp_radio_active,
|
||||
bool incoming_region_allowed,
|
||||
const RegionEntry* incoming_region) {
|
||||
const RegionEntry* incoming_region, uint8_t context) {
|
||||
static_assert(RULE_SLOTS <= 32,
|
||||
"flood rule match mask supports at most 32 slots");
|
||||
if (packet == NULL || !packet->isRouteFlood()) return 0;
|
||||
if (packet == NULL || (context == FloodFilterPolicy::RULE_MODE_RADIO && !packet->isRouteFlood())) return 0;
|
||||
bool incoming_is_scoped =
|
||||
packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD;
|
||||
packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD
|
||||
|| packet->getRouteType() == ROUTE_TYPE_TRANSPORT_DIRECT;
|
||||
uint16_t incoming_transport_code = incoming_is_scoped
|
||||
? packet->transport_codes[0] : 0;
|
||||
bool channel_auth_checked[RULE_SLOTS] = { false };
|
||||
@@ -935,7 +949,7 @@ uint32_t FloodRuleEngine::evaluate(
|
||||
const Entry& entry = _entries[i];
|
||||
if (!fieldsMatch(entry, packet, temp_radio_active, incoming_is_scoped,
|
||||
incoming_transport_code, incoming_region_allowed,
|
||||
incoming_region)) {
|
||||
incoming_region, context)) {
|
||||
continue;
|
||||
}
|
||||
bool authenticated = true;
|
||||
@@ -1025,7 +1039,7 @@ bool FloodRuleEngine::applyScope(mesh::Packet* packet, uint32_t match_mask,
|
||||
bool FloodRuleEngine::shouldBlock(const mesh::Packet* packet,
|
||||
uint32_t match_mask,
|
||||
uint32_t now_millis) const {
|
||||
if (packet == NULL || !packet->isRouteFlood()) return false;
|
||||
if (packet == NULL) return false;
|
||||
uint8_t type = packet->getPayloadType();
|
||||
uint8_t hops = packet->getPathHashCount();
|
||||
|
||||
@@ -1056,7 +1070,7 @@ bool FloodRuleEngine::shouldBlock(const mesh::Packet* packet,
|
||||
void FloodRuleEngine::commitRates(const mesh::Packet* packet,
|
||||
uint32_t match_mask,
|
||||
uint32_t now_millis) {
|
||||
if (packet == NULL || !packet->isRouteFlood()) return;
|
||||
if (packet == NULL) return;
|
||||
for (int i = 0; i < RULE_SLOTS; i++) {
|
||||
Entry& entry = _entries[i];
|
||||
if ((match_mask & ((uint32_t)1U << i)) == 0
|
||||
@@ -1074,12 +1088,16 @@ void FloodRuleEngine::commitRates(const mesh::Packet* packet,
|
||||
}
|
||||
|
||||
void FloodRuleEngine::formatDetail(int index, char* reply,
|
||||
size_t reply_len) const {
|
||||
size_t reply_len, bool compact) const {
|
||||
if (index < 0 || index >= RULE_SLOTS || !_entries[index].active) {
|
||||
snprintf(reply, reply_len, "Err - empty filter slot");
|
||||
return;
|
||||
}
|
||||
const Entry& entry = _entries[index];
|
||||
if (compact) {
|
||||
FloodRuleCLI::formatCompact(reply, reply_len, index + 1, entry);
|
||||
return;
|
||||
}
|
||||
char hops[12];
|
||||
char prefix[32];
|
||||
char incoming[48];
|
||||
@@ -1145,8 +1163,9 @@ void FloodRuleEngine::formatDetail(int index, char* reply,
|
||||
|
||||
int written = snprintf(
|
||||
reply, reply_len,
|
||||
"> %d type=%s hops=%s channel=%s prefix=%s in=%s %s%s%s priority=%u%s%s%s",
|
||||
index + 1, payloadTypeName(entry.payload_type), hops,
|
||||
"> %d type=%s mode=%s hops=%s channel=%s prefix=%s in=%s %s%s%s priority=%u%s%s%s",
|
||||
index + 1, payloadTypeName(entry.payload_type),
|
||||
FloodFilterPolicy::ruleModeName(entry.transport_modes), hops,
|
||||
entry.channel_key_len == 0 ? "*" : entry.channel_name,
|
||||
prefix, incoming, action, rate, retry,
|
||||
(unsigned int)entry.priority,
|
||||
@@ -1155,74 +1174,10 @@ void FloodRuleEngine::formatDetail(int index, char* reply,
|
||||
entry.suspend_on_temp_radio ? " suspend=tempradio" : "");
|
||||
if (written >= 0 && (size_t)written < reply_len) return;
|
||||
|
||||
char compact_type[4];
|
||||
if (entry.payload_type == ANY_TYPE) copyString(compact_type, "any", sizeof(compact_type));
|
||||
else snprintf(compact_type, sizeof(compact_type), "%u",
|
||||
(unsigned int)entry.payload_type);
|
||||
char compact_incoming[44];
|
||||
switch (entry.incoming_scope_kind) {
|
||||
case FloodFilterPolicy::RULE_IN_NONE:
|
||||
copyString(compact_incoming, "n", sizeof(compact_incoming));
|
||||
break;
|
||||
case FloodFilterPolicy::RULE_IN_SCOPED:
|
||||
copyString(compact_incoming, "s", sizeof(compact_incoming));
|
||||
break;
|
||||
case FloodFilterPolicy::RULE_IN_ALLOWED:
|
||||
copyString(compact_incoming, "a", sizeof(compact_incoming));
|
||||
break;
|
||||
case FloodFilterPolicy::RULE_IN_UNKNOWN:
|
||||
copyString(compact_incoming, "u", sizeof(compact_incoming));
|
||||
break;
|
||||
case FloodFilterPolicy::RULE_IN_SCOPE:
|
||||
snprintf(compact_incoming, sizeof(compact_incoming), "s:%s",
|
||||
entry.incoming_scope_name);
|
||||
break;
|
||||
case FloodFilterPolicy::RULE_IN_REGION:
|
||||
snprintf(compact_incoming, sizeof(compact_incoming), "r:%s",
|
||||
entry.incoming_scope_name);
|
||||
break;
|
||||
default:
|
||||
copyString(compact_incoming, "*", sizeof(compact_incoming));
|
||||
break;
|
||||
}
|
||||
char compact_action[44];
|
||||
if (entry.drop_on_match) {
|
||||
copyString(compact_action, " drop", sizeof(compact_action));
|
||||
} else if (entry.scope_name[0] != 0) {
|
||||
snprintf(compact_action, sizeof(compact_action), " scope=%s",
|
||||
entry.scope_name);
|
||||
} else if (entry.target_region_name[0] != 0) {
|
||||
snprintf(compact_action, sizeof(compact_action), " region=%s",
|
||||
entry.target_region_name);
|
||||
} else {
|
||||
compact_action[0] = 0;
|
||||
}
|
||||
char compact_rate[12];
|
||||
compact_rate[0] = 0;
|
||||
if (entry.rate_limit_enabled) {
|
||||
snprintf(compact_rate, sizeof(compact_rate), " q=%u",
|
||||
(unsigned int)entry.rate_per_minute);
|
||||
}
|
||||
char compact_flags[8];
|
||||
compact_flags[0] = 0;
|
||||
if (entry.scope_uses_slow_timing || entry.suspend_on_temp_radio
|
||||
|| entry.retry_on_match) {
|
||||
snprintf(compact_flags, sizeof(compact_flags), " f=%s%s%s",
|
||||
entry.scope_uses_slow_timing ? "s" : "",
|
||||
entry.suspend_on_temp_radio ? "t" : "",
|
||||
entry.retry_on_match ? "r" : "");
|
||||
}
|
||||
snprintf(reply, reply_len,
|
||||
">%d %s %s c=%s p=%s i=%s%s%s pri=%u%s%s",
|
||||
index + 1, compact_type, hops,
|
||||
entry.channel_key_len == 0 ? "*" : entry.channel_name,
|
||||
prefix, compact_incoming, compact_action, compact_rate,
|
||||
(unsigned int)entry.priority,
|
||||
entry.stop_on_match ? " stop" : "",
|
||||
compact_flags);
|
||||
FloodRuleCLI::formatCompact(reply, reply_len, index + 1, entry);
|
||||
}
|
||||
|
||||
void FloodRuleEngine::format(const char* args, char* reply) const {
|
||||
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) {
|
||||
@@ -1232,7 +1187,7 @@ void FloodRuleEngine::format(const char* args, char* reply) const {
|
||||
(unsigned int)RULE_SLOTS);
|
||||
return;
|
||||
}
|
||||
formatDetail((int)slot - 1, reply, 160);
|
||||
formatDetail((int)slot - 1, reply, 160, compact);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1263,8 +1218,9 @@ void FloodRuleEngine::format(const char* args, char* reply) 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",
|
||||
i + 1, payloadTypeName(entry.payload_type), hops,
|
||||
snprintf(item, sizeof(item), " %d=%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,
|
||||
priority, entry.stop_on_match ? "~stop" : "",
|
||||
entry.rate_limit_enabled ? "~rate" : "",
|
||||
@@ -1342,7 +1298,8 @@ void FloodRuleEngine::set(const char* args, char* reply,
|
||||
|
||||
uint8_t payload_type = 0;
|
||||
const char* type_text = asciiStartsWith(tokens[0], "type=")
|
||||
? tokens[0] + strlen("type=") : tokens[0];
|
||||
? tokens[0] + strlen("type=")
|
||||
: asciiStartsWith(tokens[0], "t=") ? tokens[0] + 2 : tokens[0];
|
||||
if (!parsePayloadType(type_text, payload_type)) {
|
||||
copyString(reply,
|
||||
"Err - packet type must be name, any, 0-15, or 0x00-0x0F",
|
||||
@@ -1385,9 +1342,23 @@ void FloodRuleEngine::set(const char* args, char* reply,
|
||||
bool priority_set = false;
|
||||
bool stop_on_match = false;
|
||||
bool retry_on_match = false;
|
||||
uint8_t transport_modes = FloodFilterPolicy::RULE_MODE_RADIO;
|
||||
bool mode_set = false;
|
||||
|
||||
for (int i = 1; i < token_count; i++) {
|
||||
if (asciiEqual(tokens[i], "suspend=tempradio")) {
|
||||
if (asciiStartsWith(tokens[i], "mode=")
|
||||
|| asciiStartsWith(tokens[i], "m=")) {
|
||||
if (mode_set) {
|
||||
strcpy(reply, DUPLICATE_OPTION);
|
||||
return;
|
||||
}
|
||||
const char* value = strchr(tokens[i], '=') + 1;
|
||||
if (!FloodFilterPolicy::parseRuleModes(value, transport_modes)) {
|
||||
strcpy(reply, "Err - mode is radio, bridge, cross, or bridge,cross");
|
||||
return;
|
||||
}
|
||||
mode_set = true;
|
||||
} else if (asciiEqual(tokens[i], "suspend=tempradio")) {
|
||||
if (suspend_on_temp_radio) {
|
||||
copyString(reply, DUPLICATE_OPTION, 160);
|
||||
return;
|
||||
@@ -1425,7 +1396,9 @@ void FloodRuleEngine::set(const char* args, char* reply,
|
||||
}
|
||||
flags++;
|
||||
}
|
||||
} else if (asciiEqual(tokens[i], "path=blacklist")) {
|
||||
} else if (asciiEqual(tokens[i], "path=blacklist")
|
||||
|| asciiEqual(tokens[i], "p=blacklist")
|
||||
|| asciiEqual(tokens[i], "p=bl")) {
|
||||
copyString(reply,
|
||||
"Err - path=blacklist is repeater-only; use prefix= on rooms",
|
||||
160);
|
||||
@@ -1504,6 +1477,7 @@ void FloodRuleEngine::set(const char* args, char* reply,
|
||||
priority = (uint8_t)parsed;
|
||||
priority_set = true;
|
||||
} else if (asciiEqual(tokens[i], "stop")
|
||||
|| asciiEqual(tokens[i], "s")
|
||||
|| asciiEqual(tokens[i], "action=stop")) {
|
||||
if (stop_on_match) {
|
||||
copyString(reply, DUPLICATE_OPTION, 160);
|
||||
@@ -1511,6 +1485,7 @@ void FloodRuleEngine::set(const char* args, char* reply,
|
||||
}
|
||||
stop_on_match = true;
|
||||
} else if (asciiEqual(tokens[i], "retry")
|
||||
|| asciiEqual(tokens[i], "r")
|
||||
|| asciiEqual(tokens[i], "retry=on")
|
||||
|| asciiEqual(tokens[i], "retry=allow")
|
||||
|| asciiEqual(tokens[i], "action=retry")) {
|
||||
@@ -1531,12 +1506,13 @@ void FloodRuleEngine::set(const char* args, char* reply,
|
||||
return;
|
||||
}
|
||||
scope_timing_set = true;
|
||||
} else if (asciiStartsWith(tokens[i], "scope=")) {
|
||||
} else if (asciiStartsWith(tokens[i], "scope=")
|
||||
|| asciiStartsWith(tokens[i], "s=")) {
|
||||
if (target_set || drop_set) {
|
||||
copyString(reply, DUPLICATE_OPTION, 160);
|
||||
return;
|
||||
}
|
||||
if (!normalizeScopeName(tokens[i] + strlen("scope="), scope_name,
|
||||
if (!normalizeScopeName(strchr(tokens[i], '=') + 1, scope_name,
|
||||
sizeof(scope_name))) {
|
||||
copyString(reply,
|
||||
"Err - scope must be a public name of at most 30 characters",
|
||||
@@ -1544,13 +1520,14 @@ void FloodRuleEngine::set(const char* args, char* reply,
|
||||
return;
|
||||
}
|
||||
target_set = true;
|
||||
} else if (asciiStartsWith(tokens[i], "region=")) {
|
||||
} else if (asciiStartsWith(tokens[i], "region=")
|
||||
|| asciiStartsWith(tokens[i], "r=")) {
|
||||
if (target_set || drop_set) {
|
||||
copyString(reply, DUPLICATE_OPTION, 160);
|
||||
return;
|
||||
}
|
||||
RegionEntry* region = _regions == NULL ? NULL
|
||||
: _regions->findByNamePrefix(tokens[i] + strlen("region="));
|
||||
: _regions->findByNamePrefix(strchr(tokens[i], '=') + 1);
|
||||
TransportKey target_scope;
|
||||
if (region == NULL || region->isWildcard()
|
||||
|| (region->flags & REGION_DENY_FLOOD) != 0
|
||||
@@ -1563,6 +1540,7 @@ void FloodRuleEngine::set(const char* args, char* reply,
|
||||
sizeof(target_region_name));
|
||||
target_set = true;
|
||||
} else if (asciiEqual(tokens[i], "drop")
|
||||
|| asciiEqual(tokens[i], "d")
|
||||
|| asciiEqual(tokens[i], "action=drop")) {
|
||||
if (drop_set || target_set) {
|
||||
copyString(reply, DUPLICATE_OPTION, 160);
|
||||
@@ -1605,7 +1583,14 @@ void FloodRuleEngine::set(const char* args, char* reply,
|
||||
channel_set = true;
|
||||
const char* value = tokens[i] + (asciiStartsWith(tokens[i], "c=")
|
||||
? 2 : strlen("channel="));
|
||||
if (!asciiEqual(value, "*")) {
|
||||
if (FloodRuleCLI::isChannelReference(value)) {
|
||||
if (!FloodRuleCLI::copyChannelReference(value, _entries, RULE_SLOTS,
|
||||
channel_key_len, channel_hash, channel_secret, channel_name,
|
||||
sizeof(channel_name))) {
|
||||
copyString(reply, "Err - unknown or ambiguous channel key reference; use the original key", 160);
|
||||
return;
|
||||
}
|
||||
} else if (!asciiEqual(value, "*")) {
|
||||
if (!parseChannel(value, channel_secret, channel_key_len,
|
||||
channel_hash, channel_name,
|
||||
sizeof(channel_name))) {
|
||||
@@ -1630,8 +1615,9 @@ void FloodRuleEngine::set(const char* args, char* reply,
|
||||
return;
|
||||
}
|
||||
path_set = true;
|
||||
} else if (asciiStartsWith(tokens[i], "hops=")) {
|
||||
if (hops_set || !parseHopSpec(tokens[i] + strlen("hops="),
|
||||
} else if (asciiStartsWith(tokens[i], "hops=")
|
||||
|| asciiStartsWith(tokens[i], "h=")) {
|
||||
if (hops_set || !parseHopSpec(strchr(tokens[i], '=') + 1,
|
||||
min_hops, max_hops)) {
|
||||
copyString(reply,
|
||||
"Err - hops must be all, N, N+, or N-M (0-63)", 160);
|
||||
@@ -1667,6 +1653,11 @@ void FloodRuleEngine::set(const char* args, char* reply,
|
||||
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");
|
||||
return;
|
||||
}
|
||||
bool action_set = drop_set || target_set || rate_limit_enabled
|
||||
|| stop_on_match || retry_on_match;
|
||||
if (require_explicit_action && !action_set) {
|
||||
@@ -1706,6 +1697,7 @@ void FloodRuleEngine::set(const char* args, char* reply,
|
||||
candidate.priority = priority;
|
||||
candidate.stop_on_match = stop_on_match;
|
||||
candidate.retry_on_match = retry_on_match;
|
||||
candidate.transport_modes = transport_modes;
|
||||
|
||||
int slot = requested_slot;
|
||||
if (slot < 0) {
|
||||
@@ -1740,6 +1732,10 @@ void FloodRuleEngine::set(const char* args, char* reply,
|
||||
}
|
||||
char detail[160];
|
||||
formatDetail(slot, detail, sizeof(detail));
|
||||
if (strncmp(detail, "Err - compact", 13) == 0) {
|
||||
snprintf(reply, 160, "OK - rule %d saved (details exceed reply size)", slot + 1);
|
||||
return;
|
||||
}
|
||||
const char* text = detail[0] == '>' ? skipSpaces(detail + 1) : detail;
|
||||
snprintf(reply, 160, "OK - %s", text);
|
||||
}
|
||||
@@ -1791,6 +1787,18 @@ bool FloodRuleEngine::handleCommand(const char* command, char* reply) {
|
||||
160);
|
||||
return true;
|
||||
}
|
||||
if (commandMatches(command, "get fr")) {
|
||||
format(command + strlen("get fr"), reply, true);
|
||||
return true;
|
||||
}
|
||||
if (commandMatches(command, "set fr")) {
|
||||
set(command + strlen("set fr"), reply, true);
|
||||
return true;
|
||||
}
|
||||
if (commandMatches(command, "del fr")) {
|
||||
remove(command + strlen("del fr"), reply);
|
||||
return true;
|
||||
}
|
||||
if (commandMatches(command, "get flood.rule")) {
|
||||
format(command + strlen("get flood.rule"), reply);
|
||||
return true;
|
||||
|
||||
@@ -26,7 +26,7 @@ public:
|
||||
|
||||
uint32_t evaluate(const mesh::Packet* packet, bool temp_radio_active,
|
||||
bool incoming_region_allowed,
|
||||
const RegionEntry* incoming_region);
|
||||
const RegionEntry* incoming_region, uint8_t context = 0);
|
||||
bool applyScope(mesh::Packet* packet, uint32_t match_mask,
|
||||
bool& scope_set, bool& fast_track,
|
||||
bool log_change = true);
|
||||
@@ -65,6 +65,7 @@ private:
|
||||
uint8_t priority;
|
||||
bool stop_on_match;
|
||||
bool retry_on_match;
|
||||
uint8_t transport_modes;
|
||||
uint32_t rate_window_started;
|
||||
uint16_t rate_window_count;
|
||||
bool rate_window_active;
|
||||
@@ -81,15 +82,15 @@ private:
|
||||
bool temp_radio_active, bool incoming_is_scoped,
|
||||
uint16_t incoming_transport_code,
|
||||
bool incoming_region_allowed,
|
||||
const RegionEntry* incoming_region) const;
|
||||
const RegionEntry* incoming_region, uint8_t context) const;
|
||||
bool authenticateChannel(const Entry& entry,
|
||||
const mesh::Packet* packet) const;
|
||||
int nextMatch(uint32_t match_mask, uint32_t visited_mask) const;
|
||||
bool resolveTargetRegion(const char* name, TransportKey& scope,
|
||||
const char*& canonical_name);
|
||||
uint32_t applyStop(uint32_t match_mask);
|
||||
void format(const char* args, char* reply) const;
|
||||
void formatDetail(int index, char* reply, size_t reply_len) const;
|
||||
void format(const char* args, char* reply, bool compact = false) const;
|
||||
void formatDetail(int index, char* reply, size_t reply_len, bool compact = false) const;
|
||||
void set(const char* args, char* reply,
|
||||
bool require_explicit_action = false);
|
||||
void remove(const char* args, char* reply);
|
||||
|
||||
@@ -142,6 +142,7 @@ void MyMesh::pushPostToClient(ClientInfo *client, PostInfo &post) {
|
||||
auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, client->shared_secret, reply_data, len);
|
||||
bool sent = false;
|
||||
if (reply) {
|
||||
reply->radio_reply = true; // asynchronous response to the room subscription
|
||||
if (client->out_path_len == OUT_PATH_UNKNOWN) {
|
||||
unsigned long delay_millis = 0;
|
||||
sent = sendFloodScoped(default_scope, reply, delay_millis,
|
||||
@@ -278,6 +279,29 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t
|
||||
return 0; // unknown command
|
||||
}
|
||||
|
||||
#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE
|
||||
bool MyMesh::allowTransportPacket(const mesh::Packet* packet, uint8_t context) {
|
||||
if (!packet) return false;
|
||||
RegionEntry* incoming_region = NULL;
|
||||
bool scoped = packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD
|
||||
|| packet->getRouteType() == ROUTE_TYPE_TRANSPORT_DIRECT;
|
||||
bool incoming_allowed;
|
||||
if (scoped) {
|
||||
incoming_region = region_map.findMatch(packet, REGION_DENY_FLOOD);
|
||||
incoming_allowed = incoming_region != NULL;
|
||||
} else {
|
||||
incoming_allowed = (region_map.getWildcard().flags & REGION_DENY_FLOOD) == 0;
|
||||
}
|
||||
// Use a local mask: bridge RX/TX callbacks can run inside mesh dispatch,
|
||||
// where recv_pkt_* still belongs to the ordinary radio forwarding phase.
|
||||
uint32_t matches = flood_rules.evaluate(packet, isAnyTempRadioActive(),
|
||||
incoming_allowed, incoming_region, context);
|
||||
if (flood_rules.shouldBlock(packet, matches, _ms->getMillis())) return false;
|
||||
flood_rules.commitRates(packet, matches, _ms->getMillis());
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
|
||||
#if MESH_PACKET_LOGGING
|
||||
if (mesh::isUsbLoggingEnabled()) {
|
||||
@@ -411,7 +435,7 @@ bool MyMesh::evaluateFloodRuleTiming(const mesh::Packet* packet,
|
||||
|
||||
mesh::Packet candidate = *packet;
|
||||
uint32_t match_mask = flood_rules.evaluate(
|
||||
packet, isTempRadioActive(), incoming_region_allowed, incoming_region);
|
||||
packet, isAnyTempRadioActive(), incoming_region_allowed, incoming_region);
|
||||
bool scope_set = false;
|
||||
return flood_rules.applyScope(&candidate, match_mask, scope_set,
|
||||
fast_track, false);
|
||||
@@ -607,7 +631,7 @@ mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) {
|
||||
(region_map.getWildcard().flags & REGION_DENY_FLOOD) == 0;
|
||||
}
|
||||
recv_pkt_rule_match_mask = flood_rules.evaluate(
|
||||
pkt, isTempRadioActive(), incoming_region_allowed, incoming_region);
|
||||
pkt, isAnyTempRadioActive(), incoming_region_allowed, incoming_region);
|
||||
if (flood_rules.hasRetryRules()) {
|
||||
pkt->flood_retry_policy = flood_rules.allowsRetry(
|
||||
recv_pkt_rule_match_mask)
|
||||
@@ -1410,6 +1434,7 @@ void MyMesh::begin(FILESYSTEM *fs) {
|
||||
// Set stats sources for automatic stats collection
|
||||
bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms);
|
||||
|
||||
configureBridgeFilter(bridge);
|
||||
bridge->begin();
|
||||
}
|
||||
}
|
||||
@@ -2161,6 +2186,7 @@ void MyMesh::onUserGpioTimerCompleted(uint8_t pin, uint8_t state,
|
||||
client->shared_secret, data,
|
||||
5 + (size_t)text_len);
|
||||
if (!packet) return;
|
||||
packet->radio_reply = true; // delayed GPIO command completion
|
||||
if (client->out_path_len == OUT_PATH_UNKNOWN) {
|
||||
sendFlood(packet, SERVER_RESPONSE_DELAY, path_hash_size);
|
||||
} else {
|
||||
|
||||
@@ -540,12 +540,30 @@ public:
|
||||
return mesh::wireless::WiFi;
|
||||
}
|
||||
void loop();
|
||||
#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE
|
||||
bool allowTransportPacket(const mesh::Packet* packet, uint8_t context);
|
||||
bool allowRadioProfileCross(const mesh::Packet* packet) override {
|
||||
return allowTransportPacket(packet, 4); // RULE_MODE_CROSS
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
#if defined(WITH_BRIDGE)
|
||||
bool isBridgeRunning() const override {
|
||||
return bridge != nullptr && bridge->isRunning();
|
||||
}
|
||||
|
||||
|
||||
void configureBridgeFilter(AbstractBridge* active_bridge) {
|
||||
#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE
|
||||
active_bridge->setPacketFilter([](void* context, const mesh::Packet* packet) {
|
||||
return static_cast<MyMesh*>(context)->allowTransportPacket(packet, 2); // RULE_MODE_BRIDGE
|
||||
}, this);
|
||||
#else
|
||||
(void)active_bridge;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool setBridgeState(bool enable) override {
|
||||
// An absent MQTT bridge is already stopped. Do not allocate one solely to
|
||||
// satisfy an idempotent disable request.
|
||||
@@ -585,6 +603,7 @@ public:
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
configureBridgeFilter(bridge);
|
||||
bridge->begin();
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
_alerter.setBridge(bridge);
|
||||
@@ -619,6 +638,7 @@ public:
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms);
|
||||
#endif
|
||||
configureBridgeFilter(bridge);
|
||||
bridge->begin();
|
||||
return bridge->isRunning();
|
||||
}
|
||||
|
||||
@@ -577,6 +577,7 @@ void SensorMesh::onUserGpioTimerCompleted(uint8_t pin, uint8_t state,
|
||||
client->shared_secret, data,
|
||||
5 + (size_t)text_len);
|
||||
if (!packet) return;
|
||||
packet->radio_reply = true; // delayed GPIO command completion
|
||||
if (client->out_path_len == OUT_PATH_UNKNOWN) {
|
||||
sendFlood(packet, CLI_REPLY_DELAY_MILLIS, path_hash_size);
|
||||
} else {
|
||||
|
||||
@@ -367,4 +367,27 @@ test("flags remote-login reach, future-type, spoofing, and policy-stop risks", (
|
||||
assert.ok(tool.ruleWarnings(sender).some((warning) => /spoofable/.test(warning)));
|
||||
});
|
||||
|
||||
test("factory has three rules with independent radio and transport decisions", () => {
|
||||
const rules = tool.EXAMPLES.factory;
|
||||
assert.strictEqual(rules.length, 3);
|
||||
for (const via of ["radio", "bridge", "cross"]) {
|
||||
for (const hops of [0, 4, 5, 63]) {
|
||||
const facts = packet({ channel: "#wardriving", via, hops });
|
||||
assert.strictEqual(tool.simulatePolicy(rules, facts).decision.drop,
|
||||
via !== "radio" || hops >= 5);
|
||||
assert.strictEqual(tool.simulatePolicy(rules,
|
||||
{ ...facts, channel: "#other", tempRadio: true }).decision.drop, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("combined transport selector survives readable and bundle round trips", () => {
|
||||
const rule = tool.EXAMPLES.factory[2];
|
||||
const definition = tool.buildDefinition(rule);
|
||||
assert.ok(definition.includes("via=bridge,cross"));
|
||||
assert.strictEqual(tool.parseDefinition(definition).via, "bridge,cross");
|
||||
assert.strictEqual(tool.decodeBundle(tool.encodeBundle([rule]))[0].via, "bridge,cross");
|
||||
assert.strictEqual(tool.normalizeRule({ ...rule, via: "cross,bridge" }).via, "bridge,cross");
|
||||
});
|
||||
|
||||
process.stdout.write(`# ${passed} filter-policy playground tests passed\n`);
|
||||
+58
-8
@@ -123,7 +123,8 @@ void Dispatcher::restoreOutboundTxOverrides() {
|
||||
bool Dispatcher::startOutboundTransmit() {
|
||||
if (outbound == NULL) return false;
|
||||
if (!isPacketRadioCurrent(outbound)
|
||||
|| _radio->prepareTransmitProfile(outbound->radio_profile) != RadioParamApplyResult::APPLIED) return false;
|
||||
|| _radio->prepareTransmitProfile(outbound->radio_profile,
|
||||
outbound->radio_reply && outbound->radio_reply_force) != RadioParamApplyResult::APPLIED) return false;
|
||||
|
||||
int len = 0;
|
||||
uint8_t raw[MAX_TRANS_UNIT];
|
||||
@@ -354,7 +355,8 @@ void Dispatcher::loop() {
|
||||
// keep the retry waiting forever on an unread RxDone interrupt.
|
||||
if (isDualRadioActive()) checkRecv();
|
||||
if (!millisHasNowPassed(outbound_radio_retry_at)) return;
|
||||
const auto prepared = _radio->prepareTransmitProfile(outbound->radio_profile);
|
||||
const auto prepared = _radio->prepareTransmitProfile(outbound->radio_profile,
|
||||
outbound->radio_reply && outbound->radio_reply_force);
|
||||
if (prepared == RadioParamApplyResult::BUSY) {
|
||||
outbound_radio_retry_at = futureMillis(10);
|
||||
return;
|
||||
@@ -544,6 +546,8 @@ bool Dispatcher::tryParsePacket(Packet* pkt, const uint8_t* raw, int len) {
|
||||
pkt->radio_profile = pkt->radio_origin = _radio->receiveProfile();
|
||||
pkt->radio_bound = false;
|
||||
pkt->radio_local = false;
|
||||
pkt->tx_radio = RADIO_TX_AUTO;
|
||||
pkt->radio_reply = pkt->radio_reply_force = false;
|
||||
pkt->radio_generation = pkt->radio_origin_generation = _radio->receiveProfileGeneration();
|
||||
pkt->header = raw[i++];
|
||||
if (pkt->getPayloadVer() > PAYLOAD_VER_1) {
|
||||
@@ -737,7 +741,8 @@ void Dispatcher::checkSend() {
|
||||
// Waiting for airtime credit must leave the receive scanner free to run.
|
||||
// Only retune once this queue entry is actually eligible to transmit.
|
||||
if (pending && _radio->profiles()) {
|
||||
const auto prepared = _radio->prepareTransmitProfile(pending->radio_profile);
|
||||
const auto prepared = _radio->prepareTransmitProfile(pending->radio_profile,
|
||||
pending->radio_reply && pending->radio_reply_force);
|
||||
if (prepared == RadioParamApplyResult::BUSY) return;
|
||||
if (prepared == RadioParamApplyResult::FAILED) {
|
||||
outbound = _mgr->getNextOutbound(now);
|
||||
@@ -822,6 +827,9 @@ Packet* Dispatcher::obtainNewPacket() {
|
||||
pkt->radio_generation = pkt->radio_origin_generation = receive_context_active ? receive_context_generation : 0;
|
||||
pkt->radio_bound = false;
|
||||
pkt->radio_local = !receive_context_active;
|
||||
pkt->tx_radio = RADIO_TX_AUTO;
|
||||
pkt->radio_reply = receive_context_active;
|
||||
pkt->radio_reply_force = false;
|
||||
}
|
||||
return pkt;
|
||||
}
|
||||
@@ -841,21 +849,37 @@ bool Dispatcher::queueOutboundPacket(Packet* packet, uint8_t priority, uint32_t
|
||||
}
|
||||
auto* profiles = _radio->profiles();
|
||||
if (packet->radio_profile > 1) return false;
|
||||
if (profiles == nullptr || packet->radio_bound) {
|
||||
if (profiles == nullptr) {
|
||||
if (packet->tx_radio != RADIO_TX_AUTO
|
||||
&& !(explicitRadioTxMask(packet->tx_radio, false) & 1)) return false;
|
||||
return _mgr->queueOutbound(packet, priority, futureMillis(delay_millis));
|
||||
}
|
||||
if (packet->radio_bound) {
|
||||
if (!isPacketRadioCurrent(packet)) return false;
|
||||
if (profiles && packet->radio_profile != packet->radio_origin
|
||||
&& !allowRadioProfileCross(packet)) return false;
|
||||
return _mgr->queueOutbound(packet, priority, futureMillis(delay_millis));
|
||||
}
|
||||
if (!packet->radio_local && packet->radio_generation
|
||||
&& packet->radio_generation != profiles->generation[packet->radio_profile]) return false;
|
||||
if (packet->radio_reply && packet->tx_radio == RADIO_TX_AUTO) {
|
||||
packet->tx_radio = profiles->reply_tx;
|
||||
packet->radio_reply_force = profiles->reply_force;
|
||||
}
|
||||
// Locally generated OTA traffic uses the temporary update profile. Replies
|
||||
// created while processing RX inherit that request's profile instead.
|
||||
// Keep that origin in RX-only mode too: isolation must drop a disallowed
|
||||
// transmission instead of silently sending it on the normal channel.
|
||||
if (packet->radio_local && packet->getPayloadType() == PAYLOAD_TYPE_OTA
|
||||
if (packet->tx_radio == RADIO_TX_AUTO && packet->radio_local && packet->getPayloadType() == PAYLOAD_TYPE_OTA
|
||||
&& profiles->secondary_temporary && !profiles->primary_temporary) {
|
||||
packet->radio_profile = 1;
|
||||
}
|
||||
const uint8_t origin = packet->radio_profile;
|
||||
const uint8_t mask = profiles->transmitMask(origin);
|
||||
uint8_t mask = getTransmitProfileMask(packet);
|
||||
const uint8_t cross_mask = (uint8_t)(1U << (origin ^ 1));
|
||||
if ((mask & cross_mask) && !allowRadioProfileCross(packet)) {
|
||||
mask &= (uint8_t)~cross_mask;
|
||||
}
|
||||
if (!mask) return false;
|
||||
packet->radio_origin = origin;
|
||||
packet->radio_origin_generation = profiles->generation[origin];
|
||||
@@ -886,15 +910,41 @@ bool Dispatcher::queueOutboundPacket(Packet* packet, uint8_t priority, uint32_t
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t Dispatcher::getTransmitProfileMask(const Packet* packet) const {
|
||||
const auto* profiles = _radio->profiles();
|
||||
if (!profiles) return packet->tx_radio == RADIO_TX_AUTO ? 1 : explicitRadioTxMask(packet->tx_radio, false);
|
||||
if (packet->radio_profile > 1) return 0;
|
||||
const bool reply = packet->radio_reply && !packet->radio_bound && packet->tx_radio == RADIO_TX_AUTO;
|
||||
const uint8_t policy = reply ? profiles->reply_tx : packet->tx_radio;
|
||||
const bool force = packet->radio_reply && (reply ? profiles->reply_force : packet->radio_reply_force);
|
||||
uint8_t origin = packet->radio_profile;
|
||||
if (policy == RADIO_TX_AUTO && packet->radio_local && packet->getPayloadType() == PAYLOAD_TYPE_OTA
|
||||
&& profiles->secondary_temporary && !profiles->primary_temporary) origin = 1;
|
||||
return policy == RADIO_TX_AUTO ? profiles->transmitMask(origin)
|
||||
: explicitRadioTxMask(policy, profiles->canTransmit(1, force));
|
||||
}
|
||||
|
||||
uint32_t Dispatcher::getTransmitAirtime(const Packet* packet) const {
|
||||
const uint8_t mask = getTransmitProfileMask(packet);
|
||||
uint32_t total = 0;
|
||||
for (uint8_t profile = 0; profile < 2; ++profile) {
|
||||
if (mask & (1U << profile))
|
||||
total += _radio->getProfileAirtime(profile, packet->getRawLength(), packet->tx_cr);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
bool Dispatcher::isPacketRadioCurrent(const Packet* packet) const {
|
||||
const auto* p = _radio->profiles();
|
||||
if (!p || !packet->radio_bound) return true;
|
||||
const uint8_t target = packet->radio_profile;
|
||||
const uint8_t origin = packet->radio_origin;
|
||||
return target < 2 && origin < 2 && p->canTransmit(target)
|
||||
const bool force = packet->radio_reply && packet->radio_reply_force;
|
||||
return target < 2 && origin < 2 && p->canTransmit(target, force)
|
||||
&& packet->radio_generation == p->generation[target]
|
||||
&& packet->radio_origin_generation == p->generation[origin]
|
||||
&& (origin == target || p->canCross());
|
||||
&& (packet->tx_radio == RADIO_TX_AUTO ? (origin == target || p->canCross())
|
||||
: (explicitRadioTxMask(packet->tx_radio, p->canTransmit(1, force)) & (1U << target)) != 0);
|
||||
}
|
||||
|
||||
bool Dispatcher::sendPacket(Packet* packet, uint8_t priority, uint32_t delay_millis) {
|
||||
|
||||
@@ -52,6 +52,10 @@ public:
|
||||
virtual RadioParamApplyResult prepareTransmitProfile(uint8_t profile) {
|
||||
return profile == 0 ? RadioParamApplyResult::APPLIED : RadioParamApplyResult::FAILED;
|
||||
}
|
||||
virtual RadioParamApplyResult prepareTransmitProfile(uint8_t profile, bool reply_rx_override) {
|
||||
(void)reply_rx_override;
|
||||
return prepareTransmitProfile(profile);
|
||||
}
|
||||
virtual uint16_t profilePreamble(uint8_t) const { return 0; }
|
||||
virtual uint32_t getProfileAirtime(uint8_t, int len_bytes, uint8_t = 0) {
|
||||
return getEstAirtimeFor(len_bytes);
|
||||
@@ -379,6 +383,9 @@ protected:
|
||||
virtual void onSendComplete(Packet* packet) { }
|
||||
virtual void onSendFail(Packet* packet) { }
|
||||
virtual void onRadioProfileCopyQueued(Packet* packet, const Packet* original, uint8_t priority) { }
|
||||
// Policy gate for the other profile only; same-profile TX remains eligible.
|
||||
// Called before admitting copies (including bound retry packets) to a queue.
|
||||
virtual bool allowRadioProfileCross(const Packet* packet) { return true; }
|
||||
virtual const char* getLogDateTime() { return ""; }
|
||||
|
||||
virtual float getAirtimeBudgetFactor() const;
|
||||
@@ -421,6 +428,9 @@ protected:
|
||||
void setRadioAvailable(bool available);
|
||||
bool isRadioAvailable() const { return radio_available; }
|
||||
bool isPacketRadioCurrent(const Packet* packet) const;
|
||||
// Planned fan-out before transport filters; also bounds OTA queue admission.
|
||||
uint8_t getTransmitProfileMask(const Packet* packet) const;
|
||||
uint32_t getTransmitAirtime(const Packet* packet) const;
|
||||
|
||||
public:
|
||||
void begin();
|
||||
|
||||
+26
-6
@@ -192,13 +192,26 @@ static int queuedPacedOtaResponses(PacketManager* manager) {
|
||||
bool Mesh::otaSendAdapter(void* ctx, const uint8_t* msg, uint16_t len, bool /*flood*/) {
|
||||
Mesh* m = (Mesh*)ctx;
|
||||
if (!m->isAnyTempRadioActive()) return false;
|
||||
if (isPacedOtaResponse(msg, len)
|
||||
&& (queuedPacedOtaResponses(m->_mgr) >= OTA_EGRESS_QUEUE_CREDIT
|
||||
|| m->_mgr->getFreeCount() <= OTA_EGRESS_MIN_FREE)) {
|
||||
const bool paced = isPacedOtaResponse(msg, len);
|
||||
const int queued = paced ? queuedPacedOtaResponses(m->_mgr) : 0;
|
||||
// Routine OTA backpressure must not attempt an allocation, which would
|
||||
// otherwise report a packet-pool error when the receive reserve is in use.
|
||||
if (paced && (queued >= OTA_EGRESS_QUEUE_CREDIT
|
||||
|| m->_mgr->getFreeCount() <= OTA_EGRESS_MIN_FREE)) return false;
|
||||
Packet* p = m->createOtaPacket(msg, len);
|
||||
if (!p) return false;
|
||||
p->radio_reply = len && ota::ota_is_response_message(msg[0]);
|
||||
const uint8_t mask = m->getTransmitProfileMask(p);
|
||||
const int copies = (mask & 1 ? 1 : 0) + (mask & 2 ? 1 : 0);
|
||||
// p already occupies one slot. Reserve the other copy as well, keeping the
|
||||
// receive reserve and OTA credit intact when replies use both profiles.
|
||||
if (!copies || (paced
|
||||
&& (queued + copies > OTA_EGRESS_QUEUE_CREDIT
|
||||
|| m->_mgr->getFreeCount() < OTA_EGRESS_MIN_FREE + copies - 1))) {
|
||||
m->releasePacket(p);
|
||||
return false;
|
||||
}
|
||||
Packet* p = m->createOtaPacket(msg, len);
|
||||
return p && m->sendOtaFlood(p);
|
||||
return m->sendOtaFlood(p);
|
||||
}
|
||||
|
||||
// Runtime OTA flood reach (`ota config hops`, persisted in NodePrefs): accept packets up to N hops away and
|
||||
@@ -1315,6 +1328,7 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) {
|
||||
delay_millis += getDirectRetransmitDelay(packet) + 300;
|
||||
auto a1 = createMultiAck(packet->payload, packet->payload_len, extra);
|
||||
if (a1) {
|
||||
a1->radio_reply = false; // relayed ACK, not a local response
|
||||
a1->path_len = Packet::copyPath(a1->path, packet->path, packet->path_len);
|
||||
a1->header &= ~PH_ROUTE_MASK;
|
||||
a1->header |= ROUTE_TYPE_DIRECT;
|
||||
@@ -1326,6 +1340,7 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) {
|
||||
|
||||
auto a2 = createAck(packet->payload, packet->payload_len);
|
||||
if (a2) {
|
||||
a2->radio_reply = false; // relayed ACK, not a local response
|
||||
a2->path_len = Packet::copyPath(a2->path, packet->path, packet->path_len);
|
||||
a2->header &= ~PH_ROUTE_MASK;
|
||||
a2->header |= ROUTE_TYPE_DIRECT;
|
||||
@@ -2640,7 +2655,9 @@ Packet* Mesh::createAdvert(const LocalIdentity& id, const uint8_t* app_data, siz
|
||||
Packet* Mesh::createPathReturn(const Identity& dest, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len) {
|
||||
uint8_t dest_hash[PATH_HASH_SIZE];
|
||||
dest.copyHashTo(dest_hash);
|
||||
return createPathReturn(dest_hash, secret, path, path_len, extra_type, extra, extra_len);
|
||||
Packet* packet = createPathReturn(dest_hash, secret, path, path_len, extra_type, extra, extra_len);
|
||||
if (packet) packet->tx_radio = getContactTxRadio(dest);
|
||||
return packet;
|
||||
}
|
||||
|
||||
Packet* Mesh::createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len) {
|
||||
@@ -2696,6 +2713,7 @@ Packet* Mesh::createDatagram(uint8_t type, const Identity& dest, const uint8_t*
|
||||
return NULL;
|
||||
}
|
||||
packet->header = (type << PH_TYPE_SHIFT); // ROUTE_TYPE_* set later
|
||||
packet->tx_radio = getContactTxRadio(dest);
|
||||
|
||||
int len = 0;
|
||||
len += dest.copyHashTo(&packet->payload[len]); // dest hash
|
||||
@@ -2720,6 +2738,7 @@ Packet* Mesh::createAnonDatagram(uint8_t type, const LocalIdentity& sender, cons
|
||||
return NULL;
|
||||
}
|
||||
packet->header = (type << PH_TYPE_SHIFT); // ROUTE_TYPE_* set later
|
||||
packet->tx_radio = getContactTxRadio(dest);
|
||||
|
||||
int len = 0;
|
||||
if (type == PAYLOAD_TYPE_ANON_REQ) {
|
||||
@@ -2745,6 +2764,7 @@ Packet* Mesh::createGroupDatagram(uint8_t type, const GroupChannel& channel, con
|
||||
return NULL;
|
||||
}
|
||||
packet->header = (type << PH_TYPE_SHIFT); // ROUTE_TYPE_* set later
|
||||
packet->tx_radio = channel.tx_radio;
|
||||
|
||||
int len = 0;
|
||||
memcpy(&packet->payload[len], channel.hash, PATH_HASH_SIZE); len += PATH_HASH_SIZE;
|
||||
|
||||
@@ -60,6 +60,7 @@ class GroupChannel {
|
||||
public:
|
||||
uint8_t hash[PATH_HASH_SIZE];
|
||||
uint8_t secret[PUB_KEY_SIZE];
|
||||
uint8_t tx_radio = RADIO_TX_AUTO; // local setting, not part of the channel key
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -396,6 +397,9 @@ protected:
|
||||
* \brief Called exactly once whenever an active flood-retry slot is released.
|
||||
*/
|
||||
virtual void onFloodRetrySlotReleased(const uint8_t* retry_key, uint8_t radio_profile) { }
|
||||
// Resolve local TX routing while the complete peer identity is available.
|
||||
// Packet destination hashes are too short to identify a contact safely.
|
||||
virtual uint8_t getContactTxRadio(const Identity& peer) const { return RADIO_TX_AUTO; }
|
||||
|
||||
/**
|
||||
* \returns number of extra (Direct) ACK transmissions wanted.
|
||||
|
||||
@@ -15,6 +15,8 @@ Packet::Packet() {
|
||||
radio_generation = radio_origin_generation = 0;
|
||||
radio_profile = radio_origin = 0;
|
||||
radio_bound = false; radio_local = true;
|
||||
tx_radio = RADIO_TX_AUTO;
|
||||
radio_reply = radio_reply_force = false;
|
||||
}
|
||||
|
||||
bool Packet::isValidPathLen(uint8_t path_len) {
|
||||
@@ -81,6 +83,8 @@ bool Packet::readFrom(const uint8_t src[], uint8_t len) {
|
||||
radio_profile = radio_origin = 0;
|
||||
radio_bound = false; radio_local = true;
|
||||
header = src[i++];
|
||||
tx_radio = RADIO_TX_AUTO;
|
||||
radio_reply = radio_reply_force = false;
|
||||
if (getPayloadVer() > PAYLOAD_VER_1) return false;
|
||||
|
||||
if (hasTransportCodes()) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <MeshCore.h>
|
||||
#include <RadioTxPolicy.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
@@ -65,6 +66,9 @@ public:
|
||||
uint8_t radio_origin = 0;
|
||||
bool radio_bound = false; // retries retain one profile and never fan out again
|
||||
bool radio_local = true;
|
||||
uint8_t tx_radio = RADIO_TX_AUTO; // local policy, never sent over the air
|
||||
bool radio_reply = false; // locally generated response, never a forwarded packet
|
||||
bool radio_reply_force = false; // captured reply-only RX-mode override
|
||||
|
||||
/**
|
||||
* \brief calculate the hash of payload + type
|
||||
|
||||
+8
-2
@@ -3,6 +3,7 @@
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <math.h>
|
||||
#include "RadioTxPolicy.h"
|
||||
|
||||
namespace mesh {
|
||||
|
||||
@@ -62,6 +63,10 @@ class RadioProfiles {
|
||||
bool primary_temporary = false;
|
||||
bool secondary_temporary = false;
|
||||
RadioCrossMode cross = RadioCrossMode::Auto;
|
||||
// Infrastructure opts into BOTH at startup; Companions keep their own
|
||||
// contact/channel policy. Force applies only to locally generated replies.
|
||||
uint8_t reply_tx = RADIO_TX_AUTO;
|
||||
bool reply_force = false;
|
||||
uint32_t generation[2] = {1, 1};
|
||||
uint32_t switches = 0;
|
||||
uint32_t rx_packets[2] = {};
|
||||
@@ -70,8 +75,9 @@ class RadioProfiles {
|
||||
uint32_t longest_switch_us = 0;
|
||||
|
||||
bool enabled() const { return secondary.mode != RadioProfileMode::Off; }
|
||||
bool canTransmit(uint8_t profile) const {
|
||||
return profile == 0 || (profile == 1 && secondary.mode == RadioProfileMode::RxTx);
|
||||
bool canTransmit(uint8_t profile, bool reply_rx_override = false) const {
|
||||
return profile == 0 || (profile == 1 && (secondary.mode == RadioProfileMode::RxTx
|
||||
|| (reply_rx_override && secondary.mode == RadioProfileMode::Rx)));
|
||||
}
|
||||
bool canCross() const {
|
||||
return cross == RadioCrossMode::On || (cross == RadioCrossMode::Auto
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
// Local outbound choice. AUTO follows the radio2.cross policy; explicit masks
|
||||
// choose profiles independently of cross. Mode permissions are enforced
|
||||
// separately, including the infrastructure reply-only RX override.
|
||||
enum RadioTxPolicy : uint8_t {
|
||||
RADIO_TX_AUTO = 0,
|
||||
RADIO_TX_PRIMARY = 1,
|
||||
RADIO_TX_SECONDARY = 2,
|
||||
RADIO_TX_BOTH = 3,
|
||||
RADIO_TX_OFF = 4,
|
||||
};
|
||||
|
||||
inline const char* radioTxPolicyName(uint8_t policy) {
|
||||
switch (policy) {
|
||||
case RADIO_TX_PRIMARY: return "radio";
|
||||
case RADIO_TX_SECONDARY: return "radio2";
|
||||
case RADIO_TX_BOTH: return "both";
|
||||
case RADIO_TX_OFF: return "off";
|
||||
default: return "auto";
|
||||
}
|
||||
}
|
||||
|
||||
inline bool parseRadioTxPolicy(const char* text, uint8_t& policy) {
|
||||
for (uint8_t value = RADIO_TX_AUTO; value <= RADIO_TX_OFF; ++value) {
|
||||
if (!strcmp(text, radioTxPolicyName(value))) { policy = value; return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use a tagged value in previously reserved storage bytes. Zero and historical
|
||||
// unrecognised bytes retain the old automatic behavior; record sizes stay fixed.
|
||||
inline uint8_t encodeRadioTxPolicy(uint8_t policy) {
|
||||
return policy >= RADIO_TX_PRIMARY && policy <= RADIO_TX_OFF ? 0xa0 | policy : 0;
|
||||
}
|
||||
inline uint8_t decodeRadioTxPolicy(uint8_t stored) {
|
||||
return stored >= 0xa1 && stored <= 0xa4 ? stored & 7 : RADIO_TX_AUTO;
|
||||
}
|
||||
|
||||
inline uint8_t explicitRadioTxMask(uint8_t policy, bool secondary_tx) {
|
||||
return policy >= RADIO_TX_PRIMARY && policy <= RADIO_TX_BOTH
|
||||
? policy & (secondary_tx ? 3 : 1) : 0;
|
||||
}
|
||||
|
||||
} // namespace mesh
|
||||
@@ -3,9 +3,26 @@
|
||||
#include <Mesh.h>
|
||||
|
||||
class AbstractBridge {
|
||||
using PacketFilter = bool (*)(void*, const mesh::Packet*);
|
||||
PacketFilter _packet_filter = nullptr;
|
||||
void* _packet_filter_context = nullptr;
|
||||
|
||||
protected:
|
||||
// Called on the mesh loop, before copying/marking/queuing a packet. Never
|
||||
// invoke the policy from a WiFi callback or the MQTT publishing task.
|
||||
bool allowsPacket(const mesh::Packet* packet) const {
|
||||
return packet && (!_packet_filter
|
||||
|| _packet_filter(_packet_filter_context, packet));
|
||||
}
|
||||
|
||||
public:
|
||||
virtual ~AbstractBridge() {}
|
||||
|
||||
void setPacketFilter(PacketFilter filter, void* context) {
|
||||
_packet_filter = filter;
|
||||
_packet_filter_context = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Initializes the bridge.
|
||||
*/
|
||||
|
||||
@@ -84,20 +84,36 @@ mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name, double lat, doubl
|
||||
return createAdvert(self_id, app_data, app_data_len);
|
||||
}
|
||||
|
||||
uint8_t BaseChatMesh::getContactTxRadio(const mesh::Identity& peer) const {
|
||||
for (int i = MAX_ANON_CONTACTS; i < num_contacts; ++i) {
|
||||
if (peer.matches(contacts[i].id)) return contacts[i].tx_radio;
|
||||
}
|
||||
return mesh::RADIO_TX_AUTO;
|
||||
}
|
||||
|
||||
void BaseChatMesh::sendAckTo(const ContactInfo& dest, const uint8_t* ack_hash, uint8_t ack_len) {
|
||||
if (dest.out_path_len == OUT_PATH_UNKNOWN) {
|
||||
mesh::Packet* ack = createAck(ack_hash, ack_len);
|
||||
if (ack) sendFloodScoped(dest, ack, TXT_ACK_DELAY);
|
||||
if (ack) {
|
||||
ack->tx_radio = getContactTxRadio(dest.id);
|
||||
sendFloodScoped(dest, ack, TXT_ACK_DELAY);
|
||||
}
|
||||
} else {
|
||||
uint32_t d = TXT_ACK_DELAY;
|
||||
if (getExtraAckTransmitCount() > 0) {
|
||||
mesh::Packet* a1 = createMultiAck(ack_hash, ack_len, 1);
|
||||
if (a1) sendDirect(a1, dest.getPath(), dest.out_path_len, d);
|
||||
if (a1) {
|
||||
a1->tx_radio = getContactTxRadio(dest.id);
|
||||
sendDirect(a1, dest.getPath(), dest.out_path_len, d);
|
||||
}
|
||||
d += 300;
|
||||
}
|
||||
|
||||
mesh::Packet* a2 = createAck(ack_hash, ack_len);
|
||||
if (a2) sendDirect(a2, dest.getPath(), dest.out_path_len, d);
|
||||
if (a2) {
|
||||
a2->tx_radio = getContactTxRadio(dest.id);
|
||||
sendDirect(a2, dest.getPath(), dest.out_path_len, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,7 +583,7 @@ int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp,
|
||||
est_timeout = 0;
|
||||
return MSG_SEND_FAILED;
|
||||
}
|
||||
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
|
||||
uint32_t t = getTransmitAirtime(pkt);
|
||||
|
||||
int rc;
|
||||
bool sent;
|
||||
@@ -628,7 +644,7 @@ int BaseChatMesh::sendCommandData(const ContactInfo& recipient,
|
||||
return MSG_SEND_FAILED;
|
||||
}
|
||||
|
||||
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
|
||||
uint32_t t = getTransmitAirtime(pkt);
|
||||
const uint32_t base_timeout = recipient.out_path_len == OUT_PATH_UNKNOWN
|
||||
? calcFloodTimeoutMillisFor(t)
|
||||
: calcDirectTimeoutMillisFor(t, recipient.out_path_len);
|
||||
@@ -669,8 +685,7 @@ bool BaseChatMesh::sendGroupMessage(uint32_t timestamp, mesh::GroupChannel& chan
|
||||
|
||||
auto pkt = createGroupDatagram(PAYLOAD_TYPE_GRP_TXT, channel, temp, 5 + prefix_len + text_len);
|
||||
if (pkt) {
|
||||
sendFloodScoped(channel, pkt);
|
||||
return true;
|
||||
return sendFloodScoped(channel, pkt);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -698,12 +713,10 @@ bool BaseChatMesh::sendGroupData(mesh::GroupChannel& channel, uint8_t* path, uin
|
||||
}
|
||||
|
||||
if (path_len == OUT_PATH_UNKNOWN) {
|
||||
sendFloodScoped(channel, pkt);
|
||||
return sendFloodScoped(channel, pkt);
|
||||
} else {
|
||||
sendDirect(pkt, path, path_len);
|
||||
return sendDirect(pkt, path, path_len);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BaseChatMesh::shareContactZeroHop(const ContactInfo& contact) {
|
||||
@@ -763,9 +776,9 @@ int BaseChatMesh::sendLogin(const ContactInfo& recipient, const char* password,
|
||||
pkt = createAnonDatagram(PAYLOAD_TYPE_ANON_REQ, self_id, recipient.id, recipient.getSharedSecret(self_id), temp, tlen);
|
||||
}
|
||||
if (pkt) {
|
||||
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
|
||||
uint32_t t = getTransmitAirtime(pkt);
|
||||
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
|
||||
sendFloodScoped(recipient, pkt);
|
||||
if (!sendFloodScoped(recipient, pkt)) return MSG_SEND_FAILED;
|
||||
est_timeout = calcFloodTimeoutMillisFor(t);
|
||||
return MSG_SEND_SENT_FLOOD;
|
||||
} else {
|
||||
@@ -788,9 +801,9 @@ int BaseChatMesh::sendAnonReq(const ContactInfo& recipient, const uint8_t* data,
|
||||
pkt = createAnonDatagram(PAYLOAD_TYPE_ANON_REQ, self_id, recipient.id, recipient.getSharedSecret(self_id), temp, 4 + len);
|
||||
}
|
||||
if (pkt) {
|
||||
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
|
||||
uint32_t t = getTransmitAirtime(pkt);
|
||||
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
|
||||
sendFloodScoped(recipient, pkt);
|
||||
if (!sendFloodScoped(recipient, pkt)) return MSG_SEND_FAILED;
|
||||
est_timeout = calcFloodTimeoutMillisFor(t);
|
||||
return MSG_SEND_SENT_FLOOD;
|
||||
} else {
|
||||
@@ -815,9 +828,9 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, const uint8_t* req_
|
||||
pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, 4 + data_len);
|
||||
}
|
||||
if (pkt) {
|
||||
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
|
||||
uint32_t t = getTransmitAirtime(pkt);
|
||||
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
|
||||
sendFloodScoped(recipient, pkt);
|
||||
if (!sendFloodScoped(recipient, pkt)) return MSG_SEND_FAILED;
|
||||
est_timeout = calcFloodTimeoutMillisFor(t);
|
||||
return MSG_SEND_SENT_FLOOD;
|
||||
} else {
|
||||
@@ -842,9 +855,9 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, uint8_t req_type, u
|
||||
pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, sizeof(temp));
|
||||
}
|
||||
if (pkt) {
|
||||
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
|
||||
uint32_t t = getTransmitAirtime(pkt);
|
||||
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
|
||||
sendFloodScoped(recipient, pkt);
|
||||
if (!sendFloodScoped(recipient, pkt)) return MSG_SEND_FAILED;
|
||||
est_timeout = calcFloodTimeoutMillisFor(t);
|
||||
return MSG_SEND_SENT_FLOOD;
|
||||
} else {
|
||||
|
||||
@@ -173,6 +173,7 @@ protected:
|
||||
virtual bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], int len) { return false; }
|
||||
|
||||
// Mesh overrides
|
||||
uint8_t getContactTxRadio(const mesh::Identity& peer) const override;
|
||||
void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override;
|
||||
int searchPeersByHash(const uint8_t* hash) override;
|
||||
void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override;
|
||||
|
||||
@@ -733,7 +733,7 @@ static void formatSnrDbX4Short(char* dest, size_t dest_len, int16_t snr_x4) {
|
||||
}
|
||||
|
||||
void CommonCLI::loadPrefs(FILESYSTEM* fs) {
|
||||
_radio_profiles.begin(fs, _callbacks->getProfileRadio(), _rtc);
|
||||
_radio_profiles.begin(fs, _callbacks->getProfileRadio(), _rtc, true);
|
||||
const bool display_settings_loaded = mesh::ui::loadDisplayPowerSettings(fs, false);
|
||||
(void)display_settings_loaded;
|
||||
bool is_fresh_install = false;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#pragma once
|
||||
|
||||
#include <RadioTxPolicy.h>
|
||||
#include <stddef.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
namespace mesh { namespace companion {
|
||||
|
||||
struct TxRouteCommand {
|
||||
bool matched = false;
|
||||
bool valid = false;
|
||||
bool channel = false;
|
||||
bool set = false;
|
||||
bool quoted = false;
|
||||
char target[70] = {};
|
||||
uint8_t policy = RADIO_TX_AUTO;
|
||||
};
|
||||
|
||||
inline bool txRouteSpace(char c) { return c == ' ' || c == '\t'; }
|
||||
|
||||
inline TxRouteCommand parseTxRouteCommand(const char* text) {
|
||||
TxRouteCommand result;
|
||||
if (!text) return result;
|
||||
if (!strncmp(text, "get ", 4)) result.set = false;
|
||||
else if (!strncmp(text, "set ", 4)) result.set = true;
|
||||
else return result;
|
||||
text += 4;
|
||||
const char* family = nullptr;
|
||||
if (!strncmp(text, "tx.user", 7)) family = "tx.user";
|
||||
else if (!strncmp(text, "tx.channel", 10)) { family = "tx.channel"; result.channel = true; }
|
||||
else return result;
|
||||
text += strlen(family);
|
||||
if (*text && !txRouteSpace(*text)) return result;
|
||||
result.matched = true;
|
||||
while (txRouteSpace(*text)) ++text;
|
||||
const char* end = text + strlen(text);
|
||||
while (end > text && txRouteSpace(end[-1])) --end;
|
||||
if (result.set) {
|
||||
const char* mode = end;
|
||||
while (mode > text && !txRouteSpace(mode[-1])) --mode;
|
||||
char value[8];
|
||||
const size_t length = end - mode;
|
||||
if (mode == text || length >= sizeof(value)) return result;
|
||||
memcpy(value, mode, length); value[length] = 0;
|
||||
if (!parseRadioTxPolicy(value, result.policy)) return result;
|
||||
end = mode;
|
||||
while (end > text && txRouteSpace(end[-1])) --end;
|
||||
}
|
||||
if (end > text && *text == '"') {
|
||||
if (end - text < 2 || end[-1] != '"') return result;
|
||||
result.quoted = true;
|
||||
++text; --end;
|
||||
}
|
||||
const size_t length = end - text;
|
||||
if (length >= sizeof(result.target) || ((result.set || result.quoted) && !length)) return result;
|
||||
if (memchr(text, '"', length) || memchr(text, '\r', length) || memchr(text, '\n', length)) return result;
|
||||
memcpy(result.target, text, length);
|
||||
result.target[length] = 0;
|
||||
result.valid = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Explicit key: avoids confusing a contact named like hexadecimal with a key.
|
||||
inline bool parseTxRouteKey(const char* text, uint8_t key[32], size_t& bytes) {
|
||||
const size_t length = strlen(text);
|
||||
if (length < 12 || length > 64 || (length & 1)) return false;
|
||||
bytes = length / 2;
|
||||
for (size_t i = 0; i < bytes; ++i) {
|
||||
uint8_t value = 0;
|
||||
for (unsigned n = 0; n < 2; ++n) {
|
||||
char c = text[2 * i + n];
|
||||
if (c >= 'A' && c <= 'F') c += 'a' - 'A';
|
||||
if (c >= '0' && c <= '9') value = (value << 4) | (c - '0');
|
||||
else if (c >= 'a' && c <= 'f') value = (value << 4) | (c - 'a' + 10);
|
||||
else return false;
|
||||
}
|
||||
key[i] = value;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} } // namespace mesh::companion
|
||||
@@ -18,7 +18,9 @@ struct ContactInfo {
|
||||
uint8_t type = 0; // one of ADV_TYPE_*
|
||||
uint8_t flags = 0;
|
||||
uint8_t out_path_len = 0;
|
||||
mutable bool shared_secret_valid = false;
|
||||
// Both fit in the original cache-valid byte; no extra RAM per contact.
|
||||
mutable uint8_t shared_secret_valid : 1;
|
||||
uint8_t tx_radio : 3;
|
||||
#if MESH_CONTACT_CACHE
|
||||
mesh::ContactPathRef path_ref;
|
||||
#else
|
||||
@@ -36,6 +38,8 @@ struct ContactInfo {
|
||||
mutable uint16_t storage_slot = mesh::storage::CONTACT_SLOT_NONE;
|
||||
#endif
|
||||
|
||||
ContactInfo() : shared_secret_valid(false), tx_radio(mesh::RADIO_TX_AUTO) {}
|
||||
|
||||
// The pointer is a short-lived borrow. Copy bytes for a queued operation;
|
||||
// another contact-cache access may replace the resident entry.
|
||||
const uint8_t* getPath() const {
|
||||
|
||||
@@ -87,6 +87,79 @@ inline bool decodeStoredRuleChannel(uint8_t stored, uint8_t& key_len,
|
||||
return channelKeyLengthSupported(key_len);
|
||||
}
|
||||
|
||||
// FPF7's active byte keeps bit 0 as the active flag. Bits 1/2 select bridge
|
||||
// and radio crossover admission. A zero mode retains legacy radio forwarding.
|
||||
// Row length and all channel-key encodings remain unchanged.
|
||||
enum RuleMode : uint8_t {
|
||||
RULE_MODE_RADIO = 0,
|
||||
RULE_MODE_BRIDGE = 2,
|
||||
RULE_MODE_CROSS = 4,
|
||||
};
|
||||
|
||||
inline uint8_t encodeStoredRuleActive(bool active, uint8_t modes) {
|
||||
return active ? (uint8_t)(1 | modes) : 0;
|
||||
}
|
||||
|
||||
inline bool decodeStoredRuleActive(uint8_t stored, bool& active, uint8_t& modes) {
|
||||
if ((stored & ~7U) != 0 || (stored != 0 && (stored & 1U) == 0)) return false;
|
||||
active = (stored & 1U) != 0;
|
||||
modes = stored & 6U;
|
||||
return true;
|
||||
}
|
||||
|
||||
inline const char* ruleModeName(uint8_t modes) {
|
||||
switch (modes) {
|
||||
case RULE_MODE_RADIO: return "radio";
|
||||
case RULE_MODE_BRIDGE: return "bridge";
|
||||
case RULE_MODE_CROSS: return "cross";
|
||||
default: return "bridge,cross";
|
||||
}
|
||||
}
|
||||
|
||||
inline const char* compactRuleModeName(uint8_t modes) {
|
||||
switch (modes) {
|
||||
case RULE_MODE_RADIO: return "r";
|
||||
case RULE_MODE_BRIDGE: return "b";
|
||||
case RULE_MODE_CROSS: return "c";
|
||||
default: return "bc";
|
||||
}
|
||||
}
|
||||
|
||||
inline bool parseRuleModes(const char* text, uint8_t& modes) {
|
||||
if (!text) return false;
|
||||
// Fold only the bounded selector, leaving public channel names untouched.
|
||||
char value[16];
|
||||
size_t len = strlen(text);
|
||||
if (len >= sizeof(value)) return false;
|
||||
for (size_t i = 0; i <= len; i++) {
|
||||
char c = text[i];
|
||||
value[i] = c >= 'A' && c <= 'Z' ? c + ('a' - 'A') : c;
|
||||
}
|
||||
if (strcmp(value, "radio") == 0 || strcmp(value, "r") == 0) modes = RULE_MODE_RADIO;
|
||||
else if (strcmp(value, "bridge") == 0 || strcmp(value, "b") == 0) modes = RULE_MODE_BRIDGE;
|
||||
else if (strcmp(value, "cross") == 0 || strcmp(value, "c") == 0) modes = RULE_MODE_CROSS;
|
||||
else if (strcmp(value, "bridge,cross") == 0 || strcmp(value, "cross,bridge") == 0
|
||||
|| strcmp(value, "bc") == 0 || strcmp(value, "cb") == 0
|
||||
|| strcmp(value, "b,c") == 0 || strcmp(value, "c,b") == 0)
|
||||
modes = RULE_MODE_BRIDGE | RULE_MODE_CROSS;
|
||||
else return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return packet && (modes == RULE_MODE_RADIO ? context == RULE_MODE_RADIO
|
||||
: (modes & context) != 0)
|
||||
&& (context != RULE_MODE_RADIO || packet->isRouteFlood());
|
||||
}
|
||||
|
||||
inline bool transportActionsSupported(uint8_t modes, bool scope_action,
|
||||
bool retry_action, bool slow_timing) {
|
||||
return modes == RULE_MODE_RADIO || (!scope_action && !retry_action && !slow_timing);
|
||||
}
|
||||
|
||||
inline int channelHashNibble(char c) {
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include "FloodFilterPolicy.h"
|
||||
|
||||
namespace FloodRuleCLI {
|
||||
|
||||
inline bool isChannelReference(const char* text) {
|
||||
return FloodFilterPolicy::channelHashPrefixEqual(text, "key:");
|
||||
}
|
||||
|
||||
// Keep the existing non-secret key:XXXXXXXX display usable when editing or
|
||||
// copying rules on this node. Never resolve a fingerprint collision to an
|
||||
// arbitrary key, and never widen it into an unauthenticated channel hash.
|
||||
template<typename Entry>
|
||||
bool copyChannelReference(const char* text, const Entry* entries, unsigned count,
|
||||
uint8_t& key_len, uint8_t& channel_hash,
|
||||
uint8_t* secret, char* name, size_t name_len) {
|
||||
if (!isChannelReference(text) || !entries) return false;
|
||||
const Entry* found = nullptr;
|
||||
for (unsigned i = 0; i < count; i++) {
|
||||
const auto& entry = entries[i];
|
||||
if (!entry.active || !isChannelReference(entry.channel_name)
|
||||
|| !FloodFilterPolicy::channelRequiresAuthentication(entry.channel_key_len)
|
||||
|| strlen(text) != strlen(entry.channel_name)
|
||||
|| !FloodFilterPolicy::channelHashPrefixEqual(text, entry.channel_name)) continue;
|
||||
if (found && !FloodFilterPolicy::sameChannelKey(found->channel_key_len,
|
||||
found->channel_secret, entry.channel_key_len, entry.channel_secret)) return false;
|
||||
found = &entry;
|
||||
}
|
||||
if (!found) return false;
|
||||
key_len = found->channel_key_len;
|
||||
channel_hash = found->channel_hash;
|
||||
memcpy(secret, found->channel_secret, sizeof(found->channel_secret));
|
||||
snprintf(name, name_len, "%s", found->channel_name);
|
||||
return true;
|
||||
}
|
||||
|
||||
// No temporary expansion buffer: compact and descriptive commands feed the
|
||||
// same parser. This writer emits a complete, pasteable command or an error,
|
||||
// never a truncated command whose missing suffix could change its meaning.
|
||||
class CommandWriter {
|
||||
char* _out;
|
||||
size_t _capacity;
|
||||
size_t _used = 0;
|
||||
bool _complete = true;
|
||||
public:
|
||||
CommandWriter(char* out, size_t capacity) : _out(out), _capacity(capacity) {}
|
||||
void append(const char* format, ...) {
|
||||
if (!_complete || !_out || _used >= _capacity) { _complete = false; return; }
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
int length = vsnprintf(_out + _used, _capacity - _used, format, args);
|
||||
va_end(args);
|
||||
if (length < 0 || (size_t)length >= _capacity - _used) _complete = false;
|
||||
else _used += (size_t)length;
|
||||
}
|
||||
bool finish() {
|
||||
if (!_complete && _out && _capacity) {
|
||||
snprintf(_out, _capacity, "Err - compact command exceeds reply size");
|
||||
}
|
||||
return _complete;
|
||||
}
|
||||
};
|
||||
|
||||
// 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>
|
||||
bool formatCompact(char* out, size_t capacity, unsigned slot, const Entry& entry) {
|
||||
using namespace FloodFilterPolicy;
|
||||
CommandWriter text(out, capacity);
|
||||
text.append("set fr.%u ", slot);
|
||||
if (entry.payload_type == 0xFF) text.append("any");
|
||||
else text.append("%u", (unsigned)entry.payload_type);
|
||||
if (entry.transport_modes != RULE_MODE_RADIO)
|
||||
text.append(" m=%s", compactRuleModeName(entry.transport_modes));
|
||||
if (entry.min_hops != 0 || entry.max_hops != 63) {
|
||||
if (entry.min_hops == entry.max_hops) text.append(" %u", (unsigned)entry.min_hops);
|
||||
else if (entry.max_hops == 63) text.append(" %u+", (unsigned)entry.min_hops);
|
||||
else text.append(" %u-%u", (unsigned)entry.min_hops, (unsigned)entry.max_hops);
|
||||
}
|
||||
if (entry.channel_key_len != 0) text.append(" c=%s", entry.channel_name);
|
||||
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]);
|
||||
}
|
||||
}
|
||||
switch (entry.incoming_scope_kind) {
|
||||
case RULE_IN_NONE: text.append(" i=n"); break;
|
||||
case RULE_IN_SCOPED: text.append(" i=s"); break;
|
||||
case RULE_IN_ALLOWED: text.append(" i=a"); break;
|
||||
case RULE_IN_UNKNOWN: text.append(" i=u"); break;
|
||||
case RULE_IN_SCOPE: text.append(" i=s:%s", entry.incoming_scope_name); break;
|
||||
case RULE_IN_REGION: text.append(" i=r:%s", entry.incoming_scope_name); break;
|
||||
default: break;
|
||||
}
|
||||
if (entry.drop_on_match) text.append(" d");
|
||||
else if (entry.scope_name[0]) text.append(" s=%s", entry.scope_name);
|
||||
else if (entry.target_region_name[0]) text.append(" r=%s", entry.target_region_name);
|
||||
if (entry.rate_limit_enabled) text.append(" q=%u", (unsigned)entry.rate_per_minute);
|
||||
if (entry.priority) text.append(" pri=%u", (unsigned)entry.priority);
|
||||
if (entry.stop_on_match) text.append(" s");
|
||||
if (entry.scope_uses_slow_timing || entry.suspend_on_temp_radio) {
|
||||
text.append(" f=%s%s%s", entry.scope_uses_slow_timing ? "s" : "",
|
||||
entry.suspend_on_temp_radio ? "t" : "", entry.retry_on_match ? "r" : "");
|
||||
} else if (entry.retry_on_match) text.append(" r");
|
||||
return text.finish();
|
||||
}
|
||||
|
||||
} // namespace FloodRuleCLI
|
||||
@@ -44,6 +44,9 @@ unsigned split(char* s, char* parts[], unsigned maximum) {
|
||||
bool parsePreamble(const char* s, uint16_t& result) {
|
||||
return cli::parseRadioPreamble(s, result);
|
||||
}
|
||||
bool validReplySetting(uint8_t value) {
|
||||
return (value >= 0xb0 && value <= 0xb4) || value == 0xba || value == 0xbb;
|
||||
}
|
||||
}
|
||||
|
||||
bool RadioProfileCLI::readImage(const char* path, uint8_t* bytes, size_t size) {
|
||||
@@ -92,6 +95,7 @@ bool RadioProfileCLI::save(const RadioProfileConfig& config, uint16_t preamble,
|
||||
memcpy(image + 14, &config.params.preamble, 2);
|
||||
memcpy(image + 16, &preamble, 2);
|
||||
image[18] = (uint8_t)cross;
|
||||
image[19] = reply_setting_;
|
||||
const uint32_t crc = checksum(image, ImageSize - 4);
|
||||
memcpy(image + ImageSize - 4, &crc, 4);
|
||||
if (!writeImage(TempPath, image, sizeof(image))) return false;
|
||||
@@ -107,8 +111,13 @@ bool RadioProfileCLI::save(const RadioProfileConfig& config, uint16_t preamble,
|
||||
return true;
|
||||
}
|
||||
|
||||
void RadioProfileCLI::begin(FILESYSTEM* fs, Radio* radio, RTCClock* rtc) {
|
||||
void RadioProfileCLI::begin(FILESYSTEM* fs, Radio* radio, RTCClock* rtc, bool infrastructure_replies) {
|
||||
fs_ = fs; radio_ = radio; rtc_ = rtc; last_ms_ = millis();
|
||||
infrastructure_replies_ = infrastructure_replies;
|
||||
if (radio_ && radio_->profiles()) {
|
||||
radio_->profiles()->reply_tx = infrastructure_replies ? RADIO_TX_BOTH : RADIO_TX_AUTO;
|
||||
radio_->profiles()->reply_force = false;
|
||||
}
|
||||
if (!fs_ || !radio_ || !radio_->profiles()) return;
|
||||
uint8_t bytes[ImageSize];
|
||||
bool loaded = readImage(ImagePath, bytes, sizeof(bytes));
|
||||
@@ -127,10 +136,12 @@ void RadioProfileCLI::begin(FILESYSTEM* fs, Radio* radio, RTCClock* rtc) {
|
||||
memcpy(&saved_.params.preamble, bytes + 14, 2);
|
||||
memcpy(&primary_preamble_, bytes + 16, 2);
|
||||
cross_ = (RadioCrossMode)bytes[18];
|
||||
reply_setting_ = validReplySetting(bytes[19]) ? bytes[19] : 0;
|
||||
if ((uint8_t)saved_.mode > 2 || (uint8_t)cross_ > 2
|
||||
|| (saved_.mode != RadioProfileMode::Off && !radio_->validateProfile(saved_.params))
|
||||
|| (primary_preamble_ && (primary_preamble_ < 8 || primary_preamble_ > RadioProfiles::MaxPreamble))) {
|
||||
hold_ = true; saved_ = {}; primary_preamble_ = 0; cross_ = RadioCrossMode::Auto;
|
||||
reply_setting_ = 0; // reject the entire image, including its reply override
|
||||
return;
|
||||
}
|
||||
stagePrimary(primary_preamble_, false);
|
||||
@@ -157,6 +168,10 @@ bool RadioProfileCLI::acceptsPrimary(float freq, float bw, uint8_t sf, uint8_t c
|
||||
void RadioProfileCLI::publish() {
|
||||
if (!radio_ || !radio_->profiles()) return;
|
||||
radio_->profiles()->cross = cross_;
|
||||
radio_->profiles()->reply_tx = infrastructure_replies_
|
||||
? (validReplySetting(reply_setting_) ? reply_setting_ & 7 : RADIO_TX_BOTH) : RADIO_TX_AUTO;
|
||||
radio_->profiles()->reply_force = infrastructure_replies_
|
||||
&& validReplySetting(reply_setting_) && (reply_setting_ & 8);
|
||||
if (publish_pending_ && (int32_t)(millis() - publish_after_ms_) < 0) return;
|
||||
publish_pending_ = false;
|
||||
radio_->profiles()->setSecondary(temp_active_ ? temporary_ : saved_, temp_active_);
|
||||
@@ -292,12 +307,46 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity)
|
||||
const bool scheduled = !strcmp(key, "radioat2") || !strcmp(key, "tempradioat2");
|
||||
const bool scheduled_temp = !strcmp(key, "tempradioat2");
|
||||
const bool crossing = !strcmp(key, "radio2.cross");
|
||||
const bool replies = infrastructure_replies_ && !strcmp(key, "tx.reply");
|
||||
const bool status = !strcmp(key, "radio2.status");
|
||||
const bool scan = !strcmp(key, "radio2.scan");
|
||||
const bool timing = !strcmp(key, "radio2.timing") || !strcmp(key, "radio.timing");
|
||||
if (!base && !temporary && !scheduled && !crossing && !status && !scan && !timing) return false;
|
||||
if (!base && !temporary && !scheduled && !crossing && !replies && !status && !scan && !timing) return false;
|
||||
if (!radio_ || !radio_->profiles()) { snprintf(reply, capacity, "Error: radio profiles unsupported"); return true; }
|
||||
if (command == text && *args && verb == Get && (temporary || base)) verb = Set;
|
||||
if (replies) {
|
||||
const auto* p = radio_->profiles();
|
||||
if (verb == Get && !*args) {
|
||||
snprintf(reply, capacity, "> %s%s%s", radioTxPolicyName(p->reply_tx),
|
||||
p->reply_force ? " force" : "",
|
||||
(p->reply_tx == RADIO_TX_BOTH || p->reply_tx == RADIO_TX_SECONDARY)
|
||||
&& !p->canTransmit(1, p->reply_force) ? "; radio2 TX unavailable" : "");
|
||||
return true;
|
||||
}
|
||||
char mode[16];
|
||||
const size_t length = strcspn(args, " \t");
|
||||
uint8_t policy = RADIO_TX_AUTO;
|
||||
const char* suffix = args + length;
|
||||
while (*suffix == ' ' || *suffix == '\t') ++suffix;
|
||||
const bool force = !strcmp(suffix, "force");
|
||||
if (length < sizeof(mode)) { memcpy(mode, args, length); mode[length] = 0; }
|
||||
if (verb != Set || length >= sizeof(mode) || !parseRadioTxPolicy(mode, policy)
|
||||
|| (*suffix && !force)
|
||||
|| (force && policy != RADIO_TX_BOTH && policy != RADIO_TX_SECONDARY)) {
|
||||
snprintf(reply, capacity, "Error: use get/set tx.reply auto|radio|radio2|both|off [force for radio2/both]");
|
||||
return true;
|
||||
}
|
||||
const uint8_t previous = reply_setting_;
|
||||
reply_setting_ = 0xb0 | policy | (force ? 8 : 0);
|
||||
if (!save(saved_, primary_preamble_, cross_)) {
|
||||
reply_setting_ = previous;
|
||||
snprintf(reply, capacity, "Error: settings could not be saved");
|
||||
} else {
|
||||
publish();
|
||||
snprintf(reply, capacity, "OK - tx.reply=%s%s", radioTxPolicyName(policy), force ? " force" : "");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (timing) {
|
||||
if (verb != Get || *args) { snprintf(reply, capacity, "Error: radio timing is read-only"); return true; }
|
||||
const auto& p = *radio_->profiles();
|
||||
|
||||
@@ -25,6 +25,8 @@ class RadioProfileCLI {
|
||||
RadioProfileConfig pending_temporary_;
|
||||
uint16_t primary_preamble_ = 0;
|
||||
RadioCrossMode cross_ = RadioCrossMode::Auto;
|
||||
uint8_t reply_setting_ = 0; // reserved image byte: 0 = role default
|
||||
bool infrastructure_replies_ = false;
|
||||
Schedule schedules_[8];
|
||||
uint32_t temp_remaining_ms_ = 0;
|
||||
uint32_t temp_start_ms_ = 0;
|
||||
@@ -41,7 +43,7 @@ class RadioProfileCLI {
|
||||
void formatConfig(char* reply, size_t capacity, const RadioProfileConfig& config,
|
||||
bool temporary, uint32_t remaining_ms) const;
|
||||
public:
|
||||
void begin(FILESYSTEM* fs, Radio* radio, RTCClock* rtc);
|
||||
void begin(FILESYSTEM* fs, Radio* radio, RTCClock* rtc, bool infrastructure_replies = false);
|
||||
void loop();
|
||||
bool handle(const char* command, char* reply, size_t capacity = 160);
|
||||
uint16_t primaryPreamble() const { return primary_preamble_; }
|
||||
|
||||
@@ -5,26 +5,48 @@ namespace mesh {
|
||||
// A parameterized TempRadio command changes the modulation used to carry its
|
||||
// own remote-CLI reply. A wall-clock delay cannot prove that the reply has
|
||||
// left a busy outbound queue, so keep the scheduled handoff blocked by the
|
||||
// exact packet object until Dispatcher reports TX completion. This class is
|
||||
// exact packet objects on both profiles until Dispatcher reports TX completion.
|
||||
// At least one successful TX permits the handoff, after both copies drain. This class is
|
||||
// deliberately pointer-only: packet ownership stays with Dispatcher.
|
||||
class TempRadioReplyBarrier {
|
||||
const void* _packet;
|
||||
const void* _packet = nullptr;
|
||||
const void* _copy = nullptr;
|
||||
bool _armed = false;
|
||||
bool _succeeded = false;
|
||||
|
||||
public:
|
||||
TempRadioReplyBarrier() : _packet(nullptr) {}
|
||||
|
||||
void arm(const void* packet) { _packet = packet; }
|
||||
void clear() { _packet = nullptr; }
|
||||
|
||||
bool waiting() const { return _packet != nullptr; }
|
||||
|
||||
bool complete(const void* packet) {
|
||||
if (_packet == nullptr || packet != _packet) return false;
|
||||
_packet = nullptr;
|
||||
bool remove(const void* packet) {
|
||||
if (!_armed || packet == nullptr) return false;
|
||||
if (packet == _packet) _packet = nullptr;
|
||||
else if (packet == _copy) _copy = nullptr;
|
||||
else return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool fail(const void* packet) { return complete(packet); }
|
||||
public:
|
||||
// Collect the Dispatcher-created second copy during queue admission. Retry
|
||||
// bookkeeping can call onSendFail before admission; it must not resolve this.
|
||||
void prepare(const void* packet) { clear(); _packet = packet; }
|
||||
void trackCopy(const void* original, const void* copy) {
|
||||
if (original && original == _packet && copy != original) _copy = copy;
|
||||
}
|
||||
void arm(const void* packet) {
|
||||
if (packet != _packet) prepare(packet);
|
||||
_armed = packet != nullptr;
|
||||
}
|
||||
void clear() { _packet = _copy = nullptr; _armed = _succeeded = false; }
|
||||
|
||||
bool waiting() const { return _armed && (_packet || _copy); }
|
||||
|
||||
bool complete(const void* packet) {
|
||||
if (!remove(packet)) return false;
|
||||
_succeeded = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cancel the transition only when every admitted copy has failed.
|
||||
bool fail(const void* packet) {
|
||||
return remove(packet) && !waiting() && !_succeeded;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace mesh
|
||||
@@ -39,6 +39,11 @@ void BridgeBase::handleReceivedPacket(mesh::Packet *packet) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowsPacket(packet)) {
|
||||
_mgr->free(packet);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_seen_packets.wasSeen(packet)) {
|
||||
_seen_packets.markSeen(packet);
|
||||
// bridge_delay provides a buffer to prevent immediate processing conflicts in the mesh network.
|
||||
|
||||
@@ -545,6 +545,8 @@ void ESPNowBridge::sendPacket(mesh::Packet *packet) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowsPacket(packet)) return;
|
||||
|
||||
if (!_seen_packets.wasSeen(packet)) {
|
||||
// Check the serialized size before writing into the ESP-NOW-sized buffer.
|
||||
const int expectedMeshPacketLen = packet->getRawLength();
|
||||
|
||||
@@ -3214,6 +3214,11 @@ void MQTTBridge::onPacketReceived(mesh::Packet *packet) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowsPacket(packet)) {
|
||||
_filtered_packets++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Queue packet for transmission
|
||||
queuePacket(packet, false);
|
||||
}
|
||||
@@ -3237,6 +3242,11 @@ void MQTTBridge::sendPacket(mesh::Packet *packet) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowsPacket(packet)) {
|
||||
_filtered_packets++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Queue packet for transmission
|
||||
queuePacket(packet, true);
|
||||
}
|
||||
|
||||
@@ -118,6 +118,8 @@ void RS232Bridge::sendPacket(mesh::Packet *packet) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowsPacket(packet)) return;
|
||||
|
||||
if (!_seen_packets.wasSeen(packet)) {
|
||||
_seen_packets.markSeen(packet);
|
||||
|
||||
|
||||
@@ -100,6 +100,12 @@ enum OtaMsgType : uint8_t {
|
||||
OTA_LEAVES = 0x0B, // a fragment of the leaves[] array (for host-side seed leaf-diff)
|
||||
};
|
||||
|
||||
// Locally served replies may be emitted long after RX, outside its call stack.
|
||||
inline bool ota_is_response_message(uint8_t type) {
|
||||
return type == OTA_HAVE || type == OTA_MANIFEST || type == OTA_DATA
|
||||
|| type == OTA_PROOF || type == OTA_LEAVES;
|
||||
}
|
||||
|
||||
// Discovery is periodic background traffic; every packet that advances an active fetch is primary
|
||||
// traffic. Keep this classification in the wire-format layer so origins and opaque relay-only builds make
|
||||
// the same queueing decision without needing an OtaManager.
|
||||
|
||||
@@ -303,7 +303,11 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) {
|
||||
}
|
||||
|
||||
mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t profile) {
|
||||
if (!_profiles.canTransmit(profile)) return mesh::RadioParamApplyResult::FAILED;
|
||||
return prepareTransmitProfile(profile, false);
|
||||
}
|
||||
|
||||
mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t profile, bool reply_rx_override) {
|
||||
if (!_profiles.canTransmit(profile, reply_rx_override)) return mesh::RadioParamApplyResult::FAILED;
|
||||
if (_profiles.enabled() && (isChipBusy() || isPacketPendingOrReceiving())) {
|
||||
return mesh::RadioParamApplyResult::BUSY;
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@ public:
|
||||
uint8_t receiveProfile() const override { return _active_profile; }
|
||||
uint32_t receiveProfileGeneration() const override { return _profile_generation; }
|
||||
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t profile) override;
|
||||
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t profile, bool reply_rx_override) override;
|
||||
mesh::RadioParamApplyResult trySetPrimaryParams(const mesh::RadioProfileParams& params,
|
||||
bool temporary, const uint32_t* timings = nullptr) override;
|
||||
uint16_t profilePreamble(uint8_t profile) const override {
|
||||
|
||||
+2
-1
@@ -48,6 +48,7 @@ python3 test/test_esp32_tinyusb_nonblocking.py # Native CDC stalled-host/64-byte
|
||||
python3 test/test_esp32_tinyusb_role_hygiene.py # Repeater/room nonblocking console and paced large replies
|
||||
python3 test/test_esp32_tinyusb_cooperative_output.py # Real role pumps: large logs/listings, EOF, backpressure
|
||||
python3 test/test_temp_radio_reply_delivery_contract.py # TempRadio ACK path/barrier integration
|
||||
python3 test/test_reply_tx_integration.py # Delayed OTA replies, backpressure, and all infrastructure roles
|
||||
python3 test/test_repeater_radio_timing_integration.py # Real scheduler, watchdog, hourly/flood adverts and restoration
|
||||
python3 test/test_tls_download_clock_gates.py # Fresh-NTP/TLS download integration contract
|
||||
```
|
||||
@@ -124,7 +125,7 @@ does not reflect the GoogleTest count -- run the built binary directly
|
||||
| `test_atomic_file_writer` | `src/helpers/AtomicFileWriter.h` | verified temporary-file commit; short-write, readback, validation, and rename failures; preservation of the live file and stale-temp cleanup |
|
||||
| `test_client_login_persistence` | `src/helpers/ClientLoginPersistence.h`, `LazyPersistence.h` | pre-allocation durable replay reservations; reboot, ACL-eviction, and revoked-admin tombstones; bounded-store policy (low-trust no-insert, privileged fail-closed); atomic replay-file recovery, including post-commit cleanup failure; preauthorized transient refresh; admin/guest promotion and downgrade; force-flood preservation; role masking and fixed-size secret copy |
|
||||
| `test_client_path_persistence` | `src/helpers/ClientPathPersistence.h`, `LazyPersistence.h` | encoded path identity and byte lengths; unknown, zero-hop, force-flood, and nonpersistent clients; replay-unqualified RAM-only routes preserve an earlier operator route across unrelated save/reload |
|
||||
| `test_temp_radio_reply_barrier` | `src/helpers/TempRadioReplyBarrier.h`, `TempRadioLeaseDeadline.h` | exact single-copy queued reply completion/failure handoff; suppression of untracked alternate/retry copies; rejection of foreign callbacks; cancellation; monotonic hard expiry that a backward wall-clock correction cannot extend; bounded remaining-time reporting |
|
||||
| `test_temp_radio_reply_barrier` | `src/helpers/TempRadioReplyBarrier.h`, `TempRadioLeaseDeadline.h` | both profile copies drain before handoff; either successful TX permits switching; suppression of untracked alternate/retry copies; rejection of foreign callbacks; cancellation; monotonic hard expiry that a backward wall-clock correction cannot extend; bounded remaining-time reporting |
|
||||
| `test_lazy_persistence` | `src/helpers/LazyPersistence.h` | first-write scheduling without postponement, zero-sentinel rollover preservation, capped exponential save-failure backoff that mutations cannot defeat, and reset only after success |
|
||||
| `test_client_acl_file_transaction` | `src/helpers/ClientACLFileTransaction.h` | verified temp publication, preservation of the prior ACL on verification/rename failure, post-commit cleanup-failure handling, and recovery at every temp/backup/primary boundary |
|
||||
| `test_cad_timing` | `src/helpers/radiolib/CadTiming.h`, `LR2021SideDetectorConfig.h`, `RadioAirtime.h` | Cascade and slow-profile CAD deadlines; invalid airtime handling; bounded LR2021 side-detector parsing and LDRO recomputation |
|
||||
|
||||
@@ -240,6 +240,9 @@ public:
|
||||
#define MSG_SEND_SENT_DIRECT 2
|
||||
#define ADV_TYPE_ROOM 3
|
||||
class BaseChatMesh : public mesh::Mesh {
|
||||
uint32_t getTransmitAirtime(const mesh::Packet* packet) const {
|
||||
return _radio->getEstAirtimeFor(packet->getRawLength());
|
||||
}
|
||||
struct Radio { uint32_t getEstAirtimeFor(int) { return 20; } } radio;
|
||||
struct Clock { uint32_t getCurrentTimeUnique() { return 100; } } clock;
|
||||
struct Rng { void random(uint8_t* data, size_t size) { memset(data, 42, size); } } rng;
|
||||
@@ -638,7 +641,25 @@ static void nrf52_migration_snapshots_and_failed_page_replacement() {
|
||||
}
|
||||
#endif
|
||||
|
||||
static void contact_tx_policy_round_trips_without_growing_records() {
|
||||
Fixture f(1);
|
||||
auto& contact = f.host.contacts[0];
|
||||
assert(contact.tx_radio == mesh::RADIO_TX_AUTO);
|
||||
for (uint8_t policy = mesh::RADIO_TX_AUTO; policy <= mesh::RADIO_TX_OFF; ++policy) {
|
||||
contact.tx_radio = policy;
|
||||
contact.shared_secret_valid = true;
|
||||
uint8_t record[mesh::storage::CONTACT_RECORD_SIZE];
|
||||
assert(serializeContactRecord(contact, record));
|
||||
assert(sizeof(record) == 152 && record[66] == mesh::encodeRadioTxPolicy(policy));
|
||||
ContactInfo restored;
|
||||
assert(deserializeContactRecord(record, restored, 0));
|
||||
assert(restored.tx_radio == policy && !restored.shared_secret_valid);
|
||||
assert(contact.shared_secret_valid && contact.tx_radio == policy);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
contact_tx_policy_round_trips_without_growing_records();
|
||||
#if defined(ESP32_PLATFORM)
|
||||
routes_survive_eviction_and_full_sync();
|
||||
snapshot_rollback_and_dirty_eviction();
|
||||
|
||||
+78
-1
@@ -30,7 +30,7 @@ struct Fixture {
|
||||
Radio radio;
|
||||
mesh::RadioProfileCLI cli;
|
||||
char reply[160];
|
||||
Fixture() { g_mock_millis=0; cli.begin(&fs,&radio,&clock); }
|
||||
Fixture(bool infrastructure = false) { g_mock_millis=0; cli.begin(&fs,&radio,&clock,infrastructure); }
|
||||
const char* cmd(const char* text, bool ok=true) {
|
||||
memset(reply,0x55,sizeof(reply));
|
||||
assert(cli.handle(text,reply,sizeof(reply)));
|
||||
@@ -43,6 +43,83 @@ struct Fixture {
|
||||
void advance(uint32_t ms, bool epoch=true) { g_mock_millis+=ms; if(epoch)clock.epoch+=ms/1000; cli.loop(); }
|
||||
};
|
||||
int main() {
|
||||
{
|
||||
Fixture f(true);
|
||||
f.cmd("set tx.reply off");
|
||||
// Simulate an image whose CRC is valid but whose saved mode is invalid.
|
||||
auto& image=f.fs.files["/radio_profiles"];
|
||||
image[3]=9;
|
||||
uint32_t crc=0xffffffffU;
|
||||
for (size_t i=0;i<20;++i) {
|
||||
crc ^= image[i];
|
||||
for (unsigned bit=0;bit<8;++bit) crc=(crc>>1)^((crc&1)?0xedb88320U:0);
|
||||
}
|
||||
memcpy(image.data()+20,&crc,4);
|
||||
const auto damaged=image;
|
||||
Radio radio; mesh::RadioProfileCLI restored;
|
||||
restored.begin(&f.fs,&radio,&f.clock,true);
|
||||
restored.loop();
|
||||
assert(radio.p.reply_tx==mesh::RADIO_TX_BOTH && !radio.p.reply_force);
|
||||
assert(!radio.p.enabled());
|
||||
assert(restored.handle("set tx.reply radio",f.reply));
|
||||
assert(strstr(f.reply,"Error"));
|
||||
assert(f.fs.files["/radio_profiles"]==damaged);
|
||||
}
|
||||
{
|
||||
Fixture companion;
|
||||
assert(companion.radio.p.reply_tx==mesh::RADIO_TX_AUTO);
|
||||
assert(!companion.cli.handle("set tx.reply both force",companion.reply));
|
||||
Fixture f(true);
|
||||
assert(strstr(f.cmd("get tx.reply"),"> both; radio2 TX unavailable"));
|
||||
f.cmd("set radio2 910.5,500,7,5,rx"); f.advance(2000);
|
||||
f.cmd("set tx.reply both force");
|
||||
assert(!strcmp(f.cmd("get tx.reply"),"> both force"));
|
||||
assert(f.radio.p.reply_tx==mesh::RADIO_TX_BOTH && f.radio.p.reply_force);
|
||||
assert(f.radio.p.secondary.mode==mesh::RadioProfileMode::Rx);
|
||||
assert(f.fs.files["/radio_profiles"].size()==24);
|
||||
// Every legal choice survives reboot, including explicit AUTO (distinct
|
||||
// from the zero reserved byte in firmware predating reply routing).
|
||||
const char* modes[]={"auto","radio","radio2","both","off","radio2 force","both force"};
|
||||
const uint8_t policies[]={0,1,2,3,4,2,3};
|
||||
for (unsigned i=0;i<7;++i) {
|
||||
char command[64]; snprintf(command,sizeof(command),"set tx.reply %s",modes[i]);
|
||||
f.cmd(command);
|
||||
Radio reboot; mesh::RadioProfileCLI restored;
|
||||
restored.begin(&f.fs,&reboot,&f.clock,true);
|
||||
assert(reboot.p.reply_tx==policies[i] && reboot.p.reply_force==(i>=5));
|
||||
}
|
||||
f.fs.fail_write=true;
|
||||
f.cmd("set tx.reply off",false);
|
||||
assert(f.radio.p.reply_tx==mesh::RADIO_TX_BOTH && f.radio.p.reply_force);
|
||||
f.fs.fail_write=false; f.fs.fail_rename=2;
|
||||
f.cmd("set tx.reply off",false);
|
||||
Radio reboot; mesh::RadioProfileCLI restored;
|
||||
restored.begin(&f.fs,&reboot,&f.clock,true);
|
||||
assert(reboot.p.reply_tx==mesh::RADIO_TX_BOTH && reboot.p.reply_force);
|
||||
const char* invalid[]={"", "both junk", "auto force", "radio force", "off force",
|
||||
"both force extra", "force", "bothforce", "radio2345678901234567"};
|
||||
for (const char* args:invalid) {
|
||||
char command[80]; snprintf(command,sizeof(command),"set tx.reply %s",args);
|
||||
f.cmd(command,false);
|
||||
assert(f.radio.p.reply_tx==mesh::RADIO_TX_BOTH && f.radio.p.reply_force);
|
||||
}
|
||||
assert(strstr(f.cmd("get tx.reply extra"),"Error"));
|
||||
// A Companion neither activates nor erases infrastructure reply choices
|
||||
// when it saves another profile setting.
|
||||
Radio other; mesh::RadioProfileCLI other_cli;
|
||||
other_cli.begin(&f.fs,&other,&f.clock);
|
||||
assert(other.p.reply_tx==mesh::RADIO_TX_AUTO && !other.p.reply_force);
|
||||
assert(other_cli.handle("set radio2.cross on",f.reply));
|
||||
Radio back; mesh::RadioProfileCLI back_cli;
|
||||
back_cli.begin(&f.fs,&back,&f.clock,true);
|
||||
assert(back.p.reply_tx==mesh::RADIO_TX_BOTH && back.p.reply_force);
|
||||
// Old images have a zero reserved byte and adopt BOTH on infrastructure.
|
||||
Fixture legacy; legacy.cmd("set radio2 910.5,500,7,5,rxtx");
|
||||
assert(legacy.fs.files["/radio_profiles"][19]==0);
|
||||
Radio upgrade; mesh::RadioProfileCLI upgraded;
|
||||
upgraded.begin(&legacy.fs,&upgrade,&legacy.clock,true);
|
||||
assert(upgrade.p.reply_tx==mesh::RADIO_TX_BOTH && !upgrade.p.reply_force);
|
||||
}
|
||||
{
|
||||
Fixture f;
|
||||
assert(!strcmp(f.cmd("get radio2"),"> off"));
|
||||
|
||||
+348
-1
@@ -12,6 +12,11 @@ class DualProfileTestRadio : public RetryCodingRateRadio {
|
||||
void loop() override { ++carrier_services; }
|
||||
bool recoverRadio(bool) override { ++recoveries; return true; }
|
||||
uint32_t estimated_airtime = 10;
|
||||
bool distinguish_airtime = false;
|
||||
uint32_t getProfileAirtime(uint8_t profile, int bytes, uint8_t cr = 0) override {
|
||||
return distinguish_airtime ? (profile ? 70 : 900)
|
||||
: mesh::Radio::getProfileAirtime(profile, bytes, cr);
|
||||
}
|
||||
std::vector<uint8_t> incoming;
|
||||
std::vector<uint8_t> transmissions;
|
||||
DualProfileTestRadio() {
|
||||
@@ -27,8 +32,11 @@ class DualProfileTestRadio : public RetryCodingRateRadio {
|
||||
uint32_t getEstAirtimeFor(int) override { return estimated_airtime; }
|
||||
uint8_t receiveProfile() const override { return selected; }
|
||||
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t p) override {
|
||||
return prepareTransmitProfile(p, false);
|
||||
}
|
||||
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t p, bool reply_rx_override) override {
|
||||
if (!incoming.empty()) return mesh::RadioParamApplyResult::BUSY;
|
||||
if (!config.canTransmit(p)) return mesh::RadioParamApplyResult::FAILED;
|
||||
if (!config.canTransmit(p, reply_rx_override)) return mesh::RadioParamApplyResult::FAILED;
|
||||
selected = p; cr = config.params(p).cr;
|
||||
return mesh::RadioParamApplyResult::APPLIED;
|
||||
}
|
||||
@@ -51,6 +59,16 @@ class DualProfileTestRadio : public RetryCodingRateRadio {
|
||||
class DualProfileTestMesh : public RetryCodingRateMesh {
|
||||
public:
|
||||
using RetryCodingRateMesh::RetryCodingRateMesh;
|
||||
using RetryCodingRateMesh::isPacketRadioCurrent;
|
||||
using RetryCodingRateMesh::getTransmitAirtime;
|
||||
using RetryCodingRateMesh::getTransmitProfileMask;
|
||||
using RetryCodingRateMesh::tryParsePacket;
|
||||
bool cross_filter_allows = true;
|
||||
unsigned cross_filter_calls = 0;
|
||||
bool allowRadioProfileCross(const mesh::Packet*) override {
|
||||
++cross_filter_calls;
|
||||
return cross_filter_allows;
|
||||
}
|
||||
mesh::Packet* replyOn(uint8_t profile, uint32_t generation) {
|
||||
ReceiveProfileScope context(*this, profile, generation);
|
||||
auto* packet = obtainNewPacket();
|
||||
@@ -82,6 +100,54 @@ class DualProfileTest : public testing::Test {
|
||||
void tick(uint32_t ms = 1) { clock.now += ms; node.loop(); }
|
||||
};
|
||||
|
||||
TEST_F(DualProfileTest, CrossFilterKeepsPrimaryTransmissionAndAvoidsCopyAllocation) {
|
||||
node.cross_filter_allows = false;
|
||||
ASSERT_NE(nullptr, queue());
|
||||
ASSERT_EQ(1, manager.getOutboundTotal());
|
||||
EXPECT_EQ(0, manager.getOutboundByIdx(0)->radio_profile);
|
||||
EXPECT_EQ(1U, node.cross_filter_calls);
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, CrossFilterKeepsSecondaryReplyOnItsOrigin) {
|
||||
radio.config.cross = mesh::RadioCrossMode::On;
|
||||
node.cross_filter_allows = false;
|
||||
auto* reply = node.replyOn(1, radio.config.generation[1]);
|
||||
ASSERT_TRUE(node.sendPacket(reply, 0));
|
||||
ASSERT_EQ(1, manager.getOutboundTotal());
|
||||
EXPECT_EQ(1, manager.getOutboundByIdx(0)->radio_profile);
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, CrossFilterCannotRerouteAnRxOnlyOrigin) {
|
||||
radio.config.cross = mesh::RadioCrossMode::On;
|
||||
radio.config.secondary.mode = mesh::RadioProfileMode::Rx;
|
||||
node.cross_filter_allows = false;
|
||||
auto* reply = node.replyOn(1, radio.config.generation[1]);
|
||||
ASSERT_FALSE(node.sendPacket(reply, 0));
|
||||
EXPECT_EQ(0, manager.getOutboundTotal());
|
||||
EXPECT_EQ(40, manager.getFreeCount());
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, BoundCrossRetryCannotBypassChangedFilter) {
|
||||
node.cross_filter_allows = false;
|
||||
auto* retry = node.obtainNewPacket();
|
||||
ASSERT_NE(nullptr, retry);
|
||||
*retry = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
|
||||
retry->radio_bound = true;
|
||||
retry->radio_origin = 0;
|
||||
retry->radio_profile = 1;
|
||||
ASSERT_FALSE(node.sendPacket(retry, 0));
|
||||
EXPECT_EQ(0, manager.getOutboundTotal());
|
||||
EXPECT_EQ(40, manager.getFreeCount());
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, SingleRadioDoesNotConsultCrossFilter) {
|
||||
radio.config.secondary.mode = mesh::RadioProfileMode::Off;
|
||||
node.cross_filter_allows = false;
|
||||
ASSERT_NE(nullptr, queue());
|
||||
EXPECT_EQ(1, manager.getOutboundTotal());
|
||||
EXPECT_EQ(0U, node.cross_filter_calls);
|
||||
}
|
||||
|
||||
TEST(RadioProfiles, CrossPolicyAndReceiveOnlyMatrix) {
|
||||
mesh::RadioProfiles p;
|
||||
for (int a = 0; a < 2; ++a) for (int b = 0; b < 2; ++b) {
|
||||
@@ -155,6 +221,244 @@ TEST_F(DualProfileTest, TransmitsIdenticalMessageOnBothProfilesOnce) {
|
||||
EXPECT_EQ(40, manager.getFreeCount());
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, ExplicitTransmitChoicesOverrideCrossWithoutEnablingRxOnly) {
|
||||
node.flood_attempts = 0;
|
||||
for (auto cross : {mesh::RadioCrossMode::Auto, mesh::RadioCrossMode::On, mesh::RadioCrossMode::Off}) {
|
||||
radio.config.cross = cross;
|
||||
for (auto secondary : {mesh::RadioProfileMode::Off, mesh::RadioProfileMode::Rx, mesh::RadioProfileMode::RxTx}) {
|
||||
radio.config.secondary.mode = secondary;
|
||||
for (bool temporary : {false, true}) {
|
||||
radio.config.secondary_temporary = temporary;
|
||||
for (uint8_t policy = mesh::RADIO_TX_PRIMARY; policy <= mesh::RADIO_TX_OFF; ++policy) {
|
||||
auto* packet = node.obtainNewPacket(); ASSERT_NE(nullptr, packet);
|
||||
*packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
|
||||
packet->tx_radio = policy;
|
||||
const uint8_t expected = mesh::explicitRadioTxMask(policy, secondary == mesh::RadioProfileMode::RxTx);
|
||||
ASSERT_EQ(expected != 0, node.sendFlood(packet));
|
||||
uint8_t actual = 0;
|
||||
while (manager.getOutboundTotal()) {
|
||||
auto* queued = manager.getOutboundByIdx(0);
|
||||
actual |= 1U << queued->radio_profile;
|
||||
EXPECT_EQ(policy, queued->tx_radio);
|
||||
EXPECT_TRUE(node.isPacketRadioCurrent(queued));
|
||||
manager.removeOutboundByIdx(0); node.releasePacket(queued);
|
||||
}
|
||||
EXPECT_EQ(expected, actual);
|
||||
EXPECT_EQ(40, manager.getFreeCount());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, ExplicitSecondaryRetriesStayOnSecondaryWithCrossOff) {
|
||||
radio.config.cross = mesh::RadioCrossMode::Off;
|
||||
radio.config.secondary_temporary = true;
|
||||
auto* packet = node.obtainNewPacket(); ASSERT_NE(nullptr, packet);
|
||||
*packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
|
||||
packet->tx_radio = mesh::RADIO_TX_SECONDARY;
|
||||
ASSERT_TRUE(node.sendFlood(packet));
|
||||
for (int i = 0; i < 50; ++i) { radio.complete = true; tick(1000); }
|
||||
ASSERT_GE(radio.transmissions.size(), 2U);
|
||||
for (auto profile : radio.transmissions) EXPECT_EQ(1, profile);
|
||||
EXPECT_EQ(40, manager.getFreeCount());
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, InfrastructureReplyChoiceMatrix) {
|
||||
for (auto cross : {mesh::RadioCrossMode::Auto, mesh::RadioCrossMode::On, mesh::RadioCrossMode::Off}) {
|
||||
radio.config.cross = cross;
|
||||
for (auto secondary : {mesh::RadioProfileMode::Off, mesh::RadioProfileMode::Rx, mesh::RadioProfileMode::RxTx}) {
|
||||
radio.config.secondary.mode = secondary;
|
||||
for (bool temporary : {false,true}) {
|
||||
radio.config.secondary_temporary = temporary;
|
||||
for (uint8_t policy=0;policy<=mesh::RADIO_TX_OFF;++policy) {
|
||||
for (bool force : {false,true}) {
|
||||
if (force && policy!=mesh::RADIO_TX_SECONDARY && policy!=mesh::RADIO_TX_BOTH) continue;
|
||||
radio.config.reply_tx=policy; radio.config.reply_force=force;
|
||||
for (uint8_t origin : {0,1}) {
|
||||
auto* packet=node.replyOn(origin,radio.config.generation[origin]);
|
||||
ASSERT_NE(nullptr,packet); ASSERT_TRUE(packet->radio_reply);
|
||||
const bool can_second=secondary==mesh::RadioProfileMode::RxTx
|
||||
|| (force && secondary==mesh::RadioProfileMode::Rx);
|
||||
const uint8_t expected=policy==mesh::RADIO_TX_AUTO ? radio.config.transmitMask(origin)
|
||||
: (policy==mesh::RADIO_TX_OFF ? 0 : policy & (can_second ? 3 : 1));
|
||||
ASSERT_EQ(expected!=0,node.sendPacket(packet,0));
|
||||
uint8_t actual=0;
|
||||
while (manager.getOutboundTotal()) {
|
||||
auto* queued=manager.removeOutboundByIdx(0);
|
||||
actual |= 1U << queued->radio_profile;
|
||||
EXPECT_EQ(policy,queued->tx_radio);
|
||||
EXPECT_EQ(force,queued->radio_reply_force);
|
||||
EXPECT_EQ(origin,queued->radio_origin);
|
||||
EXPECT_TRUE(node.isPacketRadioCurrent(queued));
|
||||
node.releasePacket(queued);
|
||||
}
|
||||
EXPECT_EQ(expected,actual); EXPECT_EQ(40,manager.getFreeCount());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, ReplyForceDoesNotEnableForwardedOrUnsolicitedTraffic) {
|
||||
node.flood_attempts=0;
|
||||
radio.config.cross=mesh::RadioCrossMode::Off;
|
||||
radio.config.secondary.mode=mesh::RadioProfileMode::Rx;
|
||||
radio.config.reply_tx=mesh::RADIO_TX_BOTH; radio.config.reply_force=true;
|
||||
auto raw_packet=makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
|
||||
uint8_t raw[MAX_TRANS_UNIT]; const uint8_t length=raw_packet.writeTo(raw);
|
||||
auto* received=node.replyOn(0,radio.config.generation[0]); ASSERT_NE(nullptr,received);
|
||||
received->radio_reply_force=true; received->tx_radio=mesh::RADIO_TX_BOTH;
|
||||
ASSERT_TRUE(node.tryParsePacket(received,raw,length));
|
||||
ASSERT_FALSE(received->radio_reply); ASSERT_FALSE(received->radio_reply_force);
|
||||
ASSERT_EQ(mesh::RADIO_TX_AUTO,received->tx_radio);
|
||||
ASSERT_TRUE(node.sendPacket(received,0));
|
||||
ASSERT_NE(nullptr,queue(PAYLOAD_TYPE_ADVERT));
|
||||
ASSERT_EQ(2,manager.getOutboundTotal());
|
||||
for (int i=0;i<2;++i) {
|
||||
const auto* queued=manager.getOutboundByIdx(i);
|
||||
EXPECT_EQ(0,queued->radio_profile); EXPECT_FALSE(queued->radio_reply);
|
||||
}
|
||||
for (int i=0;i<6;++i) { radio.complete=true; tick(); }
|
||||
EXPECT_EQ((std::vector<uint8_t>{0,0}),radio.transmissions);
|
||||
EXPECT_EQ(40,manager.getFreeCount());
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, ForcedReplySurvivesDriverRetryOnRxOnlySecondary) {
|
||||
node.flood_attempts=0;
|
||||
radio.config.cross=mesh::RadioCrossMode::Off;
|
||||
radio.config.secondary.mode=mesh::RadioProfileMode::Rx;
|
||||
radio.config.reply_tx=mesh::RADIO_TX_SECONDARY; radio.config.reply_force=true;
|
||||
auto* packet=node.replyOn(0,radio.config.generation[0]); ASSERT_NE(nullptr,packet);
|
||||
ASSERT_TRUE(node.sendPacket(packet,0));
|
||||
radio.fail_next_send=true;
|
||||
for (int i=0;i<12;++i) { radio.complete=true; tick(1000); }
|
||||
EXPECT_EQ((std::vector<uint8_t>{1}),radio.transmissions);
|
||||
EXPECT_EQ(mesh::RadioProfileMode::Rx,radio.config.secondary.mode);
|
||||
EXPECT_EQ(40,manager.getFreeCount());
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, BothForcedReplyCopiesKeepSeparateFloodRetries) {
|
||||
radio.config.cross=mesh::RadioCrossMode::Off;
|
||||
radio.config.secondary.mode=mesh::RadioProfileMode::Rx;
|
||||
radio.config.secondary_temporary=true;
|
||||
radio.config.reply_tx=mesh::RADIO_TX_BOTH; radio.config.reply_force=true;
|
||||
auto* packet=node.replyOn(1,radio.config.generation[1]); ASSERT_NE(nullptr,packet);
|
||||
packet->header=PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT;
|
||||
ASSERT_TRUE(node.sendFlood(packet));
|
||||
for (int i=0;i<50;++i) { radio.complete=true; tick(1000); }
|
||||
unsigned counts[2]={};
|
||||
for (auto profile:radio.transmissions) ++counts[profile];
|
||||
EXPECT_GE(counts[0],2U); EXPECT_GE(counts[1],2U);
|
||||
EXPECT_EQ(40,manager.getFreeCount());
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, ForcedReplyRespectsCrossFiltersAndProfileExpiry) {
|
||||
radio.config.reply_tx=mesh::RADIO_TX_BOTH; radio.config.reply_force=true;
|
||||
radio.config.secondary.mode=mesh::RadioProfileMode::Rx;
|
||||
node.cross_filter_allows=false;
|
||||
auto* reply=node.replyOn(0,radio.config.generation[0]); ASSERT_NE(nullptr,reply);
|
||||
ASSERT_TRUE(node.sendPacket(reply,0)); ASSERT_EQ(1,manager.getOutboundTotal());
|
||||
EXPECT_EQ(0,manager.getOutboundByIdx(0)->radio_profile);
|
||||
node.releasePacket(manager.removeOutboundByIdx(0));
|
||||
node.cross_filter_allows=true;
|
||||
reply=node.replyOn(1,radio.config.generation[1]); ASSERT_NE(nullptr,reply);
|
||||
ASSERT_TRUE(node.sendPacket(reply,0)); ASSERT_EQ(2,manager.getOutboundTotal());
|
||||
radio.config.setSecondary({},false);
|
||||
for (int i=0;i<6;++i) { radio.complete=true; tick(); }
|
||||
EXPECT_TRUE(radio.transmissions.empty()); // both originated in an expired session
|
||||
EXPECT_EQ(40,manager.getFreeCount());
|
||||
auto* stale=node.replyOn(1,radio.config.generation[1]-1); ASSERT_NE(nullptr,stale);
|
||||
EXPECT_FALSE(node.sendPacket(stale,0));
|
||||
EXPECT_EQ(40,manager.getFreeCount());
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, ReplyFlagsAreLocalAndResetOnReadAndPoolReuse) {
|
||||
auto packet=makeFloodPacket(PAYLOAD_TYPE_RESPONSE);
|
||||
uint8_t before[MAX_TRANS_UNIT],after[MAX_TRANS_UNIT];
|
||||
const auto size=packet.writeTo(before);
|
||||
packet.radio_reply=packet.radio_reply_force=true;
|
||||
packet.tx_radio=mesh::RADIO_TX_BOTH;
|
||||
EXPECT_EQ(size,packet.writeTo(after)); EXPECT_EQ(0,memcmp(before,after,size));
|
||||
EXPECT_TRUE(packet.readFrom(before,size));
|
||||
EXPECT_FALSE(packet.radio_reply); EXPECT_FALSE(packet.radio_reply_force);
|
||||
EXPECT_EQ(mesh::RADIO_TX_AUTO,packet.tx_radio);
|
||||
for (int i=0;i<80;++i) {
|
||||
auto* p=node.obtainNewPacket(); ASSERT_NE(nullptr,p);
|
||||
EXPECT_FALSE(p->radio_reply); EXPECT_FALSE(p->radio_reply_force);
|
||||
p->radio_reply=p->radio_reply_force=true; node.releasePacket(p);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, ReplyAirtimeIncludesForcedRxProfileBeforeAdmission) {
|
||||
radio.distinguish_airtime=true;
|
||||
radio.config.secondary.mode=mesh::RadioProfileMode::Rx;
|
||||
radio.config.reply_tx=mesh::RADIO_TX_BOTH;
|
||||
auto* reply=node.replyOn(0,radio.config.generation[0]); ASSERT_NE(nullptr,reply);
|
||||
EXPECT_EQ(900U,node.getTransmitAirtime(reply));
|
||||
radio.config.reply_force=true;
|
||||
EXPECT_EQ(970U,node.getTransmitAirtime(reply));
|
||||
radio.config.reply_tx=mesh::RADIO_TX_SECONDARY;
|
||||
EXPECT_EQ(70U,node.getTransmitAirtime(reply));
|
||||
radio.config.reply_tx=mesh::RADIO_TX_OFF;
|
||||
EXPECT_EQ(0U,node.getTransmitAirtime(reply));
|
||||
node.releasePacket(reply);
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, ExplicitBothSurvivesCrossOffButDisablingSecondaryRetiresItsCopy) {
|
||||
node.flood_attempts = 0;
|
||||
radio.config.cross = mesh::RadioCrossMode::Off;
|
||||
auto* packet = node.obtainNewPacket(); ASSERT_NE(nullptr, packet);
|
||||
*packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT); packet->tx_radio = mesh::RADIO_TX_BOTH;
|
||||
ASSERT_TRUE(node.sendFlood(packet));
|
||||
ASSERT_EQ(2, manager.getOutboundTotal());
|
||||
auto* copy = manager.getOutboundByIdx(1);
|
||||
EXPECT_TRUE(node.isPacketRadioCurrent(copy));
|
||||
radio.config.secondary.mode = mesh::RadioProfileMode::Rx;
|
||||
EXPECT_FALSE(node.isPacketRadioCurrent(copy));
|
||||
for (int i = 0; i < 5; ++i) { radio.complete = true; tick(); }
|
||||
EXPECT_EQ((std::vector<uint8_t>{0}), radio.transmissions);
|
||||
EXPECT_EQ(40, manager.getFreeCount());
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, TransmitPolicyIsLocalAndPoolReuseResetsIt) {
|
||||
auto* packet = node.obtainNewPacket(); ASSERT_NE(nullptr, packet);
|
||||
*packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
|
||||
uint8_t before[MAX_TRANS_UNIT], after[MAX_TRANS_UNIT];
|
||||
const auto length = packet->writeTo(before);
|
||||
packet->tx_radio = mesh::RADIO_TX_OFF;
|
||||
EXPECT_EQ(length, packet->writeTo(after));
|
||||
EXPECT_EQ(0, memcmp(before, after, length));
|
||||
node.releasePacket(packet);
|
||||
for (int i = 0; i < 40; ++i) {
|
||||
auto* reused = node.obtainNewPacket(); ASSERT_NE(nullptr, reused);
|
||||
EXPECT_EQ(mesh::RADIO_TX_AUTO, reused->tx_radio);
|
||||
reused->tx_radio = mesh::RADIO_TX_SECONDARY;
|
||||
node.releasePacket(reused);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, MessageTimeoutAirtimeUsesSelectedProfilesInsteadOfScanPosition) {
|
||||
radio.distinguish_airtime = true;
|
||||
radio.config.cross = mesh::RadioCrossMode::Off;
|
||||
auto* packet = node.obtainNewPacket(); ASSERT_NE(nullptr, packet);
|
||||
*packet = makeFloodPacket(PAYLOAD_TYPE_TXT_MSG);
|
||||
for (uint8_t scan : {0, 1}) {
|
||||
radio.selected = scan;
|
||||
packet->tx_radio = mesh::RADIO_TX_SECONDARY;
|
||||
EXPECT_EQ(70U, node.getTransmitAirtime(packet));
|
||||
packet->tx_radio = mesh::RADIO_TX_PRIMARY;
|
||||
EXPECT_EQ(900U, node.getTransmitAirtime(packet));
|
||||
packet->tx_radio = mesh::RADIO_TX_BOTH;
|
||||
EXPECT_EQ(970U, node.getTransmitAirtime(packet));
|
||||
packet->tx_radio = mesh::RADIO_TX_AUTO;
|
||||
EXPECT_EQ(900U, node.getTransmitAirtime(packet));
|
||||
}
|
||||
node.releasePacket(packet);
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, DefaultSeparatesNormalTrafficFromTemporaryOta) {
|
||||
radio.config.secondary_temporary = true;
|
||||
ASSERT_NE(nullptr, queue());
|
||||
@@ -165,6 +469,19 @@ TEST_F(DualProfileTest, DefaultSeparatesNormalTrafficFromTemporaryOta) {
|
||||
EXPECT_EQ(1, manager.getOutboundByIdx(1)->radio_profile);
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, TemporaryActivityIncludesEitherProfileAndEndsAfterBothExpire) {
|
||||
for (bool primary : {false,true}) {
|
||||
for (bool secondary : {false,true}) {
|
||||
node.tempRadioActive=primary;
|
||||
radio.config.secondary_temporary=secondary;
|
||||
EXPECT_EQ(primary || secondary,node.isAnyTempRadioActive());
|
||||
}
|
||||
}
|
||||
node.tempRadioActive=false;
|
||||
radio.config.setSecondary({},false);
|
||||
EXPECT_FALSE(node.isAnyTempRadioActive());
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, ReceiveOnlyNeverTransmitsSecondary) {
|
||||
radio.config.secondary.mode = mesh::RadioProfileMode::Rx;
|
||||
ASSERT_NE(nullptr, queue());
|
||||
@@ -172,6 +489,36 @@ TEST_F(DualProfileTest, ReceiveOnlyNeverTransmitsSecondary) {
|
||||
tick(); EXPECT_EQ((std::vector<uint8_t>{0}), radio.transmissions);
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, AsyncOtaResponseAdmissionMaskMatchesTheActualQueuedCopies) {
|
||||
radio.config.cross=mesh::RadioCrossMode::Off;
|
||||
radio.config.secondary_temporary=true;
|
||||
radio.config.reply_tx=mesh::RADIO_TX_BOTH;
|
||||
for (auto mode : {mesh::RadioProfileMode::Rx,mesh::RadioProfileMode::RxTx}) {
|
||||
radio.config.secondary.mode=mode;
|
||||
for (bool force : {false,true}) {
|
||||
radio.config.reply_force=force;
|
||||
for (uint8_t type : {mesh::ota::OTA_REQ,mesh::ota::OTA_DATA,mesh::ota::OTA_PROOF,
|
||||
mesh::ota::OTA_HAVE,mesh::ota::OTA_QUERY}) {
|
||||
auto* p=node.obtainNewPacket(); ASSERT_NE(nullptr,p);
|
||||
*p=makeFloodPacket(PAYLOAD_TYPE_OTA); p->payload[0]=type;
|
||||
p->radio_reply=mesh::ota::ota_is_response_message(type);
|
||||
ASSERT_TRUE(p->radio_local); // no RX call stack
|
||||
const uint8_t mask=node.getTransmitProfileMask(p);
|
||||
EXPECT_EQ(p->radio_reply ? ((force || mode==mesh::RadioProfileMode::RxTx) ? 3 : 1)
|
||||
: (mode==mesh::RadioProfileMode::RxTx ? 2 : 0),mask);
|
||||
ASSERT_EQ(mask!=0,node.sendPacket(p,0));
|
||||
uint8_t actual=0;
|
||||
while (manager.getOutboundTotal()) {
|
||||
auto* queued=manager.removeOutboundByIdx(0);
|
||||
actual |= 1U << queued->radio_profile;
|
||||
node.releasePacket(queued);
|
||||
}
|
||||
EXPECT_EQ(mask,actual); EXPECT_EQ(40,manager.getFreeCount());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, ReceiveOnlyTemporaryOtaDoesNotLeakOntoNormalChannel) {
|
||||
radio.config.secondary_temporary = true;
|
||||
radio.config.secondary.mode = mesh::RadioProfileMode::Rx;
|
||||
|
||||
@@ -14,6 +14,8 @@ class File {
|
||||
File(MemoryFS* fs, const char* path) : fs_(fs), path_(path) {}
|
||||
operator bool() const { return fs_ != nullptr; }
|
||||
size_t size() const;
|
||||
size_t available() const { return size() - cursor_; }
|
||||
bool isDirectory() const { return false; }
|
||||
int read(uint8_t* data, size_t size);
|
||||
size_t write(const uint8_t* data, size_t size);
|
||||
void flush() {}
|
||||
@@ -24,6 +26,7 @@ class MemoryFS {
|
||||
std::map<std::string, std::vector<uint8_t>> files;
|
||||
bool fail_write = false;
|
||||
int fail_rename = 0;
|
||||
bool mkdir(const char*) { return true; }
|
||||
bool exists(const char* path) const { return files.count(path); }
|
||||
bool remove(const char* path) { return files.erase(path); }
|
||||
bool rename(const char* from, const char* to) {
|
||||
@@ -31,7 +34,7 @@ class MemoryFS {
|
||||
if (!exists(from) || exists(to)) return false;
|
||||
files[to] = files[from]; files.erase(from); return true;
|
||||
}
|
||||
File open(const char* path, const char* mode) {
|
||||
File open(const char* path, const char* mode = "r", bool = false) {
|
||||
if (*mode == 'w') files[path].clear();
|
||||
if (!exists(path)) return {};
|
||||
return File(this, path);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Compile real Companion TX-route CLI and channel persistence; no radio required."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from test_radio_receive_contract import method
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
HARNESS = r'''
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
#include <RadioProfiles.h>
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <helpers/CompanionTxRoutingCLI.h>
|
||||
#include <helpers/IdentityStore.h>
|
||||
#define MAX_GROUP_CHANNELS 4
|
||||
#define MAX_ANON_CONTACTS 1
|
||||
#define ADV_TYPE_NONE 0
|
||||
namespace mesh {
|
||||
struct Identity { uint8_t pub_key[32] = {}; };
|
||||
struct GroupChannel { uint8_t secret[32] = {}, hash[1] = {}, tx_radio = RADIO_TX_AUTO; };
|
||||
struct Utils {
|
||||
static void toHex(char* out, const uint8_t* in, size_t n) {
|
||||
for (size_t i=0;i<n;++i) sprintf(out+2*i,"%02X",in[i]);
|
||||
}
|
||||
};
|
||||
}
|
||||
struct ContactInfo { mesh::Identity id; char name[32] = {}; uint8_t type=1, tx_radio=0; };
|
||||
struct ChannelDetails { mesh::GroupChannel channel; char name[32] = {}; };
|
||||
struct DataStoreHost {
|
||||
virtual bool onChannelLoaded(uint8_t, const ChannelDetails&)=0;
|
||||
virtual bool getChannelForSave(uint8_t, ChannelDetails&)=0;
|
||||
};
|
||||
struct Radio { mesh::RadioProfiles config; mesh::RadioProfiles* profiles() { return &config; } };
|
||||
struct DataStore {
|
||||
FILESYSTEM fs;
|
||||
FILESYSTEM* _getContactsChannelsFS() { return &fs; }
|
||||
File openRead(FILESYSTEM* fs, const char* name) { return fs->open(name,"r"); }
|
||||
void loadChannels(DataStoreHost*);
|
||||
bool saveChannels(DataStoreHost*);
|
||||
};
|
||||
File openWrite(FILESYSTEM* fs, const char* name) { return fs->open(name,"w"); }
|
||||
class MyMesh : public DataStoreHost {
|
||||
public:
|
||||
std::vector<ContactInfo> contacts{4};
|
||||
ChannelDetails channels[MAX_GROUP_CHANNELS];
|
||||
Radio radio; Radio* _radio=&radio;
|
||||
DataStore store;
|
||||
bool writable=true, writes_ok=true; unsigned writes=0;
|
||||
bool canMutateContacts() const { return writable; }
|
||||
int getTotalContactSlots() const { return contacts.size(); }
|
||||
ContactInfo* getContactPtrByIdx(int i) { return &contacts.at(i); }
|
||||
bool getChannel(int i, ChannelDetails& c) { if (i<0 || i>=MAX_GROUP_CHANNELS) return false; c=channels[i]; return true; }
|
||||
bool setChannel(int i, const ChannelDetails& c) { if (i<0 || i>=MAX_GROUP_CHANNELS) return false; channels[i]=c; return true; }
|
||||
bool saveChannels() { ++writes; return writes_ok && store.saveChannels(this); }
|
||||
bool scheduleContactWrite(const ContactInfo&) { ++writes; return writes_ok; }
|
||||
bool flushContactsBeforeReboot() { return writes_ok; }
|
||||
bool onChannelLoaded(uint8_t i,const ChannelDetails& c) override { return setChannel(i,c); }
|
||||
bool getChannelForSave(uint8_t i,ChannelDetails& c) override { return getChannel(i,c); }
|
||||
bool handleTxRoutingCommand(const char*,char*,size_t);
|
||||
};
|
||||
@METHODS@
|
||||
|
||||
int main() {
|
||||
MyMesh node;
|
||||
strcpy(node.contacts[1].name,"Alice Jones"); strcpy(node.contacts[2].name,"Bob");
|
||||
strcpy(node.contacts[3].name,"Bob");
|
||||
for (int i=1;i<4;++i) { memset(node.contacts[i].id.pub_key,0x11,32); node.contacts[i].id.pub_key[31]=i; }
|
||||
strcpy(node.channels[0].name,"Public"); strcpy(node.channels[1].name,"#wardriving");
|
||||
node.channels[1].channel.secret[0]=17;
|
||||
node.radio.config.secondary.mode=mesh::RadioProfileMode::RxTx;
|
||||
node.radio.config.cross=mesh::RadioCrossMode::Off;
|
||||
node.radio.config.secondary_temporary=true;
|
||||
char reply[160];
|
||||
auto cmd=[&](const char* text) { assert(node.handleTxRoutingCommand(text,reply,sizeof(reply))); };
|
||||
cmd("set tx.user \"Alice Jones\" radio2"); assert(!strncmp(reply,"OK",2));
|
||||
assert(strstr(reply,"active=radio2") && node.contacts[1].tx_radio==mesh::RADIO_TX_SECONDARY);
|
||||
assert(node.contacts[2].tx_radio==mesh::RADIO_TX_AUTO);
|
||||
cmd("get tx.user Alice Jones"); assert(strstr(reply,"radio2"));
|
||||
cmd("set tx.user Bob off"); assert(strstr(reply,"ambiguous"));
|
||||
cmd("set tx.user key:111111111111 off"); assert(strstr(reply,"ambiguous"));
|
||||
char key[65], command[140]; mesh::Utils::toHex(key,node.contacts[2].id.pub_key,32);
|
||||
snprintf(command,sizeof(command),"set tx.user key:%s off",key); cmd(command);
|
||||
assert(!strncmp(reply,"OK",2) && node.contacts[2].tx_radio==mesh::RADIO_TX_OFF);
|
||||
assert(node.contacts[3].tx_radio==mesh::RADIO_TX_AUTO);
|
||||
// Quoting selects a literal name, even when it resembles a slot or key.
|
||||
strcpy(node.channels[2].name,"1");
|
||||
cmd("set tx.channel \"1\" off"); assert(!strncmp(reply,"OK",2));
|
||||
assert(node.channels[2].channel.tx_radio==mesh::RADIO_TX_OFF);
|
||||
assert(node.channels[1].channel.tx_radio==mesh::RADIO_TX_AUTO);
|
||||
cmd("set tx.channel 1 radio"); assert(!strncmp(reply,"OK",2));
|
||||
assert(node.channels[1].channel.tx_radio==mesh::RADIO_TX_PRIMARY);
|
||||
cmd("set tx.channel \"1\" auto");
|
||||
node.channels[1].channel.tx_radio=mesh::RADIO_TX_AUTO;
|
||||
strcpy(node.contacts[3].name,"key:111111111111");
|
||||
cmd("set tx.user \"key:111111111111\" both"); assert(!strncmp(reply,"OK",2));
|
||||
assert(node.contacts[3].tx_radio==mesh::RADIO_TX_BOTH);
|
||||
cmd("set tx.user key:111111111111 off"); assert(strstr(reply,"ambiguous"));
|
||||
node.contacts[3].tx_radio=mesh::RADIO_TX_AUTO;
|
||||
strcpy(node.contacts[3].name,"Bob");
|
||||
for (auto text : {"set tx.user Alice Jones", "set tx.user Alice Jones radio3", "set tx.user key:12 radio", "set tx.user \"Alice radio"}) {
|
||||
cmd(text); assert(!strncmp(reply,"Error",5));
|
||||
}
|
||||
assert(!node.handleTxRoutingCommand("get tx.username",reply,sizeof(reply)));
|
||||
cmd("set tx.channel 0 both"); assert(strstr(reply,"active=both"));
|
||||
cmd("set tx.channel #wardriving radio2"); assert(strstr(reply,"active=radio2"));
|
||||
auto disk=node.store.fs.files["/channels2"];
|
||||
assert(disk.size()==MAX_GROUP_CHANNELS*68);
|
||||
assert(disk[0]==mesh::encodeRadioTxPolicy(mesh::RADIO_TX_BOTH));
|
||||
assert(disk[68]==mesh::encodeRadioTxPolicy(mesh::RADIO_TX_SECONDARY));
|
||||
node.channels[0].channel.tx_radio=node.channels[1].channel.tx_radio=0;
|
||||
node.store.loadChannels(&node);
|
||||
assert(node.channels[0].channel.tx_radio==mesh::RADIO_TX_BOTH);
|
||||
assert(node.channels[1].channel.tx_radio==mesh::RADIO_TX_SECONDARY);
|
||||
node.radio.config.secondary.mode=mesh::RadioProfileMode::Rx;
|
||||
cmd("get tx.channel 1"); assert(strstr(reply,"active=off") && strstr(reply,"unavailable"));
|
||||
cmd("get tx.channel 0"); assert(strstr(reply,"active=radio "));
|
||||
node.writes_ok=false;
|
||||
cmd("set tx.channel 0 off"); assert(strstr(reply,"not saved"));
|
||||
assert(node.channels[0].channel.tx_radio==mesh::RADIO_TX_BOTH && node.store.fs.files["/channels2"]==disk);
|
||||
cmd("set tx.user Alice Jones both"); assert(strstr(reply,"not saved"));
|
||||
assert(node.contacts[1].tx_radio==mesh::RADIO_TX_SECONDARY);
|
||||
node.writes_ok=true; node.writable=false;
|
||||
cmd("set tx.channel 0 auto"); assert(strstr(reply,"storage unavailable"));
|
||||
node.writable=true;
|
||||
cmd("set tx.channel 0 auto"); assert(!strncmp(reply,"OK",2));
|
||||
cmd("get tx.channel"); assert(strstr(reply,"1 overrides"));
|
||||
for (unsigned i=0;i<256;++i) {
|
||||
auto policy=mesh::decodeRadioTxPolicy(i);
|
||||
assert(policy==((i>=0xa1 && i<=0xa4) ? (i&7) : 0));
|
||||
}
|
||||
for (auto& byte : node.store.fs.files["/channels2"]) byte=0;
|
||||
node.store.loadChannels(&node);
|
||||
for (auto& c : node.channels) assert(c.channel.tx_radio==mesh::RADIO_TX_AUTO);
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class CompanionTxRoutingTest(unittest.TestCase):
|
||||
def test_cli_resolution_persistence_and_failures(self):
|
||||
companion = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
|
||||
store = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text()
|
||||
methods = method(companion, 'bool MyMesh::handleTxRoutingCommand(')
|
||||
methods += '\n' + method(store, 'void DataStore::loadChannels(')
|
||||
methods += '\n' + method(store, 'bool DataStore::saveChannels(')
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
work = Path(directory)
|
||||
(work / 'test.cpp').write_text(HARNESS.replace('@METHODS@', methods))
|
||||
build = subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra',
|
||||
'-fsanitize=address,undefined', '-fno-pie', '-no-pie',
|
||||
'-I', str(ROOT / 'test/fixtures/radio_profiles/mocks'),
|
||||
'-I', str(ROOT / 'test/mocks'), '-I', str(ROOT / 'src'),
|
||||
str(work / 'test.cpp'), '-o', str(work / 'test')], capture_output=True, text=True)
|
||||
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
|
||||
run = subprocess.run([str(work / 'test')], capture_output=True, text=True)
|
||||
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
|
||||
|
||||
def test_phone_protocol_updates_preserve_same_channel_policy(self):
|
||||
source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
|
||||
command = source[source.index('const bool have_previous = getChannel(channel_idx, previous);'):]
|
||||
command = command[:command.index('} else if (cmd_frame[0] == CMD_SIGN_START')]
|
||||
self.assertIn('!memcmp(channel.channel.secret, previous.channel.secret, sizeof(channel.channel.secret))', command)
|
||||
self.assertIn('channel.channel.tx_radio = previous.channel.tx_radio;', command)
|
||||
self.assertIn('setChannel(channel_idx, previous);', command)
|
||||
self.assertIn('handleTxRoutingCommand(command, reply, reply_size)',
|
||||
method(source, 'bool MyMesh::handleLocalControlCommand('))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -23,6 +23,10 @@ public:
|
||||
};
|
||||
class Radio : public mesh::Radio {
|
||||
public:
|
||||
bool dual = false;
|
||||
mesh::RadioProfiles config;
|
||||
mesh::RadioProfiles* profiles() override { return dual ? &config : nullptr; }
|
||||
const mesh::RadioProfiles* profiles() const override { return dual ? &config : nullptr; }
|
||||
int recvRaw(uint8_t*, int) override { return 0; }
|
||||
uint32_t getEstAirtimeFor(int) override { return 10; }
|
||||
float packetScore(float, int) override { return 0; }
|
||||
@@ -40,6 +44,15 @@ public:
|
||||
};
|
||||
class Chat : public BaseChatMesh {
|
||||
public:
|
||||
uint8_t getFloodRetryMaxAttempts(const mesh::Packet*) const override { return 0; }
|
||||
uint8_t getDirectRetryMaxAttempts(const mesh::Packet*) const override { return 0; }
|
||||
void receivedTextFromFirstContact(mesh::Packet& packet) {
|
||||
uint8_t hash[PATH_HASH_SIZE]; getContactPtrByIdx(MAX_ANON_CONTACTS)->id.copyHashTo(hash);
|
||||
ASSERT_GT(searchPeersByHash(hash), 0);
|
||||
uint8_t secret[32] = {}, data[32] = {};
|
||||
data[0] = 10; memcpy(data + 5, "hello", 5);
|
||||
onPeerDataRecv(&packet, PAYLOAD_TYPE_TXT_MSG, 0, secret, data, 10);
|
||||
}
|
||||
bool auto_add = false;
|
||||
bool writable = true;
|
||||
uint8_t max_hops = 1;
|
||||
@@ -215,6 +228,84 @@ TEST_F(ContactImport, MalformedAndNonAdvertPacketsDoNotLeakAllocations) {
|
||||
EXPECT_FALSE(queue(packet));
|
||||
EXPECT_EQ(pool.getFreeCount(), 8);
|
||||
}
|
||||
|
||||
TEST_F(ContactImport, ContactMessagesUseFullIdentityAndPreservePolicyAcrossAdvertRefresh) {
|
||||
radio.dual = true; radio.config.secondary.mode = mesh::RadioProfileMode::RxTx;
|
||||
radio.config.cross = mesh::RadioCrossMode::Off;
|
||||
ContactInfo first, second;
|
||||
memset(first.id.pub_key, 0, PUB_KEY_SIZE); first.id.pub_key[0] = 17;
|
||||
first.type = ADV_TYPE_CHAT; first.tx_radio = mesh::RADIO_TX_SECONDARY;
|
||||
second = first; second.id.pub_key[31] = 9; second.tx_radio = mesh::RADIO_TX_PRIMARY;
|
||||
ASSERT_TRUE(chat.addContact(first)); ASSERT_TRUE(chat.addContact(second));
|
||||
uint32_t ack=0, timeout=0;
|
||||
EXPECT_EQ(MSG_SEND_SENT_DIRECT, chat.sendMessage(first, 1, 0, "one", ack, timeout));
|
||||
EXPECT_EQ(MSG_SEND_SENT_DIRECT, chat.sendMessage(second, 2, 0, "two", ack, timeout));
|
||||
ASSERT_EQ(2, pool.getOutboundTotal());
|
||||
EXPECT_EQ(1, pool.getOutboundByIdx(0)->radio_profile);
|
||||
EXPECT_EQ(0, pool.getOutboundByIdx(1)->radio_profile);
|
||||
auto refresh = card(17, 0, 20); chat.receive(refresh);
|
||||
EXPECT_EQ(mesh::RADIO_TX_SECONDARY, chat.getContactPtrByIdx(MAX_ANON_CONTACTS)->tx_radio);
|
||||
}
|
||||
|
||||
TEST_F(ContactImport, OffContactAndChannelReportFailureWithoutLeakingPackets) {
|
||||
ContactInfo contact;
|
||||
memset(contact.id.pub_key, 1, PUB_KEY_SIZE); contact.type = ADV_TYPE_CHAT;
|
||||
contact.tx_radio = mesh::RADIO_TX_OFF;
|
||||
ASSERT_TRUE(chat.addContact(contact));
|
||||
uint32_t ack=0, timeout=0, tag=0;
|
||||
const uint8_t data[] = {1};
|
||||
for (uint8_t path : {uint8_t(0), uint8_t(OUT_PATH_UNKNOWN)}) {
|
||||
contact.out_path_len = path;
|
||||
EXPECT_EQ(MSG_SEND_FAILED, chat.sendMessage(contact, 1, 0, "test", ack, timeout));
|
||||
EXPECT_EQ(MSG_SEND_FAILED, chat.sendCommandData(contact, 1, 0, TXT_TYPE_CLI_COMMAND, "test", timeout));
|
||||
EXPECT_EQ(MSG_SEND_FAILED, chat.sendLogin(contact, "", timeout));
|
||||
EXPECT_EQ(MSG_SEND_FAILED, chat.sendAnonReq(contact, data, sizeof(data), tag, timeout));
|
||||
EXPECT_EQ(MSG_SEND_FAILED, chat.sendRequest(contact, data, sizeof(data), tag, timeout));
|
||||
EXPECT_EQ(MSG_SEND_FAILED, chat.sendRequest(contact, uint8_t(1), tag, timeout));
|
||||
mesh::GroupChannel channel; memset(channel.secret, 0, sizeof(channel.secret));
|
||||
channel.hash[0] = 17; channel.tx_radio = mesh::RADIO_TX_OFF;
|
||||
EXPECT_FALSE(chat.sendGroupMessage(1, channel, "me", "test", 4));
|
||||
EXPECT_FALSE(chat.sendGroupData(channel, nullptr, path, 1, data, sizeof(data)));
|
||||
EXPECT_EQ(0, pool.getOutboundTotal()); EXPECT_EQ(8, pool.getFreeCount());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(ContactImport, SameHashChannelsKeepIndependentTransmitChoices) {
|
||||
radio.dual = true; radio.config.secondary.mode = mesh::RadioProfileMode::RxTx;
|
||||
radio.config.cross = mesh::RadioCrossMode::Off;
|
||||
mesh::GroupChannel a, b;
|
||||
memset(a.secret, 1, sizeof(a.secret)); memset(b.secret, 2, sizeof(b.secret));
|
||||
a.hash[0] = b.hash[0] = 17;
|
||||
a.tx_radio = mesh::RADIO_TX_PRIMARY; b.tx_radio = mesh::RADIO_TX_SECONDARY;
|
||||
ASSERT_TRUE(chat.sendGroupMessage(1, a, "me", "one", 3));
|
||||
ASSERT_TRUE(chat.sendGroupMessage(2, b, "me", "two", 3));
|
||||
const uint8_t data[] = {1};
|
||||
ASSERT_TRUE(chat.sendGroupData(b, nullptr, 0, 1, data, sizeof(data)));
|
||||
ASSERT_EQ(3, pool.getOutboundTotal());
|
||||
EXPECT_EQ(0, pool.getOutboundByIdx(0)->radio_profile);
|
||||
EXPECT_EQ(1, pool.getOutboundByIdx(1)->radio_profile);
|
||||
EXPECT_EQ(1, pool.getOutboundByIdx(2)->radio_profile);
|
||||
}
|
||||
|
||||
TEST_F(ContactImport, ContactRoutingAlsoControlsAcknowledgementsAndReturnedPaths) {
|
||||
radio.dual = true; radio.config.secondary.mode = mesh::RadioProfileMode::RxTx;
|
||||
radio.config.cross = mesh::RadioCrossMode::Off;
|
||||
ContactInfo contact; memset(contact.id.pub_key, 1, PUB_KEY_SIZE);
|
||||
contact.type = ADV_TYPE_CHAT; contact.tx_radio = mesh::RADIO_TX_SECONDARY;
|
||||
ASSERT_TRUE(chat.addContact(contact));
|
||||
// A same-key anonymous slot must not bypass the saved contact's choice.
|
||||
*chat.getContactPtrByIdx(0) = contact;
|
||||
chat.getContactPtrByIdx(0)->type = ADV_TYPE_NONE;
|
||||
chat.getContactPtrByIdx(0)->tx_radio = mesh::RADIO_TX_AUTO;
|
||||
mesh::Packet incoming;
|
||||
incoming.header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_TXT_MSG << PH_TYPE_SHIFT);
|
||||
chat.receivedTextFromFirstContact(incoming);
|
||||
incoming.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_TXT_MSG << PH_TYPE_SHIFT);
|
||||
chat.receivedTextFromFirstContact(incoming);
|
||||
ASSERT_EQ(2, pool.getOutboundTotal());
|
||||
EXPECT_EQ(1, pool.getOutboundByIdx(0)->radio_profile);
|
||||
EXPECT_EQ(1, pool.getOutboundByIdx(1)->radio_profile);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
|
||||
@@ -12,8 +12,9 @@ TEST(FloodFilterPersistence, DefaultRowsTrimTheInactiveTail) {
|
||||
FloodPersistenceRow rows[63] = {};
|
||||
rows[0].active = true;
|
||||
rows[1].active = true;
|
||||
rows[2].active = true;
|
||||
|
||||
EXPECT_EQ(2, FloodFilterPolicy::forwardPersistenceCount(
|
||||
EXPECT_EQ(3, FloodFilterPolicy::forwardPersistenceCount(
|
||||
rows, 63, false));
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ struct RadioLibWrapper {
|
||||
void serviceProfileScan();
|
||||
mesh::RadioParamApplyResult tuneProfile(uint8_t);
|
||||
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t);
|
||||
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t, bool);
|
||||
mesh::RadioParamApplyResult trySetParams(float,float,uint8_t,uint8_t,const uint32_t* = nullptr);
|
||||
mesh::RadioParamApplyResult trySetPrimaryParams(const mesh::RadioProfileParams&,bool,const uint32_t* = nullptr);
|
||||
void enable() {
|
||||
@@ -84,6 +85,25 @@ struct RadioLibWrapper {
|
||||
@METHODS@
|
||||
int main() {
|
||||
using Result=mesh::RadioParamApplyResult;
|
||||
{
|
||||
// Force only admits a reply on an active RX-only profile. It cannot
|
||||
// enable a disabled profile or erase an in-progress reception.
|
||||
RadioLibWrapper w; w.enable();
|
||||
w._profiles.secondary.mode=mesh::RadioProfileMode::Rx;
|
||||
assert(w.prepareTransmitProfile(1)==Result::FAILED);
|
||||
assert(w.prepareTransmitProfile(1,false)==Result::FAILED);
|
||||
w.packet=true;
|
||||
assert(w.prepareTransmitProfile(1,true)==Result::BUSY);
|
||||
w.packet=false; w.busy=true;
|
||||
assert(w.prepareTransmitProfile(1,true)==Result::BUSY);
|
||||
w.busy=false;
|
||||
assert(w.prepareTransmitProfile(1,true)==Result::APPLIED);
|
||||
assert(w._active_profile==1 && w._profiles.secondary.mode==mesh::RadioProfileMode::Rx);
|
||||
assert(w.prepareTransmitProfile(1)==Result::FAILED);
|
||||
w._profiles.setSecondary({},false);
|
||||
assert(w.prepareTransmitProfile(1,true)==Result::FAILED);
|
||||
assert(w.prepareTransmitProfile(2,true)==Result::FAILED);
|
||||
}
|
||||
{
|
||||
// Applying the tuple is not success if RX resume failed. Roll back the
|
||||
// profile and caches, and request recovery if rollback RX also fails.
|
||||
@@ -194,6 +214,8 @@ class ProfileScanTest(unittest.TestCase):
|
||||
('void','serviceProfileScan')]+[('mesh::RadioParamApplyResult',name) for name in
|
||||
['tuneProfile','prepareTransmitProfile','trySetParams','trySetPrimaryParams']]
|
||||
methods='\n'.join(method(source,f'{kind} RadioLibWrapper::{name}(') for kind,name in names)
|
||||
methods+='\n'+method(source,
|
||||
'mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t profile, bool')
|
||||
wrapper=(ROOT/'src/helpers/radiolib/CustomSX1262Wrapper.h').read_text()
|
||||
methods+='\n'+method(wrapper,'void setProfileStandbyWarm(').replace(
|
||||
'void setProfileStandbyWarm(bool enabled) override',
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Exercise delayed OTA response classification and infrastructure reply wiring."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_radio_receive_contract import method
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class ReplyTxIntegrationTest(unittest.TestCase):
|
||||
def test_async_ota_adapter_marks_only_response_types_and_retains_backpressure(self):
|
||||
source = (ROOT / 'src/Mesh.cpp').read_text()
|
||||
adapter = method(source, 'bool Mesh::otaSendAdapter(')
|
||||
paced = method(source, 'static bool isPacedOtaResponse(')
|
||||
harness = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <initializer_list>
|
||||
#include <helpers/ota/OtaFormat.h>
|
||||
namespace mesh {
|
||||
struct Packet { bool radio_reply=false; };
|
||||
struct Manager { int free=10; int getFreeCount() { return free; } };
|
||||
static int queued;
|
||||
static int queuedPacedOtaResponses(Manager*) { return queued; }
|
||||
static constexpr int OTA_EGRESS_QUEUE_CREDIT=@CREDIT@, OTA_EGRESS_MIN_FREE=@RESERVE@;
|
||||
@PACED@
|
||||
struct Mesh {
|
||||
Manager manager; Manager* _mgr=&manager;
|
||||
Packet packet;
|
||||
bool temporary=true, allocation=true, admit=true, was_reply=false;
|
||||
uint8_t mask=1;
|
||||
int allocations=0, sends=0;
|
||||
bool isAnyTempRadioActive() { return temporary; }
|
||||
Packet* createOtaPacket(const uint8_t*,uint16_t) {
|
||||
++allocations;
|
||||
// Deliberately opposite to expected for half the types: classification
|
||||
// must not accidentally depend on the receive call stack or a prior send.
|
||||
packet.radio_reply=!packet.radio_reply;
|
||||
if (!allocation || !manager.free) return nullptr;
|
||||
--manager.free;
|
||||
return &packet;
|
||||
}
|
||||
uint8_t getTransmitProfileMask(const Packet*) { return mask; }
|
||||
void releasePacket(Packet*) { ++manager.free; }
|
||||
bool sendOtaFlood(Packet* p) { ++sends; was_reply=p->radio_reply; releasePacket(p); return admit; }
|
||||
static bool otaSendAdapter(void*,const uint8_t*,uint16_t,bool);
|
||||
};
|
||||
@ADAPTER@
|
||||
}
|
||||
int main() {
|
||||
mesh::Mesh node;
|
||||
for (unsigned type=0;type<256;++type) {
|
||||
const uint8_t msg=type;
|
||||
bool reply=type==0x03 || type==0x05 || type==0x07 || type==0x09 || type==0x0b;
|
||||
assert(node.otaSendAdapter(&node,&msg,1,true));
|
||||
assert(node.was_reply==reply);
|
||||
}
|
||||
const uint8_t data=mesh::ota::OTA_DATA, proof=mesh::ota::OTA_PROOF;
|
||||
for (const uint8_t type : {data,proof}) {
|
||||
for (uint8_t mask : {1,2,3}) {
|
||||
for (int queued=0;queued<=mesh::OTA_EGRESS_QUEUE_CREDIT+1;++queued) {
|
||||
for (int free=0;free<=mesh::OTA_EGRESS_MIN_FREE+3;++free) {
|
||||
node.mask=mask; mesh::queued=queued; node.manager.free=free;
|
||||
const int copies=mask==3 ? 2 : 1;
|
||||
const bool allowed=queued+copies<=mesh::OTA_EGRESS_QUEUE_CREDIT
|
||||
&& free>=mesh::OTA_EGRESS_MIN_FREE+copies;
|
||||
const int allocations=node.allocations;
|
||||
assert(node.otaSendAdapter(&node,&type,1,true)==allowed);
|
||||
assert(node.manager.free==free); // failure releases, simulated successful TX drains
|
||||
if (queued>=mesh::OTA_EGRESS_QUEUE_CREDIT || free<=mesh::OTA_EGRESS_MIN_FREE)
|
||||
assert(node.allocations==allocations); // normal backpressure is not pool exhaustion
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
node.mask=0; node.manager.free=10; mesh::queued=0;
|
||||
assert(!node.otaSendAdapter(&node,&data,1,true));
|
||||
assert(node.manager.free==10);
|
||||
node.mask=3;
|
||||
int allocated=node.allocations;
|
||||
node.manager.free=10; node.temporary=false;
|
||||
assert(!node.otaSendAdapter(&node,&data,1,true));
|
||||
assert(node.allocations==allocated);
|
||||
node.temporary=true; node.allocation=false;
|
||||
int sends=node.sends;
|
||||
assert(!node.otaSendAdapter(&node,&data,1,true)); assert(node.sends==sends);
|
||||
node.allocation=true; node.admit=false;
|
||||
assert(!node.otaSendAdapter(&node,&data,1,true));
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
cpp = Path(folder) / 'reply.cpp'
|
||||
exe = Path(folder) / 'reply'
|
||||
header = (ROOT / 'src/Mesh.h').read_text()
|
||||
credit = re.search(r'#define OTA_EGRESS_QUEUE_CREDIT (\d+)', header).group(1)
|
||||
reserve = re.search(r'#define OTA_EGRESS_MIN_FREE (\d+)', header).group(1)
|
||||
cpp.write_text(harness.replace('@ADAPTER@', adapter).replace('@PACED@', paced)
|
||||
.replace('@CREDIT@', credit).replace('@RESERVE@', reserve))
|
||||
result = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17',
|
||||
'-Wall', '-Wextra', '-I', str(ROOT / 'src'), str(cpp), '-o', str(exe)],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
subprocess.run([str(exe)], check=True)
|
||||
|
||||
def test_all_infrastructure_roles_enable_replies_and_mark_delayed_completions(self):
|
||||
common = (ROOT / 'src/helpers/CommonCLI.cpp').read_text()
|
||||
self.assertIn('_radio_profiles.begin(fs, _callbacks->getProfileRadio(), _rtc, true);', common)
|
||||
for path in ['examples/simple_repeater/MyMesh.cpp',
|
||||
'examples/simple_room_server/MyMesh.cpp',
|
||||
'examples/simple_sensor/SensorMesh.cpp']:
|
||||
with self.subTest(path=path):
|
||||
text = (ROOT / path).read_text()
|
||||
self.assertIn('_cli.loadPrefs(', text)
|
||||
self.assertIn('packet->radio_reply = true;', text)
|
||||
room = (ROOT / 'examples/simple_room_server/MyMesh.cpp').read_text()
|
||||
self.assertIn('reply->radio_reply = true; // asynchronous response to the room subscription', room)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -34,8 +34,8 @@ TEST(TempRadioReplyBarrier, UntrackedAlternateCannotResolveAuthoritativeReply) {
|
||||
|
||||
barrier.arm(&primary_reply);
|
||||
|
||||
// TempRadio mutation acknowledgements deliberately queue only the primary
|
||||
// copy, without an alternate-path copy or a transport retry. If an
|
||||
// TempRadio mutation acknowledgements omit alternate-path copies and
|
||||
// transport retries. If an
|
||||
// obsolete/foreign packet callback arrives anyway, it must not release or
|
||||
// fail the authoritative handoff.
|
||||
EXPECT_FALSE(barrier.complete(&alternate_reply));
|
||||
@@ -60,6 +60,41 @@ TEST(TempRadioReplyBarrier, ClearCancelsAnObsoleteHandoff) {
|
||||
EXPECT_TRUE(barrier.complete(&new_reply));
|
||||
}
|
||||
|
||||
TEST(TempRadioReplyBarrier, BothProfilesDrainAndEitherSuccessPermitsHandoff) {
|
||||
for (int successes=0;successes<4;++successes) {
|
||||
for (bool reversed : {false,true}) {
|
||||
mesh::TempRadioReplyBarrier barrier;
|
||||
int a=1,b=2,unrelated=3;
|
||||
barrier.prepare(&a);
|
||||
EXPECT_FALSE(barrier.fail(&a)); // admission can retire an old retry
|
||||
barrier.trackCopy(&unrelated,&unrelated);
|
||||
barrier.trackCopy(&a,&b);
|
||||
barrier.arm(&a);
|
||||
const void* first = reversed ? &b : &a;
|
||||
const void* second = reversed ? &a : &b;
|
||||
if (successes & 1) EXPECT_TRUE(barrier.complete(first));
|
||||
else EXPECT_FALSE(barrier.fail(first));
|
||||
EXPECT_TRUE(barrier.waiting());
|
||||
EXPECT_FALSE(barrier.complete(&unrelated));
|
||||
if (successes & 2) EXPECT_TRUE(barrier.complete(second));
|
||||
else EXPECT_EQ(successes==0,barrier.fail(second));
|
||||
EXPECT_FALSE(barrier.waiting());
|
||||
EXPECT_FALSE(barrier.fail(first));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(TempRadioReplyBarrier, CancelDiscardsBothProfilesAndPointerReuse) {
|
||||
mesh::TempRadioReplyBarrier barrier;
|
||||
int a=1,b=2;
|
||||
barrier.prepare(&a); barrier.trackCopy(&a,&b); barrier.arm(&a);
|
||||
barrier.clear();
|
||||
EXPECT_FALSE(barrier.complete(&a)); EXPECT_FALSE(barrier.fail(&b));
|
||||
barrier.arm(&b);
|
||||
EXPECT_FALSE(barrier.complete(&a));
|
||||
EXPECT_TRUE(barrier.fail(&b));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static integration contract for TempRadio's single-copy reply barrier."""
|
||||
"""Integration contract for tracked TempRadio replies on both profiles."""
|
||||
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
@@ -33,6 +33,11 @@ class TempRadioReplyDeliveryContractTest(unittest.TestCase):
|
||||
"const bool allow_redundant_copies = queued_packet == NULL;", reply
|
||||
)
|
||||
self.assertIn("fallback_scope,\n allow_redundant_copies", reply)
|
||||
self.assertLess(reply.index('temp_radio_reply_barrier.prepare(packet)'),
|
||||
reply.index('const bool queued = sendClientReplyWithFallbackScope'))
|
||||
self.assertIn('if (!queued && !allow_redundant_copies) temp_radio_reply_barrier.clear();', reply)
|
||||
self.assertIn('temp_radio_reply_barrier.trackCopy(original, packet);', source)
|
||||
self.assertIn('mesh::Mesh::onRadioProfileCopyQueued(packet, original, priority);', source)
|
||||
self.assertIn(
|
||||
"if (allow_redundant_copies\n"
|
||||
" && mesh::Packet::isValidPathLen(client->alt_path_len)",
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <helpers/FloodFilterPolicy.h>
|
||||
#include "../fixtures/radio_profiles/mocks/helpers/IdentityStore.h"
|
||||
|
||||
// Compile the production parser, authenticated matcher, ordering, rate handling
|
||||
// and atomic FPF7 reader/writer against an in-memory filesystem. Crypto and
|
||||
// packets are the real native-build implementations. These tests use no regions.
|
||||
#define MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE 1
|
||||
#include "../../examples/simple_room_server/FloodRuleEngine.cpp"
|
||||
RegionEntry* RegionMap::findByName(const char*) { return nullptr; }
|
||||
RegionEntry* RegionMap::findByNamePrefix(const char*) { return nullptr; }
|
||||
int RegionMap::getTransportKeysFor(const RegionEntry&, TransportKey[], int) { return 0; }
|
||||
uint16_t TransportKey::calcTransportCode(const mesh::Packet*) const { return 0; }
|
||||
bool TransportKey::isNull() const { return true; }
|
||||
bool RegionMap::is_name_char(uint8_t c) {
|
||||
return c == '-' || c == '$' || c == '#' || (c >= '0' && c <= '9') || c >= 'A';
|
||||
}
|
||||
|
||||
using namespace FloodFilterPolicy;
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
|
||||
TEST(TransportModes, StoragePreservesLegacyAndEverySupportedCombination) {
|
||||
for (uint8_t modes : {0, 2, 4, 6}) {
|
||||
bool active = false;
|
||||
uint8_t restored = 255;
|
||||
ASSERT_TRUE(decodeStoredRuleActive(encodeStoredRuleActive(true, modes), active, restored));
|
||||
EXPECT_TRUE(active);
|
||||
EXPECT_EQ(modes, restored);
|
||||
ASSERT_TRUE(decodeStoredRuleActive(encodeStoredRuleActive(false, modes), active, restored));
|
||||
EXPECT_FALSE(active);
|
||||
EXPECT_EQ(RULE_MODE_RADIO, restored);
|
||||
}
|
||||
for (uint8_t invalid : {2, 4, 6, 8, 9, 255}) {
|
||||
bool active; uint8_t modes;
|
||||
EXPECT_FALSE(decodeStoredRuleActive(invalid, active, modes));
|
||||
}
|
||||
}
|
||||
|
||||
TEST(TransportModes, SelectorsAreStrictAndCanonical) {
|
||||
for (const char* text : {"radio", "bridge", "cross", "bridge,cross", "CROSS,BRIDGE"}) {
|
||||
uint8_t modes;
|
||||
ASSERT_TRUE(parseRuleModes(text, modes));
|
||||
uint8_t roundtrip;
|
||||
ASSERT_TRUE(parseRuleModes(ruleModeName(modes), roundtrip));
|
||||
EXPECT_EQ(modes, roundtrip);
|
||||
}
|
||||
for (const char* text : {"", "all", "bridge,", ",cross", "bridge,bridge", "radio,cross", "cross,unknown"}) {
|
||||
uint8_t modes;
|
||||
EXPECT_FALSE(parseRuleModes(text, modes));
|
||||
}
|
||||
}
|
||||
|
||||
class TransportFilter : public testing::Test {
|
||||
protected:
|
||||
MemoryFS fs;
|
||||
FloodRuleEngine rules;
|
||||
char reply[160] = {};
|
||||
void SetUp() override { rules.begin(&fs, nullptr); }
|
||||
void command(const char* text) { ASSERT_TRUE(rules.handleCommand(text, reply)); }
|
||||
void install(const char* modes = "bridge,cross") {
|
||||
std::string cmd = "set flood.rule.3 type=any channel=#wardriving hops=all mode=";
|
||||
cmd += modes; cmd += " drop";
|
||||
command(cmd.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
|
||||
}
|
||||
mesh::Packet packet(const char* channel = "#wardriving", uint8_t type = PAYLOAD_TYPE_GRP_TXT,
|
||||
uint8_t route = ROUTE_TYPE_FLOOD, uint8_t hops = 0) {
|
||||
mesh::Packet p;
|
||||
p.header = (type << PH_TYPE_SHIFT) | route;
|
||||
p.setPathHashSizeAndCount(1, hops);
|
||||
memset(p.path, 0x12, hops);
|
||||
uint8_t key[PUB_KEY_SIZE] = {};
|
||||
mesh::Utils::sha256(key, CIPHER_KEY_SIZE, (const uint8_t*)channel, strlen(channel));
|
||||
mesh::Utils::sha256(p.payload, 1, key, CIPHER_KEY_SIZE);
|
||||
const uint8_t text[] = "wardrive: sample";
|
||||
p.payload_len = 1 + mesh::Utils::encryptThenMAC(key, p.payload + 1, text, sizeof(text));
|
||||
return p;
|
||||
}
|
||||
bool blocked(mesh::Packet& p, uint8_t mode, bool temporary = false) {
|
||||
auto mask = rules.evaluate(&p, temporary, true, nullptr, mode);
|
||||
return rules.shouldBlock(&p, mask, 100);
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(TransportFilter, OneRuleBlocksBothDirectionsAndKeepsRadioEligible) {
|
||||
install();
|
||||
for (uint8_t type : {PAYLOAD_TYPE_GRP_TXT, PAYLOAD_TYPE_GRP_DATA})
|
||||
for (uint8_t route : {ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD, ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT})
|
||||
for (uint8_t hops : {0, 4, 5, 63}) {
|
||||
auto p = packet("#wardriving", type, route, hops);
|
||||
EXPECT_TRUE(blocked(p, RULE_MODE_BRIDGE));
|
||||
EXPECT_TRUE(blocked(p, RULE_MODE_CROSS, true));
|
||||
EXPECT_FALSE(blocked(p, RULE_MODE_RADIO));
|
||||
}
|
||||
command("get flood.rule.3");
|
||||
EXPECT_NE(nullptr, strstr(reply, "mode=bridge,cross"));
|
||||
command("get flood.rule");
|
||||
EXPECT_NE(nullptr, strstr(reply, "~bridge,cross"));
|
||||
}
|
||||
|
||||
TEST_F(TransportFilter, TemporaryWindowSuspendsOtaRuleWithoutDisablingWardrivingRule) {
|
||||
install();
|
||||
auto ota = packet("#wardriving", PAYLOAD_TYPE_OTA);
|
||||
EXPECT_TRUE(blocked(ota, RULE_MODE_RADIO, false));
|
||||
EXPECT_FALSE(blocked(ota, RULE_MODE_RADIO, true));
|
||||
auto wardriving = packet();
|
||||
EXPECT_TRUE(blocked(wardriving, RULE_MODE_BRIDGE, true));
|
||||
EXPECT_TRUE(blocked(wardriving, RULE_MODE_CROSS, true));
|
||||
EXPECT_TRUE(blocked(ota, RULE_MODE_RADIO, false)); // normal rule resumes on expiry
|
||||
}
|
||||
|
||||
TEST_F(TransportFilter, AuthenticationRejectsHashCollisionAndBadMac) {
|
||||
install();
|
||||
auto wardriving = packet();
|
||||
mesh::Packet collision;
|
||||
bool found = false;
|
||||
for (unsigned i = 0; i < 10000; i++) {
|
||||
std::string name = "#different-" + std::to_string(i);
|
||||
collision = packet(name.c_str());
|
||||
if (collision.payload[0] == wardriving.payload[0]) { found = true; break; }
|
||||
}
|
||||
ASSERT_TRUE(found);
|
||||
EXPECT_FALSE(blocked(collision, RULE_MODE_BRIDGE));
|
||||
EXPECT_FALSE(blocked(collision, RULE_MODE_CROSS));
|
||||
wardriving.payload[1] ^= 1;
|
||||
EXPECT_FALSE(blocked(wardriving, RULE_MODE_BRIDGE));
|
||||
wardriving.payload_len = 1;
|
||||
EXPECT_FALSE(blocked(wardriving, RULE_MODE_CROSS));
|
||||
auto other_type = packet("#wardriving", PAYLOAD_TYPE_ADVERT);
|
||||
EXPECT_FALSE(blocked(other_type, RULE_MODE_CROSS));
|
||||
}
|
||||
|
||||
TEST_F(TransportFilter, RadioHopLimitAndStopCannotOverrideTransportDrop) {
|
||||
install();
|
||||
command("set flood.rule.2 type=any channel=#wardriving hops=5+ drop");
|
||||
auto near = packet(); auto far = packet("#wardriving", PAYLOAD_TYPE_GRP_TXT, ROUTE_TYPE_FLOOD, 5);
|
||||
EXPECT_FALSE(blocked(near, RULE_MODE_RADIO));
|
||||
EXPECT_TRUE(blocked(far, RULE_MODE_RADIO));
|
||||
command("set flood.rule.4 type=any priority=255 stop");
|
||||
EXPECT_FALSE(blocked(far, RULE_MODE_RADIO));
|
||||
EXPECT_TRUE(blocked(near, RULE_MODE_BRIDGE));
|
||||
EXPECT_TRUE(blocked(far, RULE_MODE_CROSS));
|
||||
command("set flood.rule.4 type=any mode=bridge priority=255 stop");
|
||||
EXPECT_FALSE(blocked(near, RULE_MODE_BRIDGE));
|
||||
EXPECT_TRUE(blocked(near, RULE_MODE_CROSS));
|
||||
}
|
||||
|
||||
TEST_F(TransportFilter, PersistenceDeletionAndSeparateModes) {
|
||||
install("cross,bridge");
|
||||
rules.begin(&fs, nullptr);
|
||||
auto p = packet();
|
||||
EXPECT_TRUE(blocked(p, RULE_MODE_BRIDGE));
|
||||
EXPECT_TRUE(blocked(p, RULE_MODE_CROSS));
|
||||
EXPECT_FALSE(blocked(p, RULE_MODE_RADIO));
|
||||
install("bridge");
|
||||
EXPECT_TRUE(blocked(p, RULE_MODE_BRIDGE));
|
||||
EXPECT_FALSE(blocked(p, RULE_MODE_CROSS));
|
||||
install("cross");
|
||||
EXPECT_FALSE(blocked(p, RULE_MODE_BRIDGE));
|
||||
EXPECT_TRUE(blocked(p, RULE_MODE_CROSS));
|
||||
command("del flood.rule.3");
|
||||
rules.begin(&fs, nullptr);
|
||||
EXPECT_FALSE(blocked(p, RULE_MODE_BRIDGE));
|
||||
EXPECT_FALSE(blocked(p, RULE_MODE_CROSS));
|
||||
}
|
||||
|
||||
TEST_F(TransportFilter, LegacyFpf6RemainsRadioOnlyAfterUpgrade) {
|
||||
// One old-format generic group-data deny, with no optional match/action fields.
|
||||
fs.files.clear();
|
||||
auto& old = fs.files["/flood_filter"];
|
||||
old = {'F', 'P', 'F', '6', 1, 1, PAYLOAD_TYPE_GRP_DATA, 0, 63, 0};
|
||||
old.resize(45, 0); // 32-byte scope name and three legacy boolean selectors
|
||||
rules.begin(&fs, nullptr);
|
||||
auto p = packet("#wardriving", PAYLOAD_TYPE_GRP_DATA);
|
||||
EXPECT_TRUE(blocked(p, RULE_MODE_RADIO));
|
||||
EXPECT_FALSE(blocked(p, RULE_MODE_BRIDGE));
|
||||
EXPECT_FALSE(blocked(p, RULE_MODE_CROSS));
|
||||
install(); // Saving a transport rule upgrades the same table to FPF7.
|
||||
rules.begin(&fs, nullptr);
|
||||
EXPECT_TRUE(blocked(p, RULE_MODE_RADIO));
|
||||
EXPECT_TRUE(blocked(p, RULE_MODE_BRIDGE));
|
||||
EXPECT_TRUE(blocked(p, RULE_MODE_CROSS));
|
||||
command("get flood.rule.1");
|
||||
EXPECT_NE(nullptr, strstr(reply, "mode=radio"));
|
||||
}
|
||||
|
||||
TEST_F(TransportFilter, InvalidModesAndActionsDoNotReplaceSavedRule) {
|
||||
install();
|
||||
const auto saved = fs.files;
|
||||
for (const char* tail : {"mode=bridge,", "mode=all", "mode=radio,cross",
|
||||
"mode=bridge mode=cross", "mode=cross retry", "mode=bridge scope=test", "mode=cross tx=slow"}) {
|
||||
std::string cmd = "set flood.rule.3 type=any "; cmd += tail; cmd += " drop";
|
||||
command(cmd.c_str()); EXPECT_EQ(0, strncmp(reply, "Err", 3)) << reply;
|
||||
EXPECT_EQ(saved, fs.files);
|
||||
}
|
||||
fs.fail_write = true;
|
||||
command("set flood.rule.3 type=any mode=radio stop");
|
||||
EXPECT_EQ(0, strncmp(reply, "Err", 3));
|
||||
auto p = packet();
|
||||
EXPECT_TRUE(blocked(p, RULE_MODE_CROSS));
|
||||
EXPECT_FALSE(blocked(p, RULE_MODE_RADIO));
|
||||
}
|
||||
|
||||
TEST_F(TransportFilter, CombinedModeSharesRateAndResetsAfterMinute) {
|
||||
command("set flood.rule.3 type=any mode=bridge,cross rate=1/min");
|
||||
ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
|
||||
auto p = packet();
|
||||
auto mask = rules.evaluate(&p, false, true, nullptr, RULE_MODE_BRIDGE);
|
||||
EXPECT_FALSE(rules.shouldBlock(&p, mask, 100));
|
||||
rules.commitRates(&p, mask, 100);
|
||||
mask = rules.evaluate(&p, false, true, nullptr, RULE_MODE_CROSS);
|
||||
EXPECT_TRUE(rules.shouldBlock(&p, mask, 101));
|
||||
EXPECT_FALSE(rules.shouldBlock(&p, mask, 60100));
|
||||
mask = rules.evaluate(&p, false, true, nullptr, RULE_MODE_RADIO);
|
||||
EXPECT_FALSE(rules.shouldBlock(&p, mask, 101));
|
||||
}
|
||||
|
||||
TEST_F(TransportFilter, ShorthandAndLongCommandsSaveIdenticalRules) {
|
||||
struct Pair { const char* full; const char* short_form; };
|
||||
for (const auto& pair : {
|
||||
Pair{"set flood.rule.3 type=any channel=#wardriving hops=all mode=bridge,cross drop",
|
||||
"set fr.3 * c=#wardriving m=bc d"},
|
||||
Pair{"set flood.rule.3 type=ota hops=all drop suspend=tempradio",
|
||||
"set fr.3 t=12 h=* d f=t"},
|
||||
Pair{"set flood.rule.3 type=grp_data hops=2-5 channel=#local in=none scope=test priority=100 stop tx=slow suspend=tempradio",
|
||||
"set fr.3 6 h=2-5 c=#local i=n s=test pri=100 s f=st"},
|
||||
Pair{"set flood.rule.3 type=any channel=hash:A7 prefix=1234,5678 in=scoped rate=3/min retry stop",
|
||||
"set fr.3 any c=hash:A7 p=1234,5678 i=s q=3 r s"}}) {
|
||||
command(pair.full); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
|
||||
const auto full = fs.files["/flood_filter"];
|
||||
command(pair.short_form); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
|
||||
EXPECT_EQ(full, fs.files["/flood_filter"]);
|
||||
// get fr.N emits a complete setter, with every non-default condition/action.
|
||||
command("get fr.3"); ASSERT_EQ(0, strncmp(reply, "set fr.3 ", 9)) << reply;
|
||||
const std::string paste = reply;
|
||||
command("del fr.3"); ASSERT_EQ(0, strncmp(reply, "OK", 2));
|
||||
command(paste.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
|
||||
EXPECT_EQ(full, fs.files["/flood_filter"]);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(TransportFilter, AliasesCanMixWithLongCommandsAndKeepStrictActions) {
|
||||
install();
|
||||
const auto full = fs.files["/flood_filter"];
|
||||
for (const char* modes : {"b,c", "c,b", "bc", "cb", "BC", "bridge,cross", "cross,bridge"}) {
|
||||
std::string cmd = "set flood.rule.3 t=any c=#wardriving h=all m=";
|
||||
cmd += modes; cmd += " d";
|
||||
command(cmd.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
|
||||
EXPECT_EQ(full, fs.files["/flood_filter"]);
|
||||
}
|
||||
command("get flood.rule.3");
|
||||
EXPECT_NE(nullptr, strstr(reply, "mode=bridge,cross"));
|
||||
command("get fr.3");
|
||||
EXPECT_STREQ("set fr.3 any m=bc c=#wardriving d", reply);
|
||||
auto p = packet();
|
||||
EXPECT_TRUE(blocked(p, RULE_MODE_CROSS));
|
||||
EXPECT_FALSE(blocked(p, RULE_MODE_RADIO));
|
||||
for (const char* tail : {"m=b mode=c d", "h=all hops=0 d", "s stop", "r retry",
|
||||
"d drop", "c=#local channel=#other d", "s=test scope=other", "m=radio,c d",
|
||||
"m=bc retry", "s=test d", "m=bc", "p=bl d", "r=unknown"}) {
|
||||
std::string cmd = "set fr.3 any "; cmd += tail;
|
||||
command(cmd.c_str()); EXPECT_EQ(0, strncmp(reply, "Err", 3)) << cmd << ": " << reply;
|
||||
EXPECT_EQ(full, fs.files["/flood_filter"]);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(TransportFilter, ShorthandAddReusesLongRuleAndDeletePersists) {
|
||||
install();
|
||||
const auto before = fs.files["/flood_filter"];
|
||||
command("set fr any c=#wardriving m=bc d");
|
||||
ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
|
||||
EXPECT_EQ(before, fs.files["/flood_filter"]);
|
||||
command("get fr"); EXPECT_NE(nullptr, strstr(reply, "~bridge,cross"));
|
||||
command("del fr.3");
|
||||
rules.begin(&fs, nullptr);
|
||||
command("get fr.3"); EXPECT_EQ(0, strncmp(reply, "Err", 3));
|
||||
for (const char* text : {"get fridge", "set frequency 910", "del frx.3", "get frx"})
|
||||
EXPECT_FALSE(rules.handleCommand(text, reply));
|
||||
}
|
||||
|
||||
TEST_F(TransportFilter, OversizedCompactReplyNeverReturnsPartialSetter) {
|
||||
// A valid short input can fit in 192 command bytes but exceed a 160-byte reply.
|
||||
const std::string channel = "#" + std::string(30, 'b'), name(30, 'a');
|
||||
std::string cmd = "set fr.3 any 10+ c=" + channel + " p=112233,445566,778899 i=s:" + name
|
||||
+ " s=" + name + " q=65534 pri=255 s f=str";
|
||||
ASSERT_LT(cmd.size() - strlen("set fr.3 "), 192U);
|
||||
command(cmd.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
|
||||
command("get fr.3");
|
||||
EXPECT_STREQ("Err - compact command exceeds reply size", reply);
|
||||
EXPECT_EQ(nullptr, strstr(reply, "set fr."));
|
||||
}
|
||||
|
||||
TEST_F(TransportFilter, PrivateKeyReferencesCopyLocallyWithoutDisclosingKey) {
|
||||
const std::string key(64, '1');
|
||||
command(("set fr.3 any c=" + key + " m=bc d").c_str());
|
||||
ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
|
||||
const auto saved = fs.files["/flood_filter"];
|
||||
command("get fr.3");
|
||||
const std::string paste = reply;
|
||||
ASSERT_NE(std::string::npos, paste.find("c=key:"));
|
||||
EXPECT_EQ(std::string::npos, paste.find(key));
|
||||
command(paste.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
|
||||
EXPECT_EQ(saved, fs.files["/flood_filter"]);
|
||||
std::string copy = paste; copy[7] = '4'; // set fr.4 ...
|
||||
command(copy.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
|
||||
command("del fr.3");
|
||||
command(paste.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
|
||||
command("del fr all");
|
||||
const auto empty = fs.files["/flood_filter"];
|
||||
command(paste.c_str()); EXPECT_EQ(0, strncmp(reply, "Err - unknown", 13)) << reply;
|
||||
EXPECT_EQ(empty, fs.files["/flood_filter"]);
|
||||
}
|
||||
|
||||
TEST(TransportModes, KeyFingerprintCollisionsCannotSelectAnArbitrarySecret) {
|
||||
struct Row {
|
||||
bool active = true;
|
||||
uint8_t channel_key_len = 16, channel_hash = 1;
|
||||
uint8_t channel_secret[32] = {};
|
||||
char channel_name[32] = "key:12345678";
|
||||
} rows[2];
|
||||
uint8_t key_len = 0, hash = 0, secret[32] = {};
|
||||
char name[32] = {};
|
||||
EXPECT_TRUE(FloodRuleCLI::copyChannelReference("KEY:12345678", rows, 2,
|
||||
key_len, hash, secret, name, sizeof(name)));
|
||||
rows[1].channel_secret[0] = 9;
|
||||
EXPECT_FALSE(FloodRuleCLI::copyChannelReference("key:12345678", rows, 2,
|
||||
key_len, hash, secret, name, sizeof(name)));
|
||||
EXPECT_FALSE(FloodRuleCLI::copyChannelReference("key:unknown", rows, 2,
|
||||
key_len, hash, secret, name, sizeof(name)));
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Execute real bridge admission methods and check boot/restart policy wiring."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
import re
|
||||
from test_radio_receive_contract import method
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
PACKET = r'''
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
namespace mesh {
|
||||
struct Packet {
|
||||
uint8_t payload[64] = {}, payload_len = 32, type = 5;
|
||||
uint8_t getPayloadType() const { return type; }
|
||||
int getRawLength() const { return 32; }
|
||||
uint16_t writeTo(uint8_t* bytes) const { memcpy(bytes,payload,32);return 32; }
|
||||
};
|
||||
}
|
||||
'''
|
||||
|
||||
HARNESS = r'''
|
||||
#include <helpers/AbstractBridge.h>
|
||||
#include <cassert>
|
||||
#define BRIDGE_DEBUG_PRINTLN(...) ((void)0)
|
||||
#define PUB_KEY_SIZE 32
|
||||
#define PAYLOAD_TYPE_ADVERT 4
|
||||
#define MAX_SERIAL_PACKET_SIZE 300
|
||||
#define MAX_TRANS_UNIT 256
|
||||
#define SERIAL_OVERHEAD 6
|
||||
#define BRIDGE_PACKET_MAGIC 0xabcd
|
||||
#define BRIDGE_MAGIC_SIZE 2
|
||||
#define BRIDGE_CHECKSUM_SIZE 2
|
||||
#define MAX_ESPNOW_PACKET_SIZE 256
|
||||
unsigned millis() { return 100; }
|
||||
namespace mesh {
|
||||
namespace espnow {
|
||||
struct ESPNowRawFrames {uint8_t count=0;uint16_t lengths[2]={};uint8_t data[2][256]={};};
|
||||
bool encodeEspNowRawFrames(const uint8_t*,size_t,ESPNowRawFrames& f) {f.count=1;return true;}
|
||||
}
|
||||
namespace bridge {
|
||||
enum {ESPNOW_FORMAT_RAW=0};
|
||||
size_t espNowMaxMeshPacketSize(int) {return 256;}
|
||||
const char* espNowFormatName(int) {return "raw";}
|
||||
}
|
||||
}
|
||||
struct Seen {
|
||||
unsigned checks=0,marks=0;
|
||||
bool wasSeen(mesh::Packet*) {++checks;return false;}
|
||||
void markSeen(mesh::Packet*) {++marks;}
|
||||
};
|
||||
struct Manager {
|
||||
unsigned freed=0,queued=0;
|
||||
void free(mesh::Packet*) {++freed;}
|
||||
void queueInbound(mesh::Packet*,unsigned) {++queued;}
|
||||
};
|
||||
struct Prefs {unsigned bridge_delay=10;};
|
||||
struct BridgeBase : AbstractBridge {
|
||||
bool _initialized=true;
|
||||
Seen _seen_packets;
|
||||
Manager manager; Manager* _mgr=&manager;
|
||||
Prefs prefs; Prefs* _prefs=&prefs;
|
||||
unsigned queued=0;
|
||||
void begin() override {} void end() override {} void loop() override {}
|
||||
bool isRunning() const override {return _initialized;}
|
||||
void sendPacket(mesh::Packet*) override {}
|
||||
void onPacketReceived(mesh::Packet* p) override {handleReceivedPacket(p);}
|
||||
void handleReceivedPacket(mesh::Packet*);
|
||||
uint16_t fletcher16(const uint8_t*,size_t) {return 0;}
|
||||
};
|
||||
struct Serial {unsigned writes=0;void write(const uint8_t*,size_t) {++writes;}};
|
||||
struct RS232Bridge : BridgeBase {
|
||||
Serial serial; Serial* _serial=&serial;
|
||||
void sendPacket(mesh::Packet*) override;
|
||||
};
|
||||
struct ESPNowBridge : BridgeBase {
|
||||
int _active_format=0;
|
||||
bool xorCrypt(uint8_t*,size_t) {return true;}
|
||||
bool queueTransmitFrames(mesh::espnow::ESPNowRawFrames&,const mesh::Packet&) {++queued;return true;}
|
||||
void sendPacket(mesh::Packet*) override;
|
||||
};
|
||||
struct MQTTBridge : BridgeBase {
|
||||
struct Obs {bool mqtt_packets_enabled=true,mqtt_rx_enabled=true;uint8_t mqtt_tx_enabled=1;} obs;
|
||||
Obs* _obs=&obs;
|
||||
struct Identity {uint8_t pub_key[32]={};} id;
|
||||
Identity* _identity=&id;
|
||||
unsigned _filtered_packets=0;
|
||||
bool _staged_raw_valid=false;
|
||||
bool shouldQueuePacketType(uint8_t,bool&) {return true;}
|
||||
void queuePacket(mesh::Packet*,bool) {++queued;}
|
||||
void onPacketReceived(mesh::Packet*) override;
|
||||
void sendPacket(mesh::Packet*) override;
|
||||
};
|
||||
@METHODS@
|
||||
int main() {
|
||||
bool allowed=false;
|
||||
auto filter=[](void* context,const mesh::Packet*) {return *static_cast<bool*>(context);};
|
||||
mesh::Packet p;
|
||||
BridgeBase inbound; RS232Bridge serial; ESPNowBridge espnow; MQTTBridge mqtt;
|
||||
AbstractBridge* bridges[] = {&inbound,&serial,&espnow,&mqtt};
|
||||
for (AbstractBridge* b : bridges)
|
||||
b->setPacketFilter(filter,&allowed);
|
||||
// RX denial frees exactly once, before the seen table or mesh inbound queue.
|
||||
inbound.onPacketReceived(&p);
|
||||
assert(inbound.manager.freed==1 && inbound.manager.queued==0);
|
||||
assert(inbound._seen_packets.checks==0 && inbound._seen_packets.marks==0);
|
||||
serial.sendPacket(&p);espnow.sendPacket(&p);mqtt.sendPacket(&p);mqtt.onPacketReceived(&p);
|
||||
assert(serial.serial.writes==0 && espnow.queued==0 && mqtt.queued==0);
|
||||
assert(serial._seen_packets.marks==0 && espnow._seen_packets.marks==0);
|
||||
assert(mqtt._filtered_packets==2);
|
||||
// Removing the drop immediately restores every path, including wrapped ESP-NOW.
|
||||
allowed=true;
|
||||
inbound.onPacketReceived(&p);serial.sendPacket(&p);espnow.sendPacket(&p);
|
||||
espnow._active_format=1;espnow.sendPacket(&p);
|
||||
mqtt.onPacketReceived(&p);mqtt.sendPacket(&p);
|
||||
assert(inbound.manager.queued==1 && inbound._seen_packets.marks==1);
|
||||
assert(serial.serial.writes==1 && espnow.queued==2 && mqtt.queued==2);
|
||||
assert(serial._seen_packets.marks==1 && espnow._seen_packets.marks==2);
|
||||
BridgeBase no_policy;no_policy.onPacketReceived(&p);assert(no_policy.manager.queued==1);
|
||||
}
|
||||
'''
|
||||
|
||||
class TransportFilterWiring(unittest.TestCase):
|
||||
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)
|
||||
self.assertEqual(suspension, ['isAnyTempRadioActive', 'isAnyTempRadioActive'])
|
||||
room = (ROOT/'examples/simple_room_server/MyMesh.cpp').read_text()
|
||||
evaluations = re.findall(r'flood_rules\.evaluate\(\s*\w+,\s*(\w+)\(\)', room)
|
||||
self.assertEqual(evaluations, ['isAnyTempRadioActive'] * 3)
|
||||
|
||||
def test_shorthand_uses_the_existing_filter_manager_permissions(self):
|
||||
source = (ROOT/'examples/simple_repeater/MyMesh.cpp').read_text()
|
||||
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'''
|
||||
int main() {
|
||||
for (const char* cmd : {"get fr", "get fr.3", "set fr.3 any m=bc d", "del fr.3", "del fr all"}) {
|
||||
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"})
|
||||
assert(!isFilterMgrAllowed(cmd));
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
work = Path(directory)
|
||||
(work/'test.cpp').write_text(code)
|
||||
for enabled in [0, 1]:
|
||||
subprocess.run(['g++', '-std=c++17', f'-DMESH_ENABLE_FLOOD_RULE_ENGINE={enabled}',
|
||||
str(work/'test.cpp'), '-o', str(work/'test')], check=True)
|
||||
subprocess.run([str(work/'test')], check=True)
|
||||
for verb, action in [('get', 'formatFloodPacketFilters'), ('set', 'setFloodPacketFilter'),
|
||||
('del', 'deleteFloodPacketFilter')]:
|
||||
anchor = f'commandFamilyMatches(command, "{verb} fr")'
|
||||
dispatch = source[source.index(anchor):].split('} else if', 1)[0]
|
||||
self.assertIn(action, dispatch)
|
||||
|
||||
def test_production_bridge_admission(self):
|
||||
methods = []
|
||||
for name, signatures in {
|
||||
'BridgeBase': ['void BridgeBase::handleReceivedPacket('],
|
||||
'RS232Bridge': ['void RS232Bridge::sendPacket('],
|
||||
'ESPNowBridge': ['void ESPNowBridge::sendPacket('],
|
||||
'MQTTBridge': ['void MQTTBridge::sendPacket(', 'void MQTTBridge::onPacketReceived('],
|
||||
}.items():
|
||||
source = (ROOT/f'src/helpers/bridges/{name}.cpp').read_text()
|
||||
methods.extend(method(source, signature) for signature in signatures)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
work = Path(directory)
|
||||
(work/'Mesh.h').write_text(PACKET)
|
||||
code = '#include <initializer_list>\n' + HARNESS.replace('@METHODS@', '\n'.join(methods))
|
||||
(work/'test.cpp').write_text(code)
|
||||
result = subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
|
||||
'-I', str(work), '-I', str(ROOT/'src'), str(work/'test.cpp'),
|
||||
'-o', str(work/'test')], text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
subprocess.run([str(work/'test')], check=True)
|
||||
|
||||
def test_roles_wire_filter_at_every_bridge_start_and_cross_hook(self):
|
||||
for role in ['simple_repeater', 'simple_room_server']:
|
||||
header = (ROOT/f'examples/{role}/MyMesh.h').read_text()
|
||||
source = (ROOT/f'examples/{role}/MyMesh.cpp').read_text()
|
||||
for text in [header, source]:
|
||||
lines = text.splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
if 'bridge->begin();' in line:
|
||||
self.assertIn('configureBridgeFilter(', lines[index-1], role)
|
||||
cross = method(header, 'bool allowRadioProfileCross(')
|
||||
self.assertIn('allowTransportPacket', cross)
|
||||
admission = method(source, 'bool MyMesh::allowTransportPacket(')
|
||||
self.assertIn('incoming_region, context)', admission)
|
||||
self.assertNotIn('recv_pkt_filter_match_mask =', admission)
|
||||
self.assertNotIn('recv_pkt_rule_match_mask =', admission)
|
||||
source = (ROOT/'examples/simple_repeater/MyMesh.cpp').read_text()
|
||||
defaults = method(source, 'void MyMesh::seedDefaultFloodPacketFilters(')
|
||||
self.assertIn('flood_packet_filters[2]', defaults)
|
||||
self.assertIn('bridge_wardriving = wardriving', defaults)
|
||||
self.assertIn('bridge_wardriving.min_hops = 0', defaults)
|
||||
self.assertIn('RULE_MODE_BRIDGE', defaults)
|
||||
self.assertIn('RULE_MODE_CROSS', defaults)
|
||||
self.assertNotIn('flood_packet_filters[3]', defaults)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user