From 8705e7f551e70c677af1a540c48184b8697242aa Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 25 Aug 2026 21:38:23 -0700 Subject: [PATCH] feat: add selective flood retry rules --- docs/_javascript/filter_tool.js | 16 +- docs/cli_commands.md | 54 +++++- docs/filter_tool.md | 6 +- docs/flood_filtering.md | 43 ++++- docs/halo_keymind_settings.md | 12 +- examples/simple_repeater/MyMesh.cpp | 165 +++++++++++++++--- examples/simple_repeater/MyMesh.h | 3 + .../simple_room_server/FloodRuleEngine.cpp | 129 +++++++++++--- examples/simple_room_server/FloodRuleEngine.h | 3 + examples/simple_room_server/MyMesh.cpp | 11 ++ scripts/test_filter_tool.js | 17 ++ src/Dispatcher.cpp | 2 + src/Packet.cpp | 2 + src/Packet.h | 7 + src/helpers/FloodFilterPolicy.h | 75 ++++++++ .../test_flood_filter_policy.cpp | 95 ++++++++++ .../test_mqtt_wire_scratch.cpp | 19 ++ 17 files changed, 597 insertions(+), 62 deletions(-) diff --git a/docs/_javascript/filter_tool.js b/docs/_javascript/filter_tool.js index 862a29c65..00fc1966c 100644 --- a/docs/_javascript/filter_tool.js +++ b/docs/_javascript/filter_tool.js @@ -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 = ""; diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 961a9fda1..ac0a2bb0f 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1952,7 +1952,7 @@ worked moderation examples, see [Flood Filtering and Moderation](flood_filtering **Usage:** - `get flood.rule` - `get flood.rule.` -- `set flood.rule[.] type= [hops=] [channel=] [prefix=] [in=] |region=|rate=/min|stop> [priority=<0-255>] [tx=slow] [suspend=tempradio]` +- `set flood.rule[.] type= [hops=] [channel=] [prefix=] [in=] |region=|rate=/min|retry|stop> [priority=<0-255>] [tx=slow] [suspend=tempradio]` - `del flood.rule.` - `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=`: 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=` 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:|r:` 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 diff --git a/docs/filter_tool.md b/docs/filter_tool.md index 46e5325be..f8bec9da3 100644 --- a/docs/filter_tool.md +++ b/docs/filter_tool.md @@ -65,7 +65,7 @@ in the simulator below. The examples draw from hops=Received hop count: all, 3+, 2-6, or 3 - channel=* means no channel condition; a name or key authenticates one group channel + channel=* means no channel condition; a name or key authenticates one group channel; hash:XX is an unauthenticated one-byte fallback rx.scope=Original incoming transport scope path=Path prefix, blacklist, bucket, or loop match tempradio=Temporary-radio state @@ -164,7 +164,7 @@ in the simulator below. The examples draw from