Add encoded path-hash width selectors to forwarding rules

This commit is contained in:
mikecarper committed 2026-10-02 10:07:48 -07:00
1 parent 946354760b
commit d084f5b837
12 files changed
+343 -29

No files matched your search

+31 -2
View File
@@ -210,6 +210,7 @@
hops: "all",
channel: "#rgdata",
incoming: "none",
hashBytes: 0,
pathKind: "none",
pathPrefix: "",
sender: "",
@@ -255,6 +256,8 @@
hops: normalizeHops(input.hops),
channel: normalizeChannel(input.channel, true),
incoming: normalizeIncoming(input.incoming),
hashBytes: ["", "0", "any", "*"].includes(clean(input.hashBytes).toLowerCase())
? 0 : requiredInteger(input.hashBytes, 1, 3, "Path hash bytes"),
pathKind: enumValue(input.pathKind, PATH_KINDS, "Path matcher", "none"),
pathPrefix: "",
sender: clean(input.sender),
@@ -276,6 +279,10 @@
if (rule.pathKind === "prefix") rule.pathPrefix = normalizePathPrefix(input.pathPrefix, 3);
if (rule.pathKind === "prefix" && !rule.pathPrefix) throw new FilterToolError("Prefix path matcher requires at least one pbyte ID.");
if (rule.pathKind === "prefix" && rule.hashBytes
&& rule.pathPrefix.split(",")[0].length / 2 !== rule.hashBytes) {
throw new FilterToolError("Path hash bytes conflicts with prefix width.");
}
if (rule.sender) {
rule.sender = requiredText(rule.sender, "Sender", 31);
if (rule.sender.includes(":")) throw new FilterToolError("Sender matcher cannot contain a colon.");
@@ -356,6 +363,7 @@
];
if (rule.channel) matches.push(`channel=${quoteDsl(rule.channel)}`);
if (rule.incoming !== "any") matches.push(`rx.scope=${quoteDsl(rule.incoming)}`);
if (rule.hashBytes) matches.push(`hashbytes=${rule.hashBytes}`);
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)}`);
@@ -462,6 +470,7 @@
else if (name === "hops") rule.hops = value;
else if (name === "channel") rule.channel = value;
else if (name === "rx.scope") rule.incoming = value;
else if (name === "hashbytes") rule.hashBytes = value;
else if (name === "path") {
if (value.toLowerCase().startsWith("prefix:")) {
rule.pathKind = "prefix";
@@ -568,6 +577,7 @@
else if (rule.pathKind === "blacklist") conditions.push("a path matching the passive blacklist");
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.hashBytes) conditions.push(`${rule.hashBytes}-byte encoded path hashes, including zero hops`);
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}`);
@@ -596,7 +606,9 @@
if (rule.verdict === "drop" && rule.route === "flood" && rule.type === "any"
&& rule.hops === "all" && !rule.channel && rule.incoming === "any"
&& rule.pathKind === "none" && rule.tempRadio === "any") {
warnings.push("This is a global drop rule across both flood route types.");
warnings.push(rule.hashBytes
? `This drops all ${rule.hashBytes}-byte path-hash packets across both flood route types.`
: "This is a global drop rule across both flood route types.");
}
if (limitsRemoteManagement(rule)) {
const zeroHop = minimumHops === 0 && rule.pathKind === "none";
@@ -622,6 +634,7 @@
bytes += 3 + 3 + 3;
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.hashBytes) bytes += 3;
if (rule.pathKind === "prefix") bytes += 4 + rule.pathPrefix.replace(/,/g, "").length / 2;
else if (rule.pathKind !== "none") bytes += 3;
if (rule.sender) bytes += 3 + rule.sender.length;
@@ -965,12 +978,20 @@
const buckets = clean(input.buckets)
? clean(input.buckets).split(",").map((value) => requiredInteger(value, 1, 6, "Path bucket"))
: [];
const path = normalizePacketPath(input.path);
const inferredWidth = path ? path.split(",")[0].length / 2 : 1;
const hashBytes = ["", "auto"].includes(clean(input.hashBytes).toLowerCase())
? inferredWidth : requiredInteger(input.hashBytes, 1, 3, "Packet path hash bytes");
if (path && hashBytes !== inferredWidth) {
throw new FilterToolError("Packet path hash bytes conflicts with received path width.");
}
return {
route: enumValue(input.route, ["unscoped_flood", "scoped_flood"], "Packet route", "unscoped_flood"),
type,
hops: requiredInteger(input.hops, 0, 63, "Packet hops"),
channel,
path: normalizePacketPath(input.path),
path,
hashBytes,
scopeStatus,
scopeName: clean(input.scopeName).replace(/^#/, ""),
regionName: clean(input.regionName),
@@ -1017,6 +1038,7 @@
if (packet.hops < minimum || packet.hops > maximum) misses.push(`hop ${packet.hops} is outside ${rule.hops}`);
if (rule.channel && rule.channel !== packet.channel) misses.push("channel differs or is unavailable");
if (!incomingMatches(rule.incoming, packet)) misses.push(`original scope does not satisfy ${rule.incoming}`);
if (rule.hashBytes && rule.hashBytes !== packet.hashBytes) misses.push(`path hash width is ${packet.hashBytes} bytes`);
if (rule.pathKind === "prefix") {
const wanted = rule.pathPrefix.split(",");
const actual = packet.path ? packet.path.split(",") : [];
@@ -1139,6 +1161,9 @@
}
const EXAMPLES = Object.freeze({
hash_width: Object.freeze([
{ ...documentedDropRule("drop-one-byte-paths", "any", "all"), hashBytes: 1 },
]),
channel_scope: Object.freeze([
{
...defaultRule("rgdata-scope"),
@@ -1312,6 +1337,7 @@
hops: field("hops").value,
channel: field("channel").value,
incoming: field("incoming").value,
hashBytes: field("hash-bytes").value,
pathKind: field("path-kind").value,
pathPrefix: field("path-prefix").value,
sender: field("sender").value,
@@ -1346,6 +1372,7 @@
hops: rule.hops,
channel: rule.channel,
incoming: rule.incoming,
"hash-bytes": rule.hashBytes,
"path-kind": rule.pathKind,
"path-prefix": rule.pathPrefix,
sender: rule.sender,
@@ -1575,6 +1602,7 @@
hops: packetField("hops").value,
channel: packetField("channel").value,
path: packetField("path").value,
hashBytes: packetField("hash-bytes").value,
scopeStatus: packetField("scope-status").value,
scopeName: packetField("scope-name").value,
regionName: packetField("region-name").value,
@@ -1593,6 +1621,7 @@
packetField("type").value = decoded.type;
packetField("hops").value = String(decoded.hops);
packetField("path").value = decoded.pathIds.join(",");
packetField("hash-bytes").value = String(decoded.pathHashBytes);
packetField("channel").value = decoded.channelHash;
packetField("scope-status").value = decoded.routeCode === 0 ? "unknown" : "none";
packetField("scope-name").value = "";
+9 -2
View File
@@ -2910,9 +2910,9 @@ worked moderation examples, see [Flood Filtering and Moderation](flood_filtering
- `get flood.rule`
- `get flood.rule.<n>`
- `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> [...]`
- `set fr[.<n>] <type> [h=<range>] [m=r|b|c|bc] [c=<channel>] [pb=any|1|2|3] <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]`
- `set flood.rule[.<n>] type=<type> [mode=radio|bridge|cross|bridge,cross] [hops=<range>] [channel=<channel>] [hashbytes=any|1|2|3] [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`
@@ -2968,6 +2968,12 @@ compile this table.
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.
- `hashbytes=any|1|2|3`: Optional encoded path-hash width, independent of hop
count and hash value. Omitted, `any`, or `*` matches every supported width.
Width-specific rules also match zero-hop packets. Four-byte hashes are
reserved and unsupported. A simultaneous `prefix=` must use the same width.
Example: `set fr any pb=1 d` drops all 1-byte floods from forwarding, including
relayed login/admin traffic, but not direct packets or local delivery.
- `prefix=<ID[,ID...]>`: Optional ordered source-path prefix of one to three
pbyte IDs. IDs must all be 2, 4, or 6 hex characters, matching a packet's
1-, 2-, or 3-byte pbyte width. `path=<prefix>` is an alias.
@@ -3217,6 +3223,7 @@ have the same behavior as their long forms.
| `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=...` |
| `hashbytes=any/1/2/3` | `pb=*/1/2/3` (choose one value) |
| `path=blacklist` | `p=bl` (repeater only) |
| `in=any/none/scoped/allowed/unknown` | `i=*/n/s/a/u` (choose one value) |
| `in=scope:name`, `in=region:name` | `i=s:name`, `i=r:name` |
+32
View File
@@ -24,6 +24,15 @@ Start with an example or build a rule, then test the draft against packet facts
in the simulator below. The examples draw from
[Flood Filtering and Moderation](flood_filtering.md).
To match every 1-byte path, select **Path hash width → 1 byte**, **type → ANY**,
all hops, no channel/input-scope restriction, and no prefix condition (or use
the 1-byte example below). This also matches zero-hop packets. The firmware command is
`set fr any pb=1 d` (or `set flood.rule type=any hashbytes=1 drop`); it adds a
forwarding-drop rule, including for relayed login/admin floods. Direct routes
and local delivery remain unaffected. Width-only rules require updated
firmware. The simulator's policy definitions remain design-preview text, not
installable commands.
<div class="filter-tool" data-filter-tool>
<div class="filter-management-safety" role="note">
<strong>Remote login and direct routes</strong>
@@ -68,6 +77,7 @@ in the simulator below. The examples draw from
<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>hashbytes=</code></td><td>Encoded path-hash width: 1, 2, or 3 bytes, including zero-hop packets</td></tr>
<tr><td><code>tempradio=</code></td><td>Temporary-radio state</td></tr>
<tr><td><code>do drop</code></td><td>Do not retransmit</td></tr>
<tr><td><code>do scope=</code></td><td>Set the outgoing transport scope</td></tr>
@@ -82,6 +92,10 @@ in the simulator below. The examples draw from
</p>
</div>
<div class="filter-example-grid">
<button type="button" data-example="hash_width">
<span>Drop all floods with 1-byte path hashes (including zero hops)</span>
<code>when type=any hops=all hashbytes=1 do drop</code>
</button>
<button type="button" data-example="channel_scope">
<span>Set #BlackHole86 scope on all #rgdata group traffic</span>
<code>when type=class:group hops=all channel=#rgdata do scope=#BlackHole86 timing=fast</code>
@@ -185,6 +199,15 @@ in the simulator below. The examples draw from
<option value="region:west"></option>
</datalist>
</label>
<label>
Path hash width
<select data-field="hash-bytes">
<option value="0">Any width</option>
<option value="1">1 byte</option>
<option value="2">2 bytes</option>
<option value="3">3 bytes</option>
</select>
</label>
<label>
Path matcher
<select data-field="path-kind">
@@ -497,6 +520,15 @@ in the simulator below. The examples draw from
Channel
<input data-packet="channel" value="#rgdata" placeholder="#name, public, hash:A7, key, or blank">
</label>
<label>
Encoded path hash width
<select data-packet="hash-bytes">
<option value="auto">Infer from path (empty defaults to 1 byte)</option>
<option value="1">1 byte</option>
<option value="2">2 bytes</option>
<option value="3">3 bytes</option>
</select>
</label>
<label>
Received pbyte path
<input data-packet="path" value="860C,12A4" placeholder="same-width IDs">
+26 -1
View File
@@ -445,7 +445,7 @@ The extended form is:
```text
set flood.rule[.<slot>] type=<type> [mode=radio|bridge|cross|bridge,cross]
[hops=<range>] [channel=<channel>]
[hops=<range>] [channel=<channel>] [hashbytes=any|1|2|3]
[prefix=<ID[,ID...]>] [in=<input-scope>] <action> [rate=<N>/min]
[priority=<0-255>] [stop] [tx=fast|slow] [suspend=tempradio]
get flood.rule
@@ -495,6 +495,12 @@ Match fields:
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.
- `hashbytes=any|1|2|3` matches the encoded width of each path hash, independent
of its value or the hop count. Omitted or `any` means any width; `*` is an
alias for `any`. It includes zero-hop packets carrying the selected width.
Four-byte path hashes are reserved and unsupported. Combine this with other
match fields as needed; a `prefix=` must have the same width when both are
specified. The compact alias is `pb=`.
- `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
@@ -684,6 +690,7 @@ set fr.3 any c=#wardriving m=bc d
| `mode=radio`, `bridge`, `cross`, `bridge,cross` | `m=r`, `m=b`, `m=c`, `m=bc` | Select the admission path. Omitted means radio; `cb`, `b,c`, and `c,b` also mean both transport paths. |
| `channel=...` | `c=...` | Same authenticated name/key or explicit `hash:XX` matcher. |
| `prefix=...` | `p=...` | Ordered path prefix. |
| `hashbytes=any`, `1`, `2`, or `3` | `pb=*`, `1`, `2`, or `3` | Encoded path-hash width, even with zero hops. |
| `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. |
@@ -698,6 +705,24 @@ 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.
### Filter every 1-byte flood path
```text
set flood.rule type=any hashbytes=1 drop
```
The equivalent short command is `set fr any pb=1 d`. This adds a rule in a free
slot without replacing the default rules. It stops this node forwarding all
1-byte flood packets, including advertisements and relayed login/admin traffic;
direct routes and local delivery are unchanged. Use a narrower `type=` or
`channel=` if those floods must remain reachable. Use `hashbytes=2` or `3` for
the other supported widths, and `get fr.<slot>` to copy the saved command.
Width-only rules require updated firmware. Existing rules remain unchanged and
the table uses no additional RAM or storage. Before downgrading to firmware
without this selector, remove width-only rules: its older reader considers them
invalid and can reset the rule table to defaults.
```text
get fr.3
```
+30 -10
View File
@@ -6135,14 +6135,9 @@ bool MyMesh::loadFloodPacketFilters() {
|| loaded[i].payload_type == PAYLOAD_TYPE_GRP_DATA;
}
bool path_valid = (loaded[i].path_hash_size == 0
&& loaded[i].path_hops == 0)
|| (!loaded[i].match_blacklisted_path
&& loaded[i].path_hash_size >= 1
&& loaded[i].path_hash_size <= 3
&& loaded[i].path_hops >= 1
&& loaded[i].path_hops
<= FLOOD_PACKET_FILTER_PATH_PREFIX_HOPS_MAX);
bool path_valid = FloodFilterPolicy::pathMatcherValid(
loaded[i].path_hash_size, loaded[i].path_hops,
loaded[i].match_blacklisted_path);
bool action_valid = loaded[i].drop_on_match || direct_target
|| region_target || loaded[i].rate_limit_enabled
|| loaded[i].stop_on_match || loaded[i].retry_on_match;
@@ -7429,6 +7424,10 @@ void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_
char action[64];
char rate[24];
char retry[10];
char hashbytes[16] = {};
if (entry.path_hops == 0 && entry.path_hash_size != 0)
snprintf(hashbytes, sizeof(hashbytes), " hashbytes=%u",
(unsigned)entry.path_hash_size);
formatFloodFilterHopSpec(hops, sizeof(hops), entry.min_hops, entry.max_hops);
if (entry.match_blacklisted_path) {
strcpy(prefix, "blacklist");
@@ -7490,11 +7489,11 @@ void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_
}
int written = snprintf(
reply, reply_len,
"> %d type=%s mode=%s hops=%s channel=%s prefix=%s in=%s %s%s%s priority=%u%s%s%s",
"> %d type=%s mode=%s hops=%s channel=%s prefix=%s%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,
prefix, hashbytes, incoming, action, rate, retry,
(unsigned int)entry.priority,
entry.stop_on_match ? " stop" : "",
entry.scope_uses_slow_timing ? " tx=slow" : "",
@@ -7641,6 +7640,8 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
bool match_blacklisted_path = false;
bool path_set = false;
uint8_t path_hash_size = 0;
bool hashbytes_set = false;
uint8_t hashbytes = 0;
uint8_t path_hops = 0;
uint8_t path[FLOOD_PACKET_FILTER_PATH_PREFIX_BYTES_MAX];
memset(path, 0, sizeof(path));
@@ -7936,6 +7937,18 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
}
channel_hash = hash_prefix[0];
}
} else if (floodFilterAsciiStartsWith(tokens[i], "hashbytes=")
|| floodFilterAsciiStartsWith(tokens[i], "pb=")) {
if (hashbytes_set) {
strcpy(reply, FLOOD_PACKET_FILTER_DUPLICATE);
return;
}
if (!FloodFilterPolicy::parsePathHashBytes(
strchr(tokens[i], '=') + 1, hashbytes)) {
strcpy(reply, "Err - hashbytes must be any, 1, 2, or 3");
return;
}
hashbytes_set = true;
} else if (floodFilterAsciiStartsWith(tokens[i], "prefix=")
|| floodFilterAsciiStartsWith(tokens[i], "p=")
|| (floodFilterAsciiStartsWith(tokens[i], "path=")
@@ -7970,6 +7983,13 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
return;
}
}
if (hashbytes != 0) {
if (path_hash_size != 0 && path_hash_size != hashbytes) {
strcpy(reply, "Err - hashbytes conflicts with prefix width");
return;
}
path_hash_size = hashbytes;
}
if (scope_timing_set && !target_set) {
strcpy(reply, "Err - tx timing requires scope= or region=");
return;
@@ -603,13 +603,9 @@ void FloodRuleEngine::load() {
|| loaded[i].payload_type == PAYLOAD_TYPE_GRP_DATA;
}
bool path_valid = (loaded[i].path_hash_size == 0
&& loaded[i].path_hops == 0)
|| (!loaded[i].match_blacklisted_path
&& loaded[i].path_hash_size >= 1
&& loaded[i].path_hash_size <= 3
&& loaded[i].path_hops >= 1
&& loaded[i].path_hops <= PATH_PREFIX_HOPS_MAX);
bool path_valid = FloodFilterPolicy::pathMatcherValid(
loaded[i].path_hash_size, loaded[i].path_hops,
loaded[i].match_blacklisted_path);
bool action_valid = loaded[i].drop_on_match || direct_target
|| region_target || loaded[i].rate_limit_enabled
|| loaded[i].stop_on_match || loaded[i].retry_on_match;
@@ -1110,6 +1106,10 @@ void FloodRuleEngine::formatDetail(int index, char* reply,
char action[72];
char rate[24];
char retry[10];
char hashbytes[16] = {};
if (entry.path_hops == 0 && entry.path_hash_size != 0)
snprintf(hashbytes, sizeof(hashbytes), " hashbytes=%u",
(unsigned)entry.path_hash_size);
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,
@@ -1169,11 +1169,11 @@ void FloodRuleEngine::formatDetail(int index, char* reply,
int written = snprintf(
reply, reply_len,
"> %d type=%s mode=%s hops=%s channel=%s prefix=%s in=%s %s%s%s priority=%u%s%s%s",
"> %d type=%s mode=%s hops=%s channel=%s prefix=%s%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,
prefix, hashbytes, incoming, action, rate, retry,
(unsigned int)entry.priority,
entry.stop_on_match ? " stop" : "",
entry.scope_uses_slow_timing ? " tx=slow" : "",
@@ -1319,6 +1319,8 @@ void FloodRuleEngine::set(const char* args, char* reply,
bool suspend_on_temp_radio = false;
bool path_set = false;
uint8_t path_hash_size = 0;
bool hashbytes_set = false;
uint8_t hashbytes = 0;
uint8_t path_hops = 0;
uint8_t path[PATH_PREFIX_BYTES_MAX];
memset(path, 0, sizeof(path));
@@ -1606,6 +1608,18 @@ void FloodRuleEngine::set(const char* args, char* reply,
return;
}
}
} else if (asciiStartsWith(tokens[i], "hashbytes=")
|| asciiStartsWith(tokens[i], "pb=")) {
if (hashbytes_set) {
copyString(reply, DUPLICATE_OPTION, 160);
return;
}
if (!FloodFilterPolicy::parsePathHashBytes(
strchr(tokens[i], '=') + 1, hashbytes)) {
copyString(reply, "Err - hashbytes must be any, 1, 2, or 3", 160);
return;
}
hashbytes_set = true;
} else if (asciiStartsWith(tokens[i], "prefix=")
|| asciiStartsWith(tokens[i], "p=")
|| asciiStartsWith(tokens[i], "path=")) {
@@ -1639,6 +1653,13 @@ void FloodRuleEngine::set(const char* args, char* reply,
}
}
if (hashbytes != 0) {
if (path_hash_size != 0 && path_hash_size != hashbytes) {
copyString(reply, "Err - hashbytes conflicts with prefix width", 160);
return;
}
path_hash_size = hashbytes;
}
if (scope_timing_set && !target_set) {
copyString(reply, "Err - tx timing requires scope= or region=", 160);
return;
+1 -1
View File
@@ -122,5 +122,5 @@ extra_javascript:
- _javascript/firmware_picker.js?v=20260929-4
- _javascript/telemetry_decoder.js
- _javascript/management_decoder.js
- _javascript/filter_tool.js
- _javascript/filter_tool.js?v=20261002-hashbytes
- _javascript/preset_test.js?v=20260923-2
+42
View File
@@ -390,4 +390,46 @@ test("combined transport selector survives readable and bundle round trips", ()
assert.strictEqual(tool.normalizeRule({ ...rule, via: "cross,bridge" }).via, "bridge,cross");
});
test("path hash width survives definitions and bundle round trips", () => {
assert.strictEqual(tool.buildDefinition(tool.EXAMPLES.hash_width[0]),
"policy set drop-one-byte-paths phase=forward owner=filter priority=100 when route=flood type=any hops=all hashbytes=1 do drop");
for (const hashBytes of [1, 2, 3]) {
const rule = tool.normalizeRule({ ...tool.EXAMPLES.factory[2], hashBytes });
const definition = tool.buildDefinition(rule);
assert.ok(definition.includes(`hashbytes=${hashBytes}`));
assert.strictEqual(tool.parseDefinition(definition).hashBytes, hashBytes);
assert.strictEqual(tool.decodeBundle(tool.encodeBundle([rule]))[0].hashBytes, hashBytes);
assert.match(tool.explainRule(rule), new RegExp(`${hashBytes}-byte encoded path hashes`));
}
});
test("width-only rules match all IDs and zero hops without widening other conditions", () => {
for (const wanted of [1, 2, 3]) {
const rule = { ...tool.EXAMPLES.factory[2], hashBytes: wanted };
for (const actual of [1, 2, 3]) {
for (const path of ["", "12".repeat(actual), "AB".repeat(actual)]) {
const facts = packet({ via: "bridge", channel: "#wardriving", path,
hops: path ? 1 : 0, hashBytes: actual });
assert.strictEqual(tool.matchRule(rule, facts).matched, wanted === actual);
assert.strictEqual(tool.matchRule(rule, { ...facts, channel: "#other" }).matched, false);
}
}
}
});
test("wildcard width preserves existing behavior and explicit conflicting widths fail", () => {
const base = tool.EXAMPLES.factory[2];
for (const hashBytes of [undefined, 0, "any", "*"])
assert.strictEqual(tool.normalizeRule({ ...base, hashBytes }).hashBytes, 0);
for (const hashBytes of [4, -1, "1x"])
assertToolError(() => tool.normalizeRule({ ...base, hashBytes }), /Path hash bytes/);
assertToolError(() => tool.normalizeRule({ ...base, hashBytes: 1,
pathKind: "prefix", pathPrefix: "1234" }), /conflicts with prefix width/);
assertToolError(() => tool.normalizePacket(packet({ hashBytes: 1 })), /conflicts with received path width/);
const decoded = tool.decodeRawPacketHex("15 80 A7 00 00");
assert.strictEqual(decoded.hops, 0);
assert.strictEqual(tool.normalizePacket(packet({ path: "", hops: 0,
hashBytes: decoded.pathHashBytes })).hashBytes, 3);
});
process.stdout.write(`# ${passed} filter-policy playground tests passed\n`);
+28 -3
View File
@@ -405,17 +405,42 @@ inline bool pathMatchesConfiguredIds(const mesh::Packet* packet,
packet, ids, configuredIdCount(ids, maximum_count));
}
// A nonzero width with no prefix hops is a width-only matcher. Reuse the
// existing FPF7 fields so runtime RAM and serialized row sizes stay fixed.
inline bool parsePathHashBytes(const char* text, uint8_t& width) {
if (text == NULL) return false;
if (strcmp(text, "*") == 0
|| (strlen(text) == 3 && channelHashPrefixEqual(text, "any"))) {
width = 0;
return true;
}
if (text[0] >= '1' && text[0] <= '3' && text[1] == 0) {
width = (uint8_t)(text[0] - '0');
return true;
}
return false;
}
inline bool pathMatcherValid(uint8_t width, uint8_t prefix_hops,
bool blacklist) {
return (width == 0 && prefix_hops == 0)
|| (width >= 1 && width <= 3 && prefix_hops <= 3
&& (prefix_hops == 0 || !blacklist));
}
inline bool pathStartsWith(const mesh::Packet* packet,
uint8_t hash_size,
uint8_t prefix_hops,
const uint8_t* prefix) {
if (prefix_hops == 0) return true;
if (packet == NULL || prefix == NULL || hash_size < 1 || hash_size > 3
if (hash_size == 0 && prefix_hops == 0) return true;
if (packet == NULL || hash_size < 1 || hash_size > 3
|| packet->getPathHashSize() != hash_size
|| packet->getPathHashCount() < prefix_hops) {
return false;
}
return memcmp(packet->path, prefix, hash_size * prefix_hops) == 0;
return prefix_hops == 0
|| (prefix != NULL
&& memcmp(packet->path, prefix, hash_size * prefix_hops) == 0);
}
inline bool ruleIncomingScopeMatches(uint8_t kind,
+2
View File
@@ -81,6 +81,8 @@ bool formatCompact(char* out, size_t capacity, unsigned slot, const Entry& entry
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.path_hops == 0 && entry.path_hash_size != 0)
text.append(" pb=%u", (unsigned)entry.path_hash_size);
if (entry.match_blacklisted_path) text.append(" p=bl");
else if (entry.path_hops) {
text.append(" p=");
@@ -294,6 +294,45 @@ TEST(FloodRulePrefix, RequiresTheExactPathWidthAndStartingSequence) {
&packet, 2, 3, path));
}
TEST(FloodRuleHashBytes, StrictSelectorAndStoredMatcherValidation) {
using namespace FloodFilterPolicy;
uint8_t width = 255;
for (const char* text : {"any", "ANY", "*"}) {
ASSERT_TRUE(parsePathHashBytes(text, width));
EXPECT_EQ(0, width);
}
for (uint8_t expected = 1; expected <= 3; expected++) {
char text[] = {char('0' + expected), 0};
ASSERT_TRUE(parsePathHashBytes(text, width));
EXPECT_EQ(expected, width);
EXPECT_TRUE(pathMatcherValid(width, 0, false));
EXPECT_TRUE(pathMatcherValid(width, 0, true));
EXPECT_TRUE(pathMatcherValid(width, 3, false));
EXPECT_FALSE(pathMatcherValid(width, 1, true));
}
for (const char* text : {"", "0", "4", "-1", "1x", "01", "anymore"})
EXPECT_FALSE(parsePathHashBytes(text, width));
EXPECT_FALSE(parsePathHashBytes(nullptr, width));
EXPECT_TRUE(pathMatcherValid(0, 0, false));
EXPECT_FALSE(pathMatcherValid(0, 1, false));
EXPECT_FALSE(pathMatcherValid(4, 0, false));
EXPECT_FALSE(pathMatcherValid(1, 4, false));
}
TEST(FloodRuleHashBytes, WidthOnlyMatchesEmptyAndNonemptyPaths) {
const uint8_t path[9] = {};
for (uint8_t actual = 1; actual <= 3; actual++) {
for (uint8_t hops : {0, 1, 3}) {
auto packet = makeFloodPacket(actual, path, hops);
EXPECT_TRUE(FloodFilterPolicy::pathStartsWith(&packet, 0, 0, nullptr));
for (uint8_t wanted = 1; wanted <= 3; wanted++)
EXPECT_EQ(actual == wanted,
FloodFilterPolicy::pathStartsWith(&packet, wanted, 0, nullptr));
}
}
EXPECT_FALSE(FloodFilterPolicy::pathStartsWith(nullptr, 1, 0, nullptr));
}
TEST(FloodRuleIncomingScope, MatchesOriginalScopeClasses) {
using namespace FloodFilterPolicy;
EXPECT_TRUE(ruleIncomingScopeMatches(
@@ -228,7 +228,10 @@ TEST_F(TransportFilter, ShorthandAndLongCommandsSaveIdenticalRules) {
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"}}) {
"set fr.3 any c=hash:A7 p=1234,5678 i=s q=3 r s"},
Pair{"set flood.rule.3 type=any hashbytes=1 drop", "set fr.3 any pb=1 d"},
Pair{"set flood.rule.3 type=any mode=bridge,cross hashbytes=3 rate=5/min",
"set fr.3 any m=bc pb=3 q=5"}}) {
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;
@@ -242,6 +245,75 @@ TEST_F(TransportFilter, ShorthandAndLongCommandsSaveIdenticalRules) {
}
}
TEST_F(TransportFilter, HashBytesMatchesEveryPayloadAndSurvivesReboot) {
command("del fr all");
for (uint8_t wanted = 1; wanted <= 3; wanted++) {
const std::string cmd = "set flood.rule.3 type=any hashbytes="
+ std::to_string(wanted) + " drop";
command(cmd.c_str()); ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
rules.begin(&fs, nullptr);
command("get flood.rule.3");
EXPECT_NE(nullptr, strstr(reply, ("hashbytes=" + std::to_string(wanted)).c_str()));
for (uint8_t actual = 1; actual <= 3; actual++)
for (uint8_t hops : {0, 1, 3})
for (uint8_t type = 0; type < 16; type++)
for (uint8_t route : {ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD,
ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT}) {
auto p = packet("#wardriving", type, route);
p.setPathHashSizeAndCount(actual, hops);
memset(p.path, 0x12, actual * hops);
// Radio filters never turn into direct-route filters.
const bool flood = route == ROUTE_TYPE_FLOOD || route == ROUTE_TYPE_TRANSPORT_FLOOD;
EXPECT_EQ(flood && actual == wanted, blocked(p, RULE_MODE_RADIO));
}
}
}
TEST_F(TransportFilter, HashBytesCombinesWithTransportChannelAndHops) {
command("set fr.3 any m=bc pb=2 c=#wardriving h=2+ d");
ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
rules.begin(&fs, nullptr);
for (uint8_t width = 1; width <= 3; width++)
for (uint8_t hops : {0, 1, 2, 3})
for (const char* channel : {"#wardriving", "#other"}) {
auto p = packet(channel);
p.setPathHashSizeAndCount(width, hops);
for (uint8_t mode : {RULE_MODE_BRIDGE, RULE_MODE_CROSS})
EXPECT_EQ(width == 2 && hops >= 2 && strcmp(channel, "#wardriving") == 0,
blocked(p, mode));
}
}
TEST_F(TransportFilter, HashBytesWildcardAndPrefixAreOrderIndependent) {
command("del fr all");
for (const char* tail : {"hashbytes=any", "pb=*", "prefix=* hashbytes=1", "pb=1 p=*",
"hashbytes=1 prefix=12", "prefix=12 pb=1", "prefix=12 pb=any"}) {
command((std::string("set fr.3 any ") + tail + " d").c_str());
ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
rules.begin(&fs, nullptr);
for (uint8_t width = 1; width <= 3; width++) {
auto p = packet();
p.setPathHashSizeAndCount(width, 1);
memset(p.path, 0x12, width);
const bool any_width = strcmp(tail, "hashbytes=any") == 0 || strcmp(tail, "pb=*") == 0;
EXPECT_EQ(any_width || width == 1, blocked(p, RULE_MODE_RADIO));
}
}
}
TEST_F(TransportFilter, InvalidHashBytesNeverReplacesSavedRules) {
command("set fr.3 any pb=1 d");
ASSERT_EQ(0, strncmp(reply, "OK", 2)) << reply;
const auto saved = fs.files;
for (const char* tail : {"hashbytes=0", "hashbytes=4", "pb=-1", "pb=01", "pb=1x", "pb=",
"pb=1 hashbytes=1", "hashbytes=any pb=2",
"hashbytes=1 prefix=1234", "prefix=1234 pb=1"}) {
command((std::string("set fr.3 any ") + tail + " d").c_str());
EXPECT_EQ(0, strncmp(reply, "Err", 3)) << tail << ": " << reply;
EXPECT_EQ(saved, fs.files);
}
}
TEST_F(TransportFilter, AliasesCanMixWithLongCommandsAndKeepStrictActions) {
install();
const auto full = fs.files["/flood_filter"];