feat: add selective flood retry rules

This commit is contained in:
mikecarper committed 2026-08-25 21:38:23 -07:00
1 parent 65e40ff05f
commit 605b245eb5
17 files changed
+597 -62

No files matched your search

+12 -4
View File
@@ -143,6 +143,8 @@
}
if (channel === "*") return "";
if (channel.toLowerCase() === "public") return "public";
const hashMatch = channel.match(/^(?:(?:hash|short):)?([0-9a-fA-F]{2})$/i);
if (hashMatch) return `hash:${hashMatch[1].toUpperCase()}`;
if (channel[0] === "#") {
if (channel.length < 2 || channel.length > 31 || /\s/.test(channel)) {
throw new FilterToolError("A hashtag channel must be 1-30 non-space characters after #.");
@@ -150,7 +152,7 @@
return channel;
}
if (/^(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{64})$/.test(channel)) return channel.toUpperCase();
throw new FilterToolError("Channel must be *, public, #channel, or a 128/256-bit hexadecimal key.");
throw new FilterToolError("Channel must be *, public, #channel, hash:XX, or a 128/256-bit hexadecimal key.");
}
function normalizeScopeName(value) {
@@ -526,6 +528,7 @@
function channelDescription(channel) {
if (channel === "public") return "the Public channel";
if (channel.startsWith("#")) return `the ${channel} channel`;
if (channel.startsWith("hash:")) return `unauthenticated channel hash ${channel.slice(5)}`;
return `channel key ${channel.slice(0, 8)}...`;
}
@@ -597,6 +600,7 @@
}
if (rule.type === "class:other") warnings.push("class:other intentionally includes current and future types outside group and login classes, including OTA.");
if (rule.channel && rule.type === "any") warnings.push("A channel condition narrows type=any to group text/data packets on that channel.");
if (rule.channel.startsWith("hash:")) warnings.push("A one-byte channel hash is unauthenticated, collision-prone, and sender-selectable; it is never promoted to a known channel identity (hash:11 is not Public). A name or key checks the channel MAC but does not authenticate an individual sender.");
if (rule.sender) warnings.push("Displayed sender names are spoofable and are moderation signals, not identities.");
if (rule.pathKind !== "none") warnings.push("Pbyte and path-table matches use truncated routing hints, not authenticated identities.");
if (rule.pathKind.startsWith("bucket:")) warnings.push("The selected bucket must exist on the target node; the policy stores a reference, not its IDs.");
@@ -610,7 +614,7 @@
const rule = normalizeRule(input);
let bytes = 12;
bytes += 3 + 3 + 3;
if (rule.channel) bytes += 3 + (rule.channel.length === 32 || rule.channel.length === 64 ? rule.channel.length / 2 : rule.channel.length);
if (rule.channel) bytes += 3 + (rule.channel.startsWith("hash:") ? 1 : rule.channel.length === 32 || rule.channel.length === 64 ? rule.channel.length / 2 : rule.channel.length);
if (rule.incoming !== "any") bytes += 3 + rule.incoming.length;
if (rule.pathKind === "prefix") bytes += 4 + rule.pathPrefix.replace(/,/g, "").length / 2;
else if (rule.pathKind !== "none") bytes += 3;
@@ -892,8 +896,11 @@
if (transportCodes.length) {
notes.push("Transport codes are on the wire, but a local region/scope table is required to resolve their names and allow status.");
}
const channelHash = GROUP_TYPES.includes(type) && payload.length > 0
? `hash:${bytesToHex(payload, 0, 1)}`
: "";
if (GROUP_TYPES.includes(type)) {
notes.push("A raw channel hash alone is insufficient to identify a channel; the matching channel key is required.");
notes.push("The visible one-byte channel hash can drive an unauthenticated hash:XX rule; a matching channel key is still required to identify and authenticate the channel.");
}
if (["req", "response", "txt_msg", "path", "grp_txt", "grp_data", "anon_req"].includes(type)) {
notes.push("Encrypted content, including a displayed sender, cannot be recovered without the appropriate key.");
@@ -924,6 +931,7 @@
pathHex: bytesToHex(pathBytes),
payloadLength: payload.length,
payloadHex: bytesToHex(payload),
channelHash,
payloadFields: envelope.fields,
notes,
};
@@ -1560,7 +1568,7 @@
packetField("type").value = decoded.type;
packetField("hops").value = String(decoded.hops);
packetField("path").value = decoded.pathIds.join(",");
packetField("channel").value = "";
packetField("channel").value = decoded.channelHash;
packetField("scope-status").value = decoded.routeCode === 0 ? "unknown" : "none";
packetField("scope-name").value = "";
packetField("region-name").value = "";
+49 -5
View File
@@ -1952,7 +1952,7 @@ worked moderation examples, see [Flood Filtering and Moderation](flood_filtering
**Usage:**
- `get flood.rule`
- `get flood.rule.<n>`
- `set flood.rule[.<n>] type=<type> [hops=<range>] [channel=<channel>] [prefix=<path-prefix>] [in=<input-scope>] <drop|scope=<name>|region=<name>|rate=<N>/min|stop> [priority=<0-255>] [tx=slow] [suspend=tempradio]`
- `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]`
- `del flood.rule.<n>`
- `del flood.rule all`
- `get flood.filter`
@@ -1988,11 +1988,21 @@ compile this table.
- `all`: Match every received hop count (`0-63`).
- `0+`, `all`, and an omitted hop expression are equivalent. The CLI displays
the saved range as `all`.
- `channel=*|public|#name|128-bit-key|256-bit-key`: Optional channel match.
- `channel=*|public|#name|hash:XX|128-bit-key|256-bit-key`: Optional channel match.
`channel=*` means no channel condition at all, so the row matches everything
selected by `type=` (including all flood payload types with `type=any`). It
does not authenticate a packet. `public`, `#name`, and raw keys authenticate
one channel and therefore narrow the row to `GRP_TXT`/`GRP_DATA`.
`hash:XX` matches only the visible one-byte group-channel hash; `short:XX`
and a bare two-digit byte are accepted aliases and are displayed as
`hash:XX`. This form is deliberately unauthenticated. It can collide with
another channel once in 256 hash values, and a sender can choose the byte,
so use an exact channel name or key whenever it is available. In particular,
`channel=hash:11` does not mean Public: it matches Public plus every collision
or deliberately selected `0x11` value. `channel=public` performs the deeper
MAC/decrypt check with the Public channel key. That distinguishes an ordinary
`0x11` collision, but it is channel authentication rather than sender
authentication: the group MAC is two bytes and the Public key is shared.
- `prefix=<ID[,ID...]>`: Optional ordered source-path prefix of one to three
pbyte IDs. IDs must all be 2, 4, or 6 hex characters, matching a packet's
1-, 2-, or 3-byte pbyte width. `path=<prefix>` is an alias.
@@ -2012,6 +2022,14 @@ compile this table.
- `rate=N/min`: Per-node, per-row fixed one-minute forwarding limit. It can be
the only action or accompany `scope=`/`region=`. Counters are charged only
for packets that pass all forwarding gates.
- `retry`, `retry=on`, `retry=allow`, or `action=retry`: Allow a matching
received flood packet to enter the configured flood-retry sequence. With no
active `retry` rows, retry eligibility remains backward compatible and is
controlled by the global retry settings. Once any active `retry` row exists,
the matching rows become a retry allow-list: a received flood must match at
least one surviving `retry` row. `flood.retry.bridge` still selects ordinary
or bridge-bucket completion for the allowed packet. This action can accompany
rewrite, rate, or stop, but not `drop`; compact syntax uses `f=r`.
- `priority=0-255`: Optional processing order. Higher values run first and
lower slot number breaks a tie. The default is `0`; `pri=` is an alias.
- `stop` or `action=stop`: Apply this matching row, then stop lower-order FPF7
@@ -2084,6 +2102,13 @@ when its name is absent from the local region list. It does not bypass
`repeat`, `flood.max`, other drop rows, loop detection, or
moderation.
Retry selection uses that same ordered, stop-truncated match set. With at least
one active `retry` row, a received flood starts a retry sequence only when one
of those matching rows includes `retry`. This selector cannot override
`flood.retry.count`, path/type attempt caps, `flood.retry.advert`, disabled
forwarding, a drop decision, or any other hard forwarding gate. Locally
originated floods retain the normal global retry behavior.
With `require=region`, a failed check makes that scope row ineligible. It leaves
the packet unchanged and does not set the filter-scope trust bypass, so an
unknown or denied incoming region is rejected normally unless another
@@ -2117,6 +2142,25 @@ force. A malformed persisted table fails open (no general rules are applied).
Within one receive evaluation, rows that use the same channel key share one
authentication result. The cache is discarded after that packet and stores
neither plaintext nor passwords; different keys are authenticated separately.
Raw `hash:XX` rows skip this authentication and compare only the one visible
byte.
**Per-channel retry examples:**
```text
# Use bridge-bucket retry only for authenticated Public and #hamradio traffic.
set flood.retry.bridge on
set flood.rule.2 type=any channel=public retry
set flood.rule.3 type=any channel=#hamradio retry
# If the channel key/name is unavailable, select visible channel hash A7.
# This is an unauthenticated 1-byte hint, not a channel identity.
set flood.rule.4 type=any channel=hash:A7 retry
```
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
new flood-filter table with
@@ -2151,9 +2195,9 @@ If all of those fields plus long names would exceed one CLI reply, the
numbered form automatically switches to a non-truncating compact spelling.
The compact aliases are also accepted by `set`: `c=` means `channel=`, `p=`
means `prefix=`, `i=*|n|s|a|u|s:<scope>|r:<region>` means the corresponding
`in=` condition, `q=N` means `rate=N/min`, `pri=N` means priority, and `f=st`
combines slow timing
(`s`) with temporary-radio suspension (`t`). Packet type is shown numerically
`in=` 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.
On generalized repeaters, filter rows, scope-rewrite rows, the shared
+3 -3
View File
@@ -65,7 +65,7 @@ in the simulator below. The examples draw from
</thead>
<tbody>
<tr><td><code>hops=</code></td><td>Received hop count: <code>all</code>, <code>3+</code>, <code>2-6</code>, or <code>3</code></td></tr>
<tr><td><code>channel=</code></td><td><code>*</code> means no channel condition; a name or key authenticates one group channel</td></tr>
<tr><td><code>channel=</code></td><td><code>*</code> means no channel condition; a name or key authenticates one group channel; <code>hash:XX</code> is an unauthenticated one-byte fallback</td></tr>
<tr><td><code>rx.scope=</code></td><td>Original incoming transport scope</td></tr>
<tr><td><code>path=</code></td><td>Path prefix, blacklist, bucket, or loop match</td></tr>
<tr><td><code>tempradio=</code></td><td>Temporary-radio state</td></tr>
@@ -164,7 +164,7 @@ in the simulator below. The examples draw from
</label>
<label>
Channel (optional)
<input data-field="channel" placeholder="*, #rgdata, public, or key">
<input data-field="channel" placeholder="*, #rgdata, public, hash:A7, or key">
</label>
<label>
Original incoming scope
@@ -471,7 +471,7 @@ in the simulator below. The examples draw from
</label>
<label>
Channel
<input data-packet="channel" value="#rgdata" placeholder="blank if none">
<input data-packet="channel" value="#rgdata" placeholder="#name, public, hash:A7, key, or blank">
</label>
<label>
Received pbyte path
+38 -5
View File
@@ -286,6 +286,8 @@ 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.
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
@@ -340,12 +342,19 @@ 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+`.
- `channel=*|public|#name|128-bit-key|256-bit-key` optionally narrows by
- `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
`type=any channel=*` means every flood payload type. `public`, `#name`, and
raw keys authenticate one channel and narrow the row to `GRP_TXT` or
`GRP_DATA`.
`GRP_DATA`. `hash:XX` compares only the visible one-byte group-channel hash.
`short:XX` and a bare two-digit byte are aliases; saved output uses
`hash:XX`. This matcher is unauthenticated, has a 1-in-256 collision space,
and can be selected by a sender, so prefer an exact name or key when known.
`hash:11` is not an alias for Public: it includes Public and every `0x11`
collision, while `public` verifies and decrypts with the Public key. This is
channel authentication, not sender authentication: the group MAC is two
bytes and the Public key is shared.
- `prefix=` is a source-path prefix containing one to three comma-separated
pbyte IDs. Every ID must use the packet's pbyte width: 2, 4, or 6 hex
characters for 1-, 2-, or 3-byte paths. Order matters and matching begins at
@@ -377,6 +386,12 @@ Actions:
every other forwarding gate, including moderation, accepts the packet. It is
not keyed per sender; use `flood.moderation` when a group-text rate must be
tied to an exact display name.
- `retry` allows a matching received flood to start the configured retry
sequence. `retry=on`, `retry=allow`, and `action=retry` are aliases. If the
table has no active `retry` rows, global retry behavior is unchanged. If it
has one or more, those rows are a retry allow-list: at least one must remain
in the ordered, stop-truncated match set. The action may accompany rewrite,
rate, or stop, but not `drop`; its compact flag is `f=r`.
- `priority=0-255` controls processing order. Higher values run first; lower
slot number breaks ties. `pri=` is the compact alias.
- `stop` (or `action=stop`) applies this row and prevents lower-order FPF7 rows
@@ -385,7 +400,15 @@ Actions:
When several rows use the same channel key, authentication is performed once
for that packet and reused by those rows. This cache lives only for the current
receive evaluation; it is not persisted and never stores plaintext or a
password.
password. A `hash:XX` row deliberately skips authentication and uses only the
one visible byte.
The global retry controls remain hard gates. A `retry` row does not override a
zero `flood.retry.count`, path/type attempt caps, disabled forwarding,
`flood.retry.advert`, a drop verdict, or any other forwarding rejection.
`flood.retry.bridge` independently chooses ordinary retry or bridge-bucket
completion for an allowed packet. The selector applies to received floods;
locally originated floods retain the normal global behavior.
The exact requested examples are:
@@ -404,6 +427,15 @@ set flood.rule.4 type=any prefix=860C rate=10/min
# rules. Hard gates and separate tables still apply.
set flood.rule.5 type=grp_data hops=0-2 channel=#rgdata priority=200 stop
# Retry received Public and #hamradio floods, using bridge completion.
# Once these rows exist, other received floods are not retried.
set flood.retry.bridge on
set flood.rule.6 type=any channel=public retry
set flood.rule.7 type=any channel=#hamradio retry
# Fall back to an unauthenticated one-byte match if only the visible hash is known.
set flood.rule.8 type=any channel=hash:A7 retry
get flood.rule.2
get flood.rule.3
get flood.rule.4
@@ -467,8 +499,9 @@ Numbered `get` normally uses the long field names. If a rule containing
several maximum-length names would exceed one CLI reply, it switches to a
non-truncating compact spelling that `set` also accepts: `c=` is `channel=`,
`p=` is `prefix=`, `i=*|n|s|a|u|s:<scope>|r:<region>` represents `in=`, `q=N`
is `rate=N/min`, and `f=st` combines slow timing (`s`) and temporary-radio
suspension (`t`). The fallback prints packet type numerically.
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.
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
+11 -1
View File
@@ -85,7 +85,7 @@ set flood.retry.ignore none
| `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.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 authentication, ordered 1/2/3-byte pbyte source prefix, and original scope/region conditions, then drop, rewrite to a direct scope or configured region, enforce a per-row rate, and/or stop lower-priority FPF7 rules. `channel=*` means no channel condition and `type=any channel=*` matches every flood payload type. `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 `priority` runs first and lower slot breaks a tie. Repeated rows with one channel key share a per-packet authentication result. Persistent FPF7 stores canonical region names, so region ID reorder or reuse cannot retarget a rule. A missing saved region 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|stop> [priority=0-255]`, `del flood.rule.<n>|all` | `set flood.rule.2 type=grp_data hops=4+ channel=#rgdata in=none scope=BlackHole86` |
| `flood.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 `priority` runs first and lower slot breaks a tie. Repeated rows with one channel key share a per-packet authentication result. Persistent FPF7 stores canonical region names, so region ID reorder or reuse cannot retarget a rule. A missing saved region 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` |
| `clock.sync.mesh.edge` | Defaults on so edge infrastructure nodes can collect clock evidence when all packets arrive through one relay path. Verified evidence is observed before the forwarding decision, so disabled forwarding and forwarding filters do not prevent collection. Signed adverts are deduplicated by public key and Public-channel timestamps by case-insensitive display name; all may share one receive path. Public display names are unauthenticated and can be spoofed. Changing this setting clears current clock samples. | `get clock.sync.mesh.edge`, `set clock.sync.mesh.edge <on|off>` | `set clock.sync.mesh.edge on` |
@@ -292,6 +292,16 @@ the bucket rules below instead.
| `flood.retry.bucket.<n>` | Shows one bridge bucket. Buckets are numbered `1`-`6`. | `get flood.retry.bucket.<n>` | `get flood.retry.bucket.1` |
| `flood.retry.bucket` | Sets bridge bucket prefixes. | `set flood.retry.bucket <1-6> <prefixes/none/off>` | `set flood.retry.bucket 1 71CE82,C7618C` |
Extended `flood.rule` rows can select which received floods may use these
settings. For example, enable bridge completion only for Public and
`#hamradio` with `set flood.rule type=any channel=public retry` and a second
row using `channel=#hamradio`. Adding the first active `retry` row changes
received retry selection into an allow-list; deleting the last restores the
legacy global selection. `channel=hash:XX` is available when only the visible
one-byte hash is known, but it is not authenticated and is collision-prone.
For example, `hash:11` includes Public and any other packet using `0x11`;
`channel=public` alone performs the Public-key MAC/decrypt check.
Bridge buckets may also be reused as passive `flood.channel.scope`
path selectors with `path=bucket:<1-6>`. This does not require
`flood.retry.bridge` to be enabled and does not depend on recent-repeater
+142 -23
View File
@@ -1878,6 +1878,10 @@ bool MyMesh::allowFloodRetry(const mesh::Packet* packet) const {
if (_prefs.disable_fwd || constrain(_prefs.flood_retry_attempts, 0, 15) == 0) {
return false;
}
if (packet != NULL
&& packet->flood_retry_policy == mesh::FLOOD_RETRY_POLICY_DENY) {
return false;
}
if (packet != NULL && packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && !_prefs.flood_retry_advert_enabled) {
return false;
}
@@ -2370,6 +2374,7 @@ mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) {
recv_pkt_channel_scope_bypass = false;
recv_pkt_channel_scope_rejected = false;
recv_pkt_filter_match_mask = 0;
pkt->flood_retry_policy = mesh::FLOOD_RETRY_POLICY_DEFAULT;
#if !defined(PORTABLE_MQTT_OBSERVER)
if (pkt->isRouteFlood()) {
bool incoming_region_allowed = false;
@@ -2403,6 +2408,14 @@ mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) {
if (!recv_pkt_channel_scope_rejected) {
recv_pkt_filter_match_mask = evaluateFloodPacketFilterMatches(
pkt, incoming_region_allowed, incoming_region);
#if MESH_ENABLE_FLOOD_RULE_ENGINE
if (hasFloodPacketFilterRetryRules()) {
pkt->flood_retry_policy = floodPacketFilterAllowsRetry(
recv_pkt_filter_match_mask)
? mesh::FLOOD_RETRY_POLICY_ALLOW
: mesh::FLOOD_RETRY_POLICY_DENY;
}
#endif
bool channel_regionless_scope_set = false;
scope_changed =
applyFloodChannelScope(pkt, fast_track_scope_change,
@@ -5124,6 +5137,10 @@ static bool parseFloodModerationChannel(
const char* text, uint8_t secret[PUB_KEY_SIZE], uint8_t& key_len,
uint8_t hash_prefix[FLOOD_CHANNEL_KEY_PREFIX_LEN], char* name,
size_t name_len);
static bool parseFloodRuleChannel(
const char* text, uint8_t secret[PUB_KEY_SIZE], uint8_t& key_len,
uint8_t hash_prefix[FLOOD_CHANNEL_KEY_PREFIX_LEN], char* name,
size_t name_len);
static bool parseFloodModerationPath(
const char* text, uint8_t& hash_size, uint8_t& path_hops,
uint8_t path[FLOOD_GROUP_MODERATION_PATH_BYTES_MAX]);
@@ -5165,6 +5182,11 @@ void MyMesh::seedDefaultFloodPacketFilters() {
}
#if MESH_ENABLE_FLOOD_RULE_ENGINE
static_assert(CIPHER_KEY_SIZE == FloodFilterPolicy::CHANNEL_KEY_128_LEN,
"flood rule 128-bit key encoding changed");
static_assert(PUB_KEY_SIZE == FloodFilterPolicy::CHANNEL_KEY_256_LEN,
"flood rule 256-bit key encoding changed");
bool MyMesh::loadFloodPacketFilters() {
if (_fs == NULL) {
seedDefaultFloodPacketFilters();
@@ -5208,6 +5230,7 @@ bool MyMesh::loadFloodPacketFilters() {
uint8_t drop_on_match = 0;
uint8_t rate_limit_enabled = 0;
uint8_t stop_on_match = 0;
uint8_t stored_rule_channel = 0;
success = file.read(&active, sizeof(active)) == sizeof(active);
success = success && file.read(&loaded[i].payload_type, sizeof(loaded[i].payload_type)) == sizeof(loaded[i].payload_type);
success = success && file.read(&loaded[i].min_hops, sizeof(loaded[i].min_hops)) == sizeof(loaded[i].min_hops);
@@ -5238,9 +5261,9 @@ bool MyMesh::loadFloodPacketFilters() {
sizeof(loaded[i].incoming_scope_name))
== sizeof(loaded[i].incoming_scope_name);
success = success
&& file.read(&loaded[i].channel_key_len,
sizeof(loaded[i].channel_key_len))
== sizeof(loaded[i].channel_key_len);
&& file.read(&stored_rule_channel,
sizeof(stored_rule_channel))
== sizeof(stored_rule_channel);
success = success
&& file.read(loaded[i].channel_secret,
sizeof(loaded[i].channel_secret))
@@ -5286,6 +5309,13 @@ bool MyMesh::loadFloodPacketFilters() {
: FloodFilterPolicy::RULE_IN_ANY;
drop_on_match = loaded[i].scope_name[0] == 0 ? 1 : 0;
}
if (success && version_7) {
success = FloodFilterPolicy::decodeStoredRuleChannel(
stored_rule_channel, loaded[i].channel_key_len,
loaded[i].retry_on_match);
} else {
loaded[i].retry_on_match = false;
}
loaded[i].active = active != 0;
loaded[i].suspend_on_temp_radio = suspend_on_temp_radio != 0;
loaded[i].match_blacklisted_path = match_blacklisted_path != 0;
@@ -5333,12 +5363,23 @@ bool MyMesh::loadFloodPacketFilters() {
&& loaded[i].incoming_scope_name[0] == 0;
}
bool channel_valid = loaded[i].channel_key_len == 0
|| loaded[i].channel_key_len == CIPHER_KEY_SIZE
|| loaded[i].channel_key_len == PUB_KEY_SIZE;
bool channel_valid = FloodFilterPolicy::channelKeyLengthSupported(
loaded[i].channel_key_len);
if (channel_valid && loaded[i].channel_key_len == 0) {
channel_valid = channel_name_terminated
&& loaded[i].channel_name[0] == 0;
} else if (channel_valid
&& FloodFilterPolicy::channelHashOnly(
loaded[i].channel_key_len)) {
channel_valid = channel_name_terminated;
if (channel_valid) {
loaded[i].channel_hash = loaded[i].channel_secret[0];
memset(&loaded[i].channel_secret[1], 0,
sizeof(loaded[i].channel_secret) - 1);
snprintf(loaded[i].channel_name,
sizeof(loaded[i].channel_name), "hash:%02X",
loaded[i].channel_hash);
}
} else if (channel_valid) {
channel_valid = channel_name_terminated
&& loaded[i].channel_name[0] != 0;
@@ -5365,7 +5406,7 @@ bool MyMesh::loadFloodPacketFilters() {
<= FLOOD_PACKET_FILTER_PATH_PREFIX_HOPS_MAX);
bool action_valid = loaded[i].drop_on_match || direct_target
|| region_target || loaded[i].rate_limit_enabled
|| loaded[i].stop_on_match;
|| loaded[i].stop_on_match || loaded[i].retry_on_match;
if (!((loaded[i].payload_type <= PH_TYPE_MASK
|| loaded[i].payload_type == FLOOD_PACKET_FILTER_ANY_TYPE)
&& loaded[i].min_hops <= loaded[i].max_hops
@@ -5379,6 +5420,7 @@ bool MyMesh::loadFloodPacketFilters() {
&& !(direct_target && region_target)
&& !(loaded[i].drop_on_match && (direct_target || region_target))
&& !(loaded[i].drop_on_match && loaded[i].rate_limit_enabled)
&& !(loaded[i].drop_on_match && loaded[i].retry_on_match)
&& (!loaded[i].rate_limit_enabled
|| loaded[i].rate_per_minute
< FLOOD_GROUP_MODERATION_RATE_UNLIMITED)
@@ -5612,7 +5654,8 @@ bool MyMesh::isFloodChannelDataRule(
&& entry.drop_on_match
&& !entry.rate_limit_enabled
&& entry.priority == 0
&& !entry.stop_on_match;
&& !entry.stop_on_match
&& !entry.retry_on_match;
}
int MyMesh::findFloodChannelDataRule() const {
@@ -5976,6 +6019,9 @@ bool MyMesh::saveFloodPacketFilters(bool empty_scope_phase,
uint8_t drop_on_match = entry.drop_on_match ? 1 : 0;
uint8_t rate_limit_enabled = entry.rate_limit_enabled ? 1 : 0;
uint8_t stop_on_match = entry.stop_on_match ? 1 : 0;
uint8_t stored_rule_channel =
FloodFilterPolicy::encodeStoredRuleChannel(
entry.channel_key_len, entry.retry_on_match);
success = writeExact(&active, sizeof(active))
&& writeExact(&entry.payload_type, sizeof(entry.payload_type))
&& writeExact(&entry.min_hops, sizeof(entry.min_hops))
@@ -5992,7 +6038,7 @@ bool MyMesh::saveFloodPacketFilters(bool empty_scope_phase,
sizeof(entry.incoming_scope_kind))
&& writeExact(entry.incoming_scope_name,
sizeof(entry.incoming_scope_name))
&& writeExact(&entry.channel_key_len, sizeof(entry.channel_key_len))
&& writeExact(&stored_rule_channel, sizeof(stored_rule_channel))
&& writeExact(entry.channel_secret, sizeof(entry.channel_secret))
&& writeExact(entry.channel_name, sizeof(entry.channel_name))
&& writeExact(&entry.path_hash_size, sizeof(entry.path_hash_size))
@@ -6229,6 +6275,8 @@ bool MyMesh::floodPacketFilterFieldsMatch(
bool incoming_region_allowed,
const RegionEntry* incoming_region) const {
if (!entry.active || packet == NULL || !packet->isRouteFlood()) return false;
if (!FloodFilterPolicy::channelKeyLengthSupported(
entry.channel_key_len)) return false;
if (entry.suspend_on_temp_radio && isTempRadioActive()) return false;
if (entry.match_blacklisted_path && !floodPacketFilterBlacklistMatches(packet)) {
return false;
@@ -6283,13 +6331,34 @@ bool MyMesh::floodPacketFilterFieldsMatch(
bool MyMesh::authenticateFloodPacketFilterChannel(
const FloodPacketFilterEntry& entry,
const mesh::Packet* packet) const {
if (entry.channel_key_len == 0) return true;
if (entry.channel_key_len == 0
|| FloodFilterPolicy::channelHashOnly(entry.channel_key_len)) {
return true;
}
if (!FloodFilterPolicy::channelRequiresAuthentication(
entry.channel_key_len)) return false;
uint8_t data[MAX_PACKET_PAYLOAD];
return mesh::Utils::MACThenDecrypt(
entry.channel_secret, data, &packet->payload[PATH_HASH_SIZE],
packet->payload_len - PATH_HASH_SIZE) > 0;
}
bool MyMesh::hasFloodPacketFilterRetryRules() const {
for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) {
if (flood_packet_filters[i].active
&& flood_packet_filters[i].retry_on_match) return true;
}
return false;
}
bool MyMesh::floodPacketFilterAllowsRetry(uint64_t match_mask) const {
for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) {
if ((match_mask & ((uint64_t)1U << i)) != 0
&& flood_packet_filters[i].retry_on_match) return true;
}
return false;
}
int MyMesh::nextFloodPacketFilterMatch(uint64_t match_mask,
uint64_t visited_mask) const {
uint8_t priorities[FLOOD_PACKET_FILTER_SLOTS];
@@ -6357,7 +6426,8 @@ uint64_t MyMesh::evaluateFloodPacketFilterMatches(
continue;
}
bool authenticated = true;
if (entry.channel_key_len != 0) {
if (FloodFilterPolicy::channelRequiresAuthentication(
entry.channel_key_len)) {
int cached = -1;
for (int j = 0; j < i; j++) {
if (channel_auth_checked[j]
@@ -6589,6 +6659,7 @@ void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_
char incoming[48];
char action[64];
char rate[24];
char retry[10];
formatFloodFilterHopSpec(hops, sizeof(hops), entry.min_hops, entry.max_hops);
if (entry.match_blacklisted_path) {
strcpy(prefix, "blacklist");
@@ -6643,12 +6714,17 @@ void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_
action[0] == 0 ? "" : " ",
(unsigned int)entry.rate_per_minute);
}
retry[0] = 0;
if (entry.retry_on_match) {
snprintf(retry, sizeof(retry), "%sretry",
action[0] == 0 && rate[0] == 0 ? "" : " ");
}
int written = snprintf(
reply, reply_len,
"> %d type=%s hops=%s channel=%s prefix=%s in=%s %s%s priority=%u%s%s%s",
"> %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,
entry.channel_key_len == 0 ? "*" : entry.channel_name,
prefix, incoming, action, rate,
prefix, incoming, action, rate, retry,
(unsigned int)entry.priority,
entry.stop_on_match ? " stop" : "",
entry.scope_uses_slow_timing ? " tx=slow" : "",
@@ -6712,10 +6788,12 @@ void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_
}
char compact_flags[8];
compact_flags[0] = 0;
if (entry.scope_uses_slow_timing || entry.suspend_on_temp_radio) {
snprintf(compact_flags, sizeof(compact_flags), " f=%s%s",
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.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",
@@ -6767,12 +6845,13 @@ 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",
snprintf(item, sizeof(item), " %d=%s@%s%s%s%s%s%s%s%s%s%s",
i + 1, floodFilterPayloadTypeName(entry.payload_type), hops,
entry.match_blacklisted_path ? "?blacklist" : "",
target, priority,
entry.stop_on_match ? "~stop" : "",
entry.rate_limit_enabled ? "~rate" : "",
entry.retry_on_match ? "~retry" : "",
entry.scope_uses_slow_timing ? "~slow" : "",
entry.suspend_on_temp_radio ? "~tempradio" : "",
i == findFloodChannelDataRule() ? "~data" : "");
@@ -6889,6 +6968,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
uint8_t priority = 0;
bool priority_set = false;
bool stop_on_match = false;
bool retry_on_match = false;
for (int i = 1; i < token_count; i++) {
if (floodFilterAsciiEqual(tokens[i], "suspend=tempradio")) {
if (suspend_on_temp_radio) {
@@ -6899,7 +6979,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
} else if (floodFilterAsciiStartsWith(tokens[i], "f=")) {
const char* flags = tokens[i] + 2;
if (*flags == 0) {
strcpy(reply, "Err - compact flags are s and/or t");
strcpy(reply, "Err - compact flags are s, t, and/or r");
return;
}
while (*flags != 0) {
@@ -6916,8 +6996,14 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
return;
}
suspend_on_temp_radio = true;
} else if (*flags == 'r' || *flags == 'R') {
if (retry_on_match) {
strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE);
return;
}
retry_on_match = true;
} else {
strcpy(reply, "Err - compact flags are s and/or t");
strcpy(reply, "Err - compact flags are s, t, and/or r");
return;
}
flags++;
@@ -7010,6 +7096,15 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
return;
}
stop_on_match = true;
} else if (floodFilterAsciiEqual(tokens[i], "retry")
|| floodFilterAsciiEqual(tokens[i], "retry=on")
|| floodFilterAsciiEqual(tokens[i], "retry=allow")
|| floodFilterAsciiEqual(tokens[i], "action=retry")) {
if (retry_on_match) {
strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE);
return;
}
retry_on_match = true;
} else if (floodFilterAsciiStartsWith(tokens[i], "tx=")) {
if (scope_timing_set) {
strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE);
@@ -7104,10 +7199,10 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
return;
}
uint8_t hash_prefix[FLOOD_CHANNEL_KEY_PREFIX_LEN];
if (!parseFloodModerationChannel(
if (!parseFloodRuleChannel(
value, channel_secret, channel_key_len, hash_prefix,
channel_name, sizeof(channel_name))) {
strcpy(reply, "Err - channel must be *, public, #channel, or a key");
strcpy(reply, "Err - channel must be *, public, #channel, hash:XX, or a key");
return;
}
channel_hash = hash_prefix[0];
@@ -7153,6 +7248,10 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
strcpy(reply, "Err - drop cannot be combined with rate");
return;
}
if (drop_on_match && retry_on_match) {
strcpy(reply, "Err - drop cannot be combined with retry");
return;
}
if (channel_key_len != 0 && payload_type != FLOOD_PACKET_FILTER_ANY_TYPE
&& payload_type != PAYLOAD_TYPE_GRP_TXT
&& payload_type != PAYLOAD_TYPE_GRP_DATA) {
@@ -7160,10 +7259,10 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
return;
}
bool action_set = drop_set || target_set || rate_limit_enabled
|| stop_on_match;
|| stop_on_match || retry_on_match;
if (require_explicit_action && !action_set) {
strcpy(reply,
"Err - flood.rule requires drop, scope=, region=, rate=, or stop");
"Err - flood.rule requires drop, scope=, region=, rate=, retry, or stop");
return;
}
// Preserve the positional flood.filter behavior. The flood.rule alias is
@@ -7198,6 +7297,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
candidate.rate_per_minute = rate_per_minute;
candidate.priority = priority;
candidate.stop_on_match = stop_on_match;
candidate.retry_on_match = retry_on_match;
StrHelper::strzcpy(candidate.scope_name, scope_name,
sizeof(candidate.scope_name));
@@ -8057,6 +8157,25 @@ static bool parseFloodModerationChannel(const char* text, uint8_t secret[PUB_KEY
return true;
}
static bool parseFloodRuleChannel(
const char* text, uint8_t secret[PUB_KEY_SIZE], uint8_t& key_len,
uint8_t hash_prefix[FLOOD_CHANNEL_KEY_PREFIX_LEN], char* name,
size_t name_len) {
uint8_t channel_hash = 0;
if (!FloodFilterPolicy::parseChannelHashMatcher(text, channel_hash)) {
return parseFloodModerationChannel(
text, secret, key_len, hash_prefix, name, name_len);
}
memset(secret, 0, PUB_KEY_SIZE);
memset(hash_prefix, 0, FLOOD_CHANNEL_KEY_PREFIX_LEN);
key_len = FloodFilterPolicy::CHANNEL_HASH_ONLY_LEN;
secret[0] = channel_hash;
hash_prefix[0] = channel_hash;
int written = snprintf(name, name_len, "hash:%02X", channel_hash);
return written >= 0 && (size_t)written < name_len;
}
static bool parseFloodChannelScopeIndex(const char*& cursor, int& index) {
cursor = skipFloodFilterSpaces(cursor);
index = -1;
+3
View File
@@ -361,6 +361,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
uint16_t rate_per_minute;
uint8_t priority;
bool stop_on_match;
bool retry_on_match;
uint32_t rate_window_started;
uint16_t rate_window_count;
bool rate_window_active;
@@ -677,6 +678,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
bool authenticateFloodPacketFilterChannel(
const FloodPacketFilterEntry& entry,
const mesh::Packet* packet) const;
bool hasFloodPacketFilterRetryRules() const;
bool floodPacketFilterAllowsRetry(uint64_t match_mask) const;
int nextFloodPacketFilterMatch(uint64_t match_mask,
uint64_t visited_mask) const;
bool resolveFloodPacketFilterTargetRegion(
+108 -21
View File
@@ -17,6 +17,11 @@ static const char RULE_USAGE[] =
"Err - use: set flood.rule[.n] type=<type> [hops=<range>] [...]";
static const char DUPLICATE_OPTION[] = "Err - duplicate filter option";
static_assert(CIPHER_KEY_SIZE == FloodFilterPolicy::CHANNEL_KEY_128_LEN,
"flood rule 128-bit key encoding changed");
static_assert(PUB_KEY_SIZE == FloodFilterPolicy::CHANNEL_KEY_256_LEN,
"flood rule 256-bit key encoding changed");
// Channel encryption uses a 128-bit key, while MACThenDecrypt takes a
// PUB_KEY_SIZE buffer. Keep the unused half zero-padded like GroupChannel.
static const uint8_t PUBLIC_CHANNEL_SECRET[PUB_KEY_SIZE] = {
@@ -239,7 +244,12 @@ static bool parseChannel(const char* text, uint8_t secret[PUB_KEY_SIZE],
char* name, size_t name_len) {
if (text == NULL || *text == 0) return false;
memset(secret, 0, PUB_KEY_SIZE);
if (asciiEqual(text, "public")) {
if (FloodFilterPolicy::parseChannelHashMatcher(text, channel_hash)) {
key_len = FloodFilterPolicy::CHANNEL_HASH_ONLY_LEN;
secret[0] = channel_hash;
int written = snprintf(name, name_len, "hash:%02X", channel_hash);
return written >= 0 && (size_t)written < name_len;
} else if (asciiEqual(text, "public")) {
key_len = CIPHER_KEY_SIZE;
memcpy(secret, PUBLIC_CHANNEL_SECRET, sizeof(PUBLIC_CHANNEL_SECRET));
copyString(name, "public", name_len);
@@ -438,6 +448,7 @@ void FloodRuleEngine::load() {
uint8_t drop_on_match = 0;
uint8_t rate_limit_enabled = 0;
uint8_t stop_on_match = 0;
uint8_t stored_rule_channel = 0;
success = readExact(&active, sizeof(active))
&& readExact(&loaded[i].payload_type,
@@ -458,8 +469,8 @@ void FloodRuleEngine::load() {
sizeof(loaded[i].incoming_scope_kind))
&& readExact(loaded[i].incoming_scope_name,
sizeof(loaded[i].incoming_scope_name))
&& readExact(&loaded[i].channel_key_len,
sizeof(loaded[i].channel_key_len))
&& readExact(&stored_rule_channel,
sizeof(stored_rule_channel))
&& readExact(loaded[i].channel_secret,
sizeof(loaded[i].channel_secret))
&& readExact(loaded[i].channel_name,
@@ -483,6 +494,13 @@ void FloodRuleEngine::load() {
: FloodFilterPolicy::RULE_IN_ANY;
drop_on_match = loaded[i].scope_name[0] == 0 ? 1 : 0;
}
if (success && version_7) {
success = FloodFilterPolicy::decodeStoredRuleChannel(
stored_rule_channel, loaded[i].channel_key_len,
loaded[i].retry_on_match);
} else {
loaded[i].retry_on_match = false;
}
loaded[i].active = active != 0;
loaded[i].suspend_on_temp_radio = suspend_on_temp_radio != 0;
@@ -534,12 +552,23 @@ void FloodRuleEngine::load() {
&& loaded[i].incoming_scope_name[0] == 0;
}
bool channel_valid = loaded[i].channel_key_len == 0
|| loaded[i].channel_key_len == CIPHER_KEY_SIZE
|| loaded[i].channel_key_len == PUB_KEY_SIZE;
bool channel_valid = FloodFilterPolicy::channelKeyLengthSupported(
loaded[i].channel_key_len);
if (channel_valid && loaded[i].channel_key_len == 0) {
channel_valid = channel_name_terminated
&& loaded[i].channel_name[0] == 0;
} else if (channel_valid
&& FloodFilterPolicy::channelHashOnly(
loaded[i].channel_key_len)) {
channel_valid = channel_name_terminated;
if (channel_valid) {
loaded[i].channel_hash = loaded[i].channel_secret[0];
memset(&loaded[i].channel_secret[1], 0,
sizeof(loaded[i].channel_secret) - 1);
snprintf(loaded[i].channel_name,
sizeof(loaded[i].channel_name), "hash:%02X",
loaded[i].channel_hash);
}
} else if (channel_valid) {
channel_valid = channel_name_terminated
&& loaded[i].channel_name[0] != 0;
@@ -565,7 +594,7 @@ void FloodRuleEngine::load() {
&& loaded[i].path_hops <= PATH_PREFIX_HOPS_MAX);
bool action_valid = loaded[i].drop_on_match || direct_target
|| region_target || loaded[i].rate_limit_enabled
|| loaded[i].stop_on_match;
|| loaded[i].stop_on_match || loaded[i].retry_on_match;
if (!((loaded[i].payload_type <= PH_TYPE_MASK
|| loaded[i].payload_type == ANY_TYPE)
&& loaded[i].min_hops <= loaded[i].max_hops
@@ -581,6 +610,8 @@ void FloodRuleEngine::load() {
&& (direct_target || region_target))
&& !(loaded[i].drop_on_match
&& loaded[i].rate_limit_enabled)
&& !(loaded[i].drop_on_match
&& loaded[i].retry_on_match)
&& (!loaded[i].rate_limit_enabled
|| loaded[i].rate_per_minute < RATE_UNLIMITED)
&& (!loaded[i].scope_uses_slow_timing
@@ -695,6 +726,9 @@ bool FloodRuleEngine::save() {
uint8_t drop_on_match = entry.drop_on_match ? 1 : 0;
uint8_t rate_limit_enabled = entry.rate_limit_enabled ? 1 : 0;
uint8_t stop_on_match = entry.stop_on_match ? 1 : 0;
uint8_t stored_rule_channel =
FloodFilterPolicy::encodeStoredRuleChannel(
entry.channel_key_len, entry.retry_on_match);
success = writeExact(&active, sizeof(active))
&& writeExact(&entry.payload_type, sizeof(entry.payload_type))
&& writeExact(&entry.min_hops, sizeof(entry.min_hops))
@@ -712,7 +746,7 @@ bool FloodRuleEngine::save() {
sizeof(entry.incoming_scope_kind))
&& writeExact(entry.incoming_scope_name,
sizeof(entry.incoming_scope_name))
&& writeExact(&entry.channel_key_len, sizeof(entry.channel_key_len))
&& writeExact(&stored_rule_channel, sizeof(stored_rule_channel))
&& writeExact(entry.channel_secret, sizeof(entry.channel_secret))
&& writeExact(entry.channel_name, sizeof(entry.channel_name))
&& writeExact(&entry.path_hash_size, sizeof(entry.path_hash_size))
@@ -764,6 +798,8 @@ bool FloodRuleEngine::fieldsMatch(
uint16_t incoming_transport_code, bool incoming_region_allowed,
const RegionEntry* incoming_region) const {
if (!entry.active || packet == NULL || !packet->isRouteFlood()) return false;
if (!FloodFilterPolicy::channelKeyLengthSupported(
entry.channel_key_len)) return false;
if (entry.suspend_on_temp_radio && temp_radio_active) return false;
// FULL room servers do not carry the repeater's separate passive blacklist.
// Legacy blacklist-qualified rows stay inert instead of widening their match.
@@ -816,7 +852,12 @@ bool FloodRuleEngine::fieldsMatch(
bool FloodRuleEngine::authenticateChannel(
const Entry& entry, const mesh::Packet* packet) const {
if (entry.channel_key_len == 0) return true;
if (entry.channel_key_len == 0
|| FloodFilterPolicy::channelHashOnly(entry.channel_key_len)) {
return true;
}
if (!FloodFilterPolicy::channelRequiresAuthentication(
entry.channel_key_len)) return false;
uint8_t data[MAX_PACKET_PAYLOAD];
return mesh::Utils::MACThenDecrypt(
entry.channel_secret, data, &packet->payload[PATH_HASH_SIZE],
@@ -891,7 +932,8 @@ uint32_t FloodRuleEngine::evaluate(
continue;
}
bool authenticated = true;
if (entry.channel_key_len != 0) {
if (FloodFilterPolicy::channelRequiresAuthentication(
entry.channel_key_len)) {
int cached = -1;
for (int j = 0; j < i; j++) {
if (channel_auth_checked[j]
@@ -913,6 +955,21 @@ uint32_t FloodRuleEngine::evaluate(
return applyStop(result);
}
bool FloodRuleEngine::hasRetryRules() const {
for (int i = 0; i < RULE_SLOTS; i++) {
if (_entries[i].active && _entries[i].retry_on_match) return true;
}
return false;
}
bool FloodRuleEngine::allowsRetry(uint32_t match_mask) const {
for (int i = 0; i < RULE_SLOTS; i++) {
if ((match_mask & ((uint32_t)1U << i)) != 0
&& _entries[i].retry_on_match) return true;
}
return false;
}
bool FloodRuleEngine::applyScope(mesh::Packet* packet, uint32_t match_mask,
bool& scope_set, bool& fast_track,
bool log_change) {
@@ -1021,6 +1078,7 @@ void FloodRuleEngine::formatDetail(int index, char* reply,
char incoming[48];
char action[72];
char rate[24];
char retry[10];
formatHopSpec(hops, sizeof(hops), entry.min_hops, entry.max_hops);
if (entry.match_blacklisted_path) copyString(prefix, "blacklist", sizeof(prefix));
else formatPathPrefix(prefix, sizeof(prefix), entry.path_hash_size,
@@ -1072,13 +1130,18 @@ void FloodRuleEngine::formatDetail(int index, char* reply,
action[0] == 0 ? "" : " ",
(unsigned int)entry.rate_per_minute);
}
retry[0] = 0;
if (entry.retry_on_match) {
snprintf(retry, sizeof(retry), "%sretry",
action[0] == 0 && rate[0] == 0 ? "" : " ");
}
int written = snprintf(
reply, reply_len,
"> %d type=%s hops=%s channel=%s prefix=%s in=%s %s%s priority=%u%s%s%s",
"> %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,
entry.channel_key_len == 0 ? "*" : entry.channel_name,
prefix, incoming, action, rate,
prefix, incoming, action, rate, retry,
(unsigned int)entry.priority,
entry.stop_on_match ? " stop" : "",
entry.scope_uses_slow_timing ? " tx=slow" : "",
@@ -1135,10 +1198,12 @@ void FloodRuleEngine::formatDetail(int index, char* reply,
}
char compact_flags[8];
compact_flags[0] = 0;
if (entry.scope_uses_slow_timing || entry.suspend_on_temp_radio) {
snprintf(compact_flags, sizeof(compact_flags), " f=%s%s",
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.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",
@@ -1191,11 +1256,12 @@ 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",
snprintf(item, sizeof(item), " %d=%s@%s%s%s%s%s%s%s%s%s",
i + 1, payloadTypeName(entry.payload_type), hops,
entry.match_blacklisted_path ? "?blacklist" : "", target,
priority, entry.stop_on_match ? "~stop" : "",
entry.rate_limit_enabled ? "~rate" : "",
entry.retry_on_match ? "~retry" : "",
entry.scope_uses_slow_timing ? "~slow" : "",
entry.suspend_on_temp_radio ? "~tempradio" : "");
size_t item_len = strlen(item);
@@ -1311,6 +1377,7 @@ void FloodRuleEngine::set(const char* args, char* reply,
uint8_t priority = 0;
bool priority_set = false;
bool stop_on_match = false;
bool retry_on_match = false;
for (int i = 1; i < token_count; i++) {
if (asciiEqual(tokens[i], "suspend=tempradio")) {
@@ -1322,7 +1389,7 @@ void FloodRuleEngine::set(const char* args, char* reply,
} else if (asciiStartsWith(tokens[i], "f=")) {
const char* flags = tokens[i] + 2;
if (*flags == 0) {
copyString(reply, "Err - compact flags are s and/or t", 160);
copyString(reply, "Err - compact flags are s, t, and/or r", 160);
return;
}
while (*flags != 0) {
@@ -1339,8 +1406,14 @@ void FloodRuleEngine::set(const char* args, char* reply,
return;
}
suspend_on_temp_radio = true;
} else if (*flags == 'r' || *flags == 'R') {
if (retry_on_match) {
copyString(reply, DUPLICATE_OPTION, 160);
return;
}
retry_on_match = true;
} else {
copyString(reply, "Err - compact flags are s and/or t", 160);
copyString(reply, "Err - compact flags are s, t, and/or r", 160);
return;
}
flags++;
@@ -1430,6 +1503,15 @@ void FloodRuleEngine::set(const char* args, char* reply,
return;
}
stop_on_match = true;
} else if (asciiEqual(tokens[i], "retry")
|| asciiEqual(tokens[i], "retry=on")
|| asciiEqual(tokens[i], "retry=allow")
|| asciiEqual(tokens[i], "action=retry")) {
if (retry_on_match) {
copyString(reply, DUPLICATE_OPTION, 160);
return;
}
retry_on_match = true;
} else if (asciiStartsWith(tokens[i], "tx=")) {
if (scope_timing_set) {
copyString(reply, DUPLICATE_OPTION, 160);
@@ -1521,7 +1603,7 @@ void FloodRuleEngine::set(const char* args, char* reply,
channel_hash, channel_name,
sizeof(channel_name))) {
copyString(reply,
"Err - channel must be *, public, #channel, or a key",
"Err - channel must be *, public, #channel, hash:XX, or a key",
160);
return;
}
@@ -1566,6 +1648,10 @@ void FloodRuleEngine::set(const char* args, char* reply,
copyString(reply, "Err - drop cannot be combined with rate", 160);
return;
}
if (drop_on_match && retry_on_match) {
copyString(reply, "Err - drop cannot be combined with retry", 160);
return;
}
if (channel_key_len != 0 && payload_type != ANY_TYPE
&& payload_type != PAYLOAD_TYPE_GRP_TXT
&& payload_type != PAYLOAD_TYPE_GRP_DATA) {
@@ -1575,10 +1661,10 @@ void FloodRuleEngine::set(const char* args, char* reply,
return;
}
bool action_set = drop_set || target_set || rate_limit_enabled
|| stop_on_match;
|| stop_on_match || retry_on_match;
if (require_explicit_action && !action_set) {
copyString(reply,
"Err - flood.rule requires drop, scope=, region=, rate=, or stop",
"Err - flood.rule requires drop, scope=, region=, rate=, retry, or stop",
160);
return;
}
@@ -1612,6 +1698,7 @@ void FloodRuleEngine::set(const char* args, char* reply,
candidate.rate_per_minute = rate_per_minute;
candidate.priority = priority;
candidate.stop_on_match = stop_on_match;
candidate.retry_on_match = retry_on_match;
int slot = requested_slot;
if (slot < 0) {
@@ -34,6 +34,8 @@ public:
uint32_t now_millis) const;
void commitRates(const mesh::Packet* packet, uint32_t match_mask,
uint32_t now_millis);
bool hasRetryRules() const;
bool allowsRetry(uint32_t match_mask) const;
bool handleCommand(const char* command, char* reply);
@@ -62,6 +64,7 @@ private:
uint16_t rate_per_minute;
uint8_t priority;
bool stop_on_match;
bool retry_on_match;
uint32_t rate_window_started;
uint16_t rate_window_count;
bool rate_window_active;
+11
View File
@@ -489,6 +489,10 @@ uint32_t MyMesh::getDirectRetryAttemptDelay(const mesh::Packet* packet, uint8_t
bool MyMesh::allowFloodRetry(const mesh::Packet* packet) const {
if (_prefs.disable_fwd || _prefs.flood_retry_attempts == 0) return false;
if (packet != NULL
&& packet->flood_retry_policy == mesh::FLOOD_RETRY_POLICY_DENY) {
return false;
}
return packet == NULL || packet->getPayloadType() != PAYLOAD_TYPE_ADVERT
|| _prefs.flood_retry_advert_enabled;
}
@@ -569,6 +573,7 @@ mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) {
bool fast_track_scope_change = false;
recv_pkt_regionless_scope_set = false;
recv_pkt_rule_match_mask = 0;
pkt->flood_retry_policy = mesh::FLOOD_RETRY_POLICY_DEFAULT;
if (pkt->isRouteFlood()) {
bool incoming_region_allowed = false;
RegionEntry* incoming_region = NULL;
@@ -581,6 +586,12 @@ mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) {
}
recv_pkt_rule_match_mask = flood_rules.evaluate(
pkt, isTempRadioActive(), incoming_region_allowed, incoming_region);
if (flood_rules.hasRetryRules()) {
pkt->flood_retry_policy = flood_rules.allowsRetry(
recv_pkt_rule_match_mask)
? mesh::FLOOD_RETRY_POLICY_ALLOW
: mesh::FLOOD_RETRY_POLICY_DENY;
}
bool scope_set = false;
scope_changed = flood_rules.applyScope(
pkt, recv_pkt_rule_match_mask, scope_set, fast_track_scope_change);
+17
View File
@@ -104,6 +104,23 @@ test("treats channel star as no channel condition", () => {
assert.doesNotMatch(tool.buildDefinition(rule), /channel=/);
});
test("canonicalizes and matches one-byte channel hash selectors", () => {
const definition = "policy set short-channel phase=forward owner=filter priority=1 when route=flood type=grp_data hops=all channel=short:a7 do tag=seen";
const rule = tool.parseDefinition(definition);
assert.strictEqual(rule.channel, "hash:A7");
assert.match(tool.buildDefinition(rule), /channel=hash:A7/);
assert.strictEqual(tool.matchRule(rule, packet({ channel: "A7" })).matched, true);
assert.strictEqual(tool.matchRule(rule, packet({ channel: "hash:A8" })).matched, false);
assert.ok(tool.ruleWarnings(rule).some((warning) => /unauthenticated/.test(warning)));
});
test("extracts a visible group channel hash from raw packets", () => {
const decoded = tool.decodeRawPacketHex("15 00 A7 00 00");
assert.strictEqual(decoded.type, "grp_txt");
assert.strictEqual(decoded.channelHash, "hash:A7");
assert.ok(decoded.notes.some((note) => /hash:XX rule/.test(note)));
});
test("keeps a payload class and path bucket in one rule", () => {
const definition = "policy set other-bucket phase=rewrite owner=scope priority=130 when route=flood type=class:other hops=all path=bucket:2 do scope=#BlackHole86 timing=slow";
const rule = tool.parseDefinition(definition);
+2
View File
@@ -494,6 +494,7 @@ bool Dispatcher::tryParsePacket(Packet* pkt, const uint8_t* raw, int len) {
int i = 0;
pkt->tx_cr = 0;
pkt->flood_retry_policy = FLOOD_RETRY_POLICY_DEFAULT;
pkt->header = raw[i++];
if (pkt->getPayloadVer() > PAYLOAD_VER_1) {
MESH_DEBUG_PRINTLN("%s Dispatcher::checkRecv(): unsupported packet version", getLogDateTime());
@@ -725,6 +726,7 @@ Packet* Dispatcher::obtainNewPacket() {
pkt->payload_len = pkt->path_len = 0;
pkt->_snr = 0;
pkt->tx_cr = 0;
pkt->flood_retry_policy = FLOOD_RETRY_POLICY_DEFAULT;
}
return pkt;
}
+2
View File
@@ -9,6 +9,7 @@ Packet::Packet() {
path_len = 0;
payload_len = 0;
tx_cr = 0;
flood_retry_policy = FLOOD_RETRY_POLICY_DEFAULT;
}
bool Packet::isValidPathLen(uint8_t path_len) {
@@ -68,6 +69,7 @@ bool Packet::readFrom(const uint8_t src[], uint8_t len) {
uint8_t i = 0;
tx_cr = 0;
flood_retry_policy = FLOOD_RETRY_POLICY_DEFAULT;
header = src[i++];
if (getPayloadVer() > PAYLOAD_VER_1) return false;
+7
View File
@@ -4,6 +4,12 @@
namespace mesh {
enum FloodRetryPolicy : uint8_t {
FLOOD_RETRY_POLICY_DEFAULT = 0,
FLOOD_RETRY_POLICY_DENY = 1,
FLOOD_RETRY_POLICY_ALLOW = 2,
};
// Packet::header values
#define PH_ROUTE_MASK 0x03 // 2-bits
#define PH_TYPE_SHIFT 2
@@ -51,6 +57,7 @@ public:
uint8_t payload[MAX_PACKET_PAYLOAD];
int8_t _snr;
uint8_t tx_cr; // volatile local-only TX coding-rate override; not serialized
uint8_t flood_retry_policy; // volatile receive-policy result; not serialized
/**
* \brief calculate the hash of payload + type
+75
View File
@@ -22,6 +22,15 @@ static constexpr uint8_t SCOPE_PATH_BLACKLIST = 1;
static constexpr uint8_t SCOPE_PATH_BRIDGE_BUCKET_BASE = 2;
static constexpr uint8_t SCOPE_PATH_BRIDGE_BUCKET_COUNT = 6;
static constexpr uint8_t SCOPE_PATH_INVALID_BUCKET = 0xFF;
// FPF7 reserves the high bit of its stored channel-length byte for rule
// actions. The low six bits retain the channel matcher representation, so the
// row stays byte-compatible between generalized repeater and room engines.
static constexpr uint8_t STORED_RULE_RETRY_FLAG = 0x80;
static constexpr uint8_t STORED_RULE_RESERVED_FLAG = 0x40;
static constexpr uint8_t STORED_RULE_CHANNEL_LEN_MASK = 0x3F;
static constexpr uint8_t CHANNEL_HASH_ONLY_LEN = 1;
static constexpr uint8_t CHANNEL_KEY_128_LEN = 16;
static constexpr uint8_t CHANNEL_KEY_256_LEN = 32;
enum RuleIncomingScope : uint8_t {
RULE_IN_ANY = 0,
@@ -40,6 +49,72 @@ enum ChannelScopeGate {
CHANNEL_SCOPE_REQUIRED_REJECTED,
};
inline bool channelKeyLengthSupported(uint8_t key_len) {
return key_len == 0 || key_len == CHANNEL_HASH_ONLY_LEN
|| key_len == CHANNEL_KEY_128_LEN
|| key_len == CHANNEL_KEY_256_LEN;
}
inline bool channelHashOnly(uint8_t key_len) {
return key_len == CHANNEL_HASH_ONLY_LEN;
}
inline bool channelRequiresAuthentication(uint8_t key_len) {
return key_len == CHANNEL_KEY_128_LEN
|| key_len == CHANNEL_KEY_256_LEN;
}
inline uint8_t encodeStoredRuleChannel(uint8_t key_len,
bool retry_on_match) {
return (uint8_t)(key_len
| (retry_on_match ? STORED_RULE_RETRY_FLAG : 0));
}
inline bool decodeStoredRuleChannel(uint8_t stored, uint8_t& key_len,
bool& retry_on_match) {
if ((stored & STORED_RULE_RESERVED_FLAG) != 0) return false;
key_len = stored & STORED_RULE_CHANNEL_LEN_MASK;
retry_on_match = (stored & STORED_RULE_RETRY_FLAG) != 0;
return channelKeyLengthSupported(key_len);
}
inline int channelHashNibble(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
inline bool channelHashPrefixEqual(const char* text, const char* prefix) {
if (text == NULL || prefix == NULL) return false;
while (*prefix != 0) {
char left = *text++;
char right = *prefix++;
if (left >= 'A' && left <= 'Z') left += 'a' - 'A';
if (right >= 'A' && right <= 'Z') right += 'a' - 'A';
if (left != right) return false;
}
return true;
}
// Accept an explicit hash:XX spelling, the short:XX alias, or a bare byte.
// Callers always format the canonical spelling as hash:XX.
inline bool parseChannelHashMatcher(const char* text, uint8_t& channel_hash) {
if (text == NULL) return false;
const char* hex = text;
if (channelHashPrefixEqual(text, "hash:")) {
hex += 5;
} else if (channelHashPrefixEqual(text, "short:")) {
hex += 6;
}
if (hex[0] == 0 || hex[1] == 0 || hex[2] != 0) return false;
int high = channelHashNibble(hex[0]);
int low = channelHashNibble(hex[1]);
if (high < 0 || low < 0) return false;
channel_hash = (uint8_t)((high << 4) | low);
return true;
}
// Persist only through the highest occupied forward-rule slot. The on-disk
// formats are dense, so inactive holes before that slot must remain present,
// while the usually-large inactive tail need not consume LittleFS space.
@@ -1,5 +1,6 @@
#include <gtest/gtest.h>
#include <Utils.h>
#include <helpers/FloodFilterPolicy.h>
#include <helpers/RegionNameUtils.h>
@@ -38,6 +39,100 @@ TEST(FloodFilterPersistence, OldFullCountRemainsLoadCompatible) {
EXPECT_FALSE(FloodFilterPolicy::forwardPersistenceCountSupported(64, 63));
}
TEST(FloodRuleChannel, StoredRetryFlagPreservesTheChannelMatcher) {
uint8_t key_len = 0;
bool retry = false;
uint8_t stored = FloodFilterPolicy::encodeStoredRuleChannel(16, true);
EXPECT_TRUE(FloodFilterPolicy::decodeStoredRuleChannel(
stored, key_len, retry));
EXPECT_EQ(16, key_len);
EXPECT_TRUE(retry);
stored = FloodFilterPolicy::encodeStoredRuleChannel(
FloodFilterPolicy::CHANNEL_HASH_ONLY_LEN, false);
EXPECT_TRUE(FloodFilterPolicy::decodeStoredRuleChannel(
stored, key_len, retry));
EXPECT_EQ(FloodFilterPolicy::CHANNEL_HASH_ONLY_LEN, key_len);
EXPECT_FALSE(retry);
}
TEST(FloodRuleChannel, StoredReservedFlagAndUnknownLengthsAreRejected) {
uint8_t key_len = 0;
bool retry = false;
EXPECT_FALSE(FloodFilterPolicy::decodeStoredRuleChannel(
FloodFilterPolicy::STORED_RULE_RESERVED_FLAG, key_len, retry));
EXPECT_FALSE(FloodFilterPolicy::decodeStoredRuleChannel(
2, key_len, retry));
}
TEST(FloodRuleChannel, ParsesCanonicalAliasAndBareHashMatchers) {
uint8_t channel_hash = 0;
EXPECT_TRUE(FloodFilterPolicy::parseChannelHashMatcher(
"hash:A5", channel_hash));
EXPECT_EQ(0xA5, channel_hash);
EXPECT_TRUE(FloodFilterPolicy::parseChannelHashMatcher(
"SHORT:0c", channel_hash));
EXPECT_EQ(0x0C, channel_hash);
EXPECT_TRUE(FloodFilterPolicy::parseChannelHashMatcher(
"7F", channel_hash));
EXPECT_EQ(0x7F, channel_hash);
EXPECT_FALSE(FloodFilterPolicy::parseChannelHashMatcher(
"hash:7", channel_hash));
EXPECT_FALSE(FloodFilterPolicy::parseChannelHashMatcher(
"hash:700", channel_hash));
EXPECT_FALSE(FloodFilterPolicy::parseChannelHashMatcher(
"hash:GG", channel_hash));
}
TEST(FloodRuleChannel, HashOnlyMatchersDoNotRequestAuthentication) {
EXPECT_TRUE(FloodFilterPolicy::channelHashOnly(1));
EXPECT_FALSE(FloodFilterPolicy::channelRequiresAuthentication(1));
EXPECT_TRUE(FloodFilterPolicy::channelRequiresAuthentication(16));
EXPECT_TRUE(FloodFilterPolicy::channelRequiresAuthentication(32));
}
TEST(FloodRuleChannel, Hash11DoesNotAliasTheAuthenticatedPublicChannel) {
static const uint8_t public_channel_key[PUB_KEY_SIZE] = {
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72
};
static const uint8_t colliding_channel_key[PUB_KEY_SIZE] = {
0x0b
};
uint8_t public_channel_hash = 0;
mesh::Utils::sha256(&public_channel_hash, sizeof(public_channel_hash),
public_channel_key, CIPHER_KEY_SIZE);
uint8_t colliding_channel_hash = 0;
mesh::Utils::sha256(&colliding_channel_hash,
sizeof(colliding_channel_hash),
colliding_channel_key, CIPHER_KEY_SIZE);
uint8_t channel_hash = 0;
ASSERT_TRUE(FloodFilterPolicy::parseChannelHashMatcher(
"11", channel_hash));
EXPECT_EQ(0x11, public_channel_hash);
EXPECT_EQ(public_channel_hash, colliding_channel_hash);
EXPECT_EQ(0x11, channel_hash);
EXPECT_TRUE(FloodFilterPolicy::channelHashOnly(
FloodFilterPolicy::CHANNEL_HASH_ONLY_LEN));
EXPECT_FALSE(FloodFilterPolicy::channelRequiresAuthentication(
FloodFilterPolicy::CHANNEL_HASH_ONLY_LEN));
EXPECT_TRUE(FloodFilterPolicy::channelRequiresAuthentication(
FloodFilterPolicy::CHANNEL_KEY_128_LEN));
uint8_t plaintext[CIPHER_BLOCK_SIZE] = { 0x42 };
uint8_t framed[CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE] = {};
uint8_t recovered[CIPHER_BLOCK_SIZE] = {};
ASSERT_EQ((int)sizeof(framed), mesh::Utils::encryptThenMAC(
colliding_channel_key, framed, plaintext, sizeof(plaintext)));
EXPECT_EQ(0, mesh::Utils::MACThenDecrypt(
public_channel_key, recovered, framed, sizeof(framed)));
}
static mesh::Packet makeFloodPacket(uint8_t hash_size,
const uint8_t* path,
uint8_t path_hops) {
@@ -127,6 +127,25 @@ TEST(MQTTWireScratch, ZeroPayloadPacketSerializesAndRoundTrips) {
EXPECT_EQ(0, restored.payload_len);
}
TEST(MQTTWireScratch, FloodRetryPolicyIsLocalOnlyAndCopiesWithThePacket) {
mesh::Packet packet;
packet.header = ROUTE_TYPE_FLOOD;
packet.path_len = 0;
packet.payload_len = 0;
packet.flood_retry_policy = mesh::FLOOD_RETRY_POLICY_ALLOW;
mesh::Packet retry = packet;
EXPECT_EQ(mesh::FLOOD_RETRY_POLICY_ALLOW, retry.flood_retry_policy);
uint8_t wire[MAX_TRANS_UNIT] = {0};
uint8_t wire_len = packet.writeTo(wire);
mesh::Packet received;
received.flood_retry_policy = mesh::FLOOD_RETRY_POLICY_DENY;
ASSERT_TRUE(received.readFrom(wire, wire_len));
EXPECT_EQ(mesh::FLOOD_RETRY_POLICY_DEFAULT,
received.flood_retry_policy);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();