diff --git a/dmc-docs/custom-cli.de.md b/dmc-docs/custom-cli.de.md index 1f6b3b655d..e5b73085f9 100644 --- a/dmc-docs/custom-cli.de.md +++ b/dmc-docs/custom-cli.de.md @@ -15,6 +15,7 @@ ACL-Kontaktpakete werden übersprungen. Der Zustand wird in `/filter_prefs` gesp - `filter help` - `filter on` / `filter off` - `filter reset` +- `filter dryrun on|off` (Drops zählen, aber weiterhin weiterleiten; Statuszeile endet auf `(dry-run)`) - `filter types` - `filter count` - `filter hops ` (Typ `00`-`11`, Hops `0`-`64`) @@ -23,7 +24,18 @@ ACL-Kontaktpakete werden übersprungen. Der Zustand wird in `/filter_prefs` gesp - `filter channel list|add|remove <#name|Public>` (bis zu 16, nur GRP_TXT) - `filter hash ` (1-3, minimale Path-Hash-Größe) - `filter malformed on|off` (UTF-8-/Strukturvalidierung von öffentlichem GRP_TXT) -- `filter stats ` mit topic = `hops|rate|hash|channel|malformed|top` +- `filter advert ` (0-720; das Advert jedes Knotens höchstens einmal pro Fenster + weitergeleitet, `0` = aus) / `filter advert clear` +- `filter path list|add|remove ` (bis zu 8 Präfixe mit 2-8 Hex-Ziffern; verwirft alles, + was über einen Repeater mit diesem ID-Präfix kam) +- `filter sender add [secs] [prob]` / `remove` / `list` (bis zu 8; Name exakt, `Bot*` = Präfix; + `secs` 0 = blockieren, sonst eine Nachricht pro `secs` durchlassen; `prob` 1-100 = Anteil, den die Regel entscheidet) +- `filter text add [secs] [prob]` / `remove` / `list` (bis zu 8; Teilstring, `^` = Nachrichtenanfang) +- `filter watch add|remove|list <#name>` (bis zu 4 Kanäle, die die Regeln lesen dürfen, neben Public) +- `filter age |off` (1-10080; verwirft Gruppennachrichten auf Public und beobachteten + Kanälen, die älter sind, nach der Uhr des Repeaters; inaktiv, solange die Uhr nicht gestellt ist) +- `filter stats ` mit topic = `hops|rate|hash|channel|malformed|top|advert|path|air|sender|text|age` + (`air` = geschätzte eingesparte Sendezeit) ### Pakettypen diff --git a/dmc-docs/custom-cli.en.md b/dmc-docs/custom-cli.en.md index 85d88dc638..3899c61a54 100644 --- a/dmc-docs/custom-cli.en.md +++ b/dmc-docs/custom-cli.en.md @@ -14,6 +14,7 @@ packets bypass it. State is persisted to `/filter_prefs`. - `filter help` - `filter on` / `filter off` - `filter reset` +- `filter dryrun on|off` (count drops but keep forwarding; the status line ends in `(dry-run)`) - `filter types` - `filter count` - `filter hops ` (type `00`-`11`, hops `0`-`64`) @@ -22,7 +23,18 @@ packets bypass it. State is persisted to `/filter_prefs`. - `filter channel list|add|remove <#name|Public>` (up to 16, GRP_TXT only) - `filter hash ` (1-3, minimum path-hash size) - `filter malformed on|off` (UTF-8 / structure validation of public GRP_TXT) -- `filter stats ` where topic = `hops|rate|hash|channel|malformed|top` +- `filter advert ` (0-720; each node's advert forwarded at most once per window, + `0` = off) / `filter advert clear` +- `filter path list|add|remove ` (up to 8 prefixes of 2-8 hex digits; drops everything + that came through a repeater whose ID starts with it) +- `filter sender add [secs] [prob]` / `remove` / `list` (up to 8; exact name, `Bot*` = prefix; + `secs` 0 = block, else pass one message per `secs`; `prob` 1-100 = share of matches the rule decides) +- `filter text add [secs] [prob]` / `remove` / `list` (up to 8; substring, `^` = start of message) +- `filter watch add|remove|list <#name>` (up to 4 channels the rules may read, besides Public) +- `filter age |off` (1-10080; drop group texts on Public and watched channels older than + this, by the repeater's clock; inactive while the clock is not set) +- `filter stats ` where topic = `hops|rate|hash|channel|malformed|top|advert|path|air|sender|text|age` + (`air` = estimated airtime saved) ### Packet types diff --git a/dmc-docs/custom-cli.md b/dmc-docs/custom-cli.md index 617a6f259b..72084d7b65 100644 --- a/dmc-docs/custom-cli.md +++ b/dmc-docs/custom-cli.md @@ -15,6 +15,7 @@ ACL-contactpakketten worden overgeslagen. De status wordt bewaard in `/filter_pr - `filter help` - `filter on` / `filter off` - `filter reset` +- `filter dryrun on|off` (drops tellen maar wél doorsturen; statusregel eindigt op `(dry-run)`) - `filter types` - `filter count` - `filter hops ` (type `00`-`11`, hops `0`-`64`) @@ -23,7 +24,18 @@ ACL-contactpakketten worden overgeslagen. De status wordt bewaard in `/filter_pr - `filter channel list|add|remove <#naam|Public>` (max. 16, alleen GRP_TXT) - `filter hash ` (1-3, minimale path-hashgrootte) - `filter malformed on|off` (UTF-8-/structuurvalidatie van publieke GRP_TXT) -- `filter stats ` met topic = `hops|rate|hash|channel|malformed|top` +- `filter advert ` (0-720; elke node's advert hooguit één keer per venster doorgestuurd, + `0` = uit) / `filter advert clear` +- `filter path list|add|remove ` (max. 8 prefixen van 2-8 hexcijfers; blokkeert alles wat + via een repeater met dat ID-prefix kwam) +- `filter sender add [secs] [prob]` / `remove` / `list` (max. 8; naam exact, `Bot*` = prefix; + `secs` 0 = blokkeren, anders 1 bericht per `secs` doorlaten; `prob` 1-100 = aandeel dat de regel beslist) +- `filter text add [secs] [prob]` / `remove` / `list` (max. 8; substring, `^` = begin van het bericht) +- `filter watch add|remove|list <#naam>` (max. 4 kanalen die de regels mogen lezen, naast Public) +- `filter age |off` (1-10080; groepsberichten op Public en gevolgde kanalen die ouder zijn + dan dit worden gedropt, volgens de klok van de repeater; doet niets zolang de klok niet is gezet) +- `filter stats ` met topic = `hops|rate|hash|channel|malformed|top|advert|path|air|sender|text|age` + (`air` = geschatte bespaarde zendtijd) ### Pakkettypes diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 6c2dc46113..4a109c4807 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -211,9 +211,10 @@ Configuration and statistics are kept apart: `filter ` shows and change - `filter on` - `filter off` - `filter reset` — restore the default settings (counters are not affected) +- `filter dryrun` / `filter dryrun ` — count drops but keep forwarding, to size a setting before enforcing it - `filter types` — list the packet type IDs used by `hops`, `rate` and `count` -**Note:** The filter is disabled by default. Direct-routed packets and packets involving known ACL contacts always bypass it. +**Note:** The filter is disabled by default. Direct-routed packets and packets involving known ACL contacts always bypass it. While dry-run is on the status line ends in `(dry-run)`. --- @@ -241,6 +242,11 @@ filter - `filter stats channel` - `filter stats malformed` - `filter stats top` +- `filter stats advert` — drops by the per-origin advert window +- `filter stats path` — drops per blocked path prefix +- `filter stats air` — estimated time-on-air the drops saved (in dry-run: would have saved) +- `filter stats sender` / `filter stats text` — drops (and throttle passes) per sender/text rule +- `filter stats age` — group texts dropped by the message age limit (`filter age |off`) **Example:** ```text diff --git a/docs/packet_filter_reference.md b/docs/packet_filter_reference.md index 93842b9e49..343be8d0c9 100644 --- a/docs/packet_filter_reference.md +++ b/docs/packet_filter_reference.md @@ -6,11 +6,18 @@ The filter can selectively block forwarded packets based on: * Hop count * Per-packet-type rate limits +* Per-origin advert window (each node's advert at most once per N hours) +* Blocked path prefixes (isolate one upstream repeater) * Minimum path hash size * Name of a group channel +* Sender name or message text of a group text (block, throttle, or dose) * Malformed group text messages * Packet type +A **dry-run** mode counts every drop but still forwards, so a setting can be +sized before it is enforced. Every drop also adds the packet's estimated +time-on-air to a **saved airtime** counter. + The filter is **disabled by default**. Direct-routed packets bypass the filter. Priority packets involving known ACL contacts are also exempt. @@ -29,6 +36,8 @@ that setting actually dropped. See [Statistics](#statistics). | `filter help` | List every subcommand | | `filter on` / `filter off` | Enable or disable filtering | | `filter reset` | Restore all settings to their defaults | +| `filter dryrun` | Current dry-run state | +| `filter dryrun on` / `filter dryrun off` | Count drops without dropping (see [Dry-run](#dry-run)) | | `filter types` | List the packet type IDs | | `filter count` | Hop and rate drops per packet type | | `filter hops` | Current hop limits | @@ -41,13 +50,26 @@ that setting actually dropped. See [Statistics](#statistics). | `filter hash ` | Set the minimum path hash size | | `filter malformed` | Current malformed scan state | | `filter malformed on` / `filter malformed off` | Enable or disable the malformed scan | +| `filter advert` | Current per-origin advert window and cache fill | +| `filter advert ` | Set the window (0-720 h; 0 = off) | +| `filter advert clear` | Forget every remembered advert origin | +| `filter path list` | Blocked path prefixes | +| `filter path add ` / `filter path remove ` | Block or unblock a path prefix | +| `filter sender list` | Sender rules, in evaluation order | +| `filter sender add [secs] [prob]` | Block, throttle or dose a sender (see [Sender and text rules](#sender-and-text-rules)) | +| `filter sender remove ` | Remove a sender rule | +| `filter text list` / `filter text add [secs] [prob]` / `filter text remove ` | The same for message text | +| `filter watch list` | Channels the rules can read (Public plus the watch list) | +| `filter watch add <#name>` / `filter watch remove <#name>` | Add or remove a channel the rules read | +| `filter age` | Current message age limit and its drops | +| `filter age ` / `filter age off` | Drop group texts older than this (1-10080 min; see [Message age limit](#message-age-limit)) | | `filter stats ` | Drops for one topic, in detail | The same list is available on the device: ```text filter help -> filter [ help | on | off | reset | types | count | stats | hops | rate | channel | hash | malformed ] +> filter [ help | on | off | reset | dryrun | types | count | stats | hops | rate | channel | hash | malformed | advert | path | sender | text | watch | age ] ``` Commands that change a setting reply `> Filter: OK` unless noted otherwise. @@ -87,6 +109,35 @@ filter reset This resets the settings, not the counters. Statistics are cleared by `clear stats` and on reboot. +## Dry-run + +Not sure a limit is right? Turn on dry-run: every rule is evaluated and every +drop is **counted exactly as it would be**, but the packet is still forwarded. + +```text +filter dryrun on +> Filter: dry-run on +``` + +While it is on, the status line carries a marker so the counters are not +mistaken for real drops: + +```text +filter +> Filter on: Blocked [ Hops: 3 | Rate: 12 | Channel: 1 | Hash: 0 | Malformed: 2 ] (dry-run) +``` + +Watch `filter stats ` and `filter stats air` until the numbers look +right, then switch it off to enforce: + +```text +filter dryrun off +> Filter: dry-run off +``` + +Dry-run is persisted like every other setting, so a repeater left in dry-run +stays in dry-run across a reboot. Default: `off`. + --- # Packet Types @@ -250,6 +301,186 @@ Default limits: --- +# Per-Origin Advert Window + +The per-type rate limit caps the *total* advert budget, so a node that +re-advertises every few minutes eats the budget and legitimate nodes get +dropped with it. The advert window works per node instead: **each origin's +flood advert is forwarded at most once every N hours**. It still passes each +node's advert once per window, so every node stays reachable through this +repeater. + +Display the current window: + +```text +filter advert +> Filter: advert origin window 0h (cache 0/256) +``` + +Set the window: + +```text +filter advert +``` + +Example: + +```text +filter advert 48 +> Filter: OK +``` + +Each origin is now re-flooded at most once every 48 hours. Allowed values are +`0` to `720` hours (30 days); `0` turns the window off. Origins are keyed on +the first 4 bytes of the advert's public key and kept in a 256-entry cache; +when it fills, the oldest entry is forgotten. The cache lives in RAM, so after +a reboot every origin gets one free pass. + +Forget every remembered origin without changing the window: + +```text +filter advert clear +> Filter: advert cache cleared +``` + +The window runs *before* the per-type advert rate limit, so a repeat advert +never consumes the budget of a legitimate one. Drops are reported by +`filter stats advert`. + +Default: + +```text +0 (off) +``` + +--- + +# Blocked Path Prefixes + +Every flood packet carries the IDs of the repeaters that relayed it. When one +upstream repeater keeps injecting junk, block its ID prefix and everything that +travelled through it is dropped here, without needing the key to any channel. + +List the blocked prefixes: + +```text +filter path list +A1B2,C3 +``` + +With nothing blocked the reply is `None`. + +Block a prefix: + +```text +filter path add +``` + +Example: + +```text +filter path add a1b2 +> Filter: path A1B2 added +``` + +A prefix is 2 to 8 hex digits (1 to 4 bytes), case-insensitive. Up to +**8 prefixes** can be blocked. A packet is dropped when **any** entry of its +path starts with a blocked prefix. + +Unblock a prefix: + +```text +filter path remove A1B2 +> Filter: path A1B2 removed +``` + +Two things to know: + +* A prefix is compared against whole path entries, aligned to the packet's + path hash size. `A1B2` is one 2-byte ID: it matches a 2-byte entry starting + `A1B2`, never the two 1-byte entries `A1` then `B2`. A prefix **longer** than + the packet's hash size never matches, so to catch a repeater that floods with + 1-byte hashes, block its 1-byte prefix (`A1`). +* Short prefixes collide: `A1` also drops every other repeater whose ID starts + with `A1`. Use the longest prefix the traffic's hash size allows. + +Drops per prefix are reported by `filter stats path`. + +--- + +# Sender and Text Rules + +Group texts decrypt to `SenderName: message text`. A sender rule matches the +name, a text rule matches the message, and each rule either **blocks** every +match, **throttles** it (one match per N seconds slips past, the rest are +dropped), or **doses** it (the rule decides only a percentage of its matches). +This is the tool for one bot flooding a channel you would rather keep: slow it +down instead of muting the channel. + +```text +filter sender add [secs] [prob] +filter text add [secs] [prob] +``` + +| Argument | Meaning | +| -------- | ------- | +| `name` | Sender name, exact and case-sensitive. End it with `*` for a prefix (`Bot*` matches `Bot1`, `BotXY`); a lone `*` matches every sender. Up to 15 characters. | +| `pattern` | Text to find anywhere in the message, case-sensitive. Start it with `^` to match only at the beginning. One word, no spaces. Up to 23 characters. | +| `secs` | `0` (default) blocks. `1`–`65535` throttles: one matching message per that many seconds passes, the excess is dropped. | +| `prob` | `1`–`100` (default `100`): the share of matches the rule decides. `50` drops half. `0` is rejected: remove the rule instead. | + +Examples: + +```text +filter sender add SpamBot # drop everything SpamBot says +filter sender add Bob 60 # Bob gets one message a minute; the rest is dropped +filter sender add Bot* 0 50 # every Bot… name loses half its messages +filter text add ^BEACON # drop messages that start with BEACON +filter text add RX_in_place 600 # at most one such report per 10 minutes +filter sender remove Bob +``` + +How the rules are applied: + +* Sender rules are evaluated top to bottom, then text rules, on every group + text the repeater can decrypt. **The first rule that decides drops the + packet.** A rule that *steps aside* (a failed dosing roll, or a throttle pass + within budget) leaves the decision to the next rule, so a throttled sender + can still be caught by a later catch-all. +* A throttle window is not extended by over-rate messages: a sender pushing + hard still gets exactly one pass per window. Throttle state lives in RAM, so + the first match after a reboot is a free pass. +* Dosing rolls per received packet with the same random source as the soft + cutoff; a retransmitted copy rolls again. +* Only **plain** group texts carry a `Sender: text` shape. Binary group data, + adverts and everything else never match a sender or text rule. +* Up to **8 sender rules** and **8 text rules**. Adding a rule that already + exists, or beyond the limit, fails. + +## Which channels the rules can read + +The rules only see traffic the repeater can decrypt. **Public** is always +readable. For a `#name` channel the key is derived from the name, exactly as +the companion apps do, so add it to the watch list and it becomes readable too: + +```text +filter watch add #bots +> Filter: watch #bots added +filter watch list +Public (11),#bots (a3) +``` + +Up to **4** watched channels. Private channels with a shared key cannot be +watched. A watched channel is *read*, not blocked; to block one use +`filter channel add`. A message on a watched channel is only decrypted when +at least one sender or text rule exists or the [message age limit](#message-age-limit) +is on, so the watch list costs nothing until you use it. + +Drops per rule are reported by `filter stats sender` and `filter stats text`; +throttle passes are shown alongside. + +--- + # Channel Blocking List blocked channels, with the channel hash byte the filter matches on: @@ -369,6 +600,10 @@ When enabled, Group Text packets are checked for: * Non-empty text * Valid UTF-8 encoding +The ±1 week check is skipped while the repeater's clock has not been set (see +[Message age limit](#message-age-limit)); a zero timestamp is always rejected. +The malformed scan reads the Public channel only. + Default: ```text @@ -377,6 +612,43 @@ off --- +# Message Age Limit + +Drops group texts whose timestamp is older than a set number of minutes, so old +messages that are replayed or trickle in late are not repeated again. + +```text +filter age 60 +> Filter: OK +filter age +> Message age: max 60m, dropped 0 +filter age off +> Filter: OK +``` + +* Range **1-10080 minutes** (one week). `off` or `0` turns it off. Default: off. +* It checks the time the **sender** put in the message against the repeater's + own clock. A message stamped in the future is never counted as old. +* Only channels the repeater can read are checked: **Public**, plus any `#` + channel on the [watch list](#which-channels-the-rules-can-read). Direct + messages and other channels are encrypted with keys the repeater does not + have, so their age cannot be read and they are never dropped by this. +* It works independently of `filter malformed`. + +**The repeater's clock must be right.** A repeater without a battery-backed +clock or GPS starts at a default date after a reboot, until it is synced (for +example with `clock sync` from a companion app). While the clock reads earlier +than 2026, the age limit does nothing, and `filter age` shows +`(clock not set, inactive)`. A clock that is set but wrong makes the limit +wrong in the same way, so check `clock` before relying on it. + +**Choose a margin for other people's clocks.** A phone or node whose clock runs +behind stamps its messages in the past. With a tight limit its messages look +old and are dropped. Start generous (for example `filter age 60`), try it with +`filter dryrun on`, and check `filter stats age` before going lower. + +--- + # Statistics Display filter status: @@ -419,7 +691,7 @@ detail that would not fit on the summary line. ```text filter stats -> filter stats [ hops | rate | channel | hash | malformed | top ] +> filter stats [ hops | rate | channel | hash | malformed | top | advert | path | air | sender | text | age ] ``` An unknown topic returns that same list. @@ -435,6 +707,11 @@ printing an empty table: | `filter stats malformed` | `> Filter: no malformed drops recorded` | | `filter stats top` | `> Filter: no source drops recorded` | | `filter stats channel` | `None`, when no channels are blocked | +| `filter stats advert` | `> Filter: advert origin limit off`, when the window is 0 | +| `filter stats path` | `None`, when no prefixes are blocked | +| `filter stats air` | `> Filter: no airtime saved yet` | +| `filter stats sender` / `filter stats text` | `None`, when no rule is set | +| `filter stats age` | `> Filter: message age limit off`, when no limit is set | The counters live in RAM, so this is also what you see after a reboot or a `clear stats`. @@ -506,6 +783,60 @@ and REQ, RESPONSE, TXT\_MSG, ANON\_REQ and PATH packets. ACK and TRACE hold no identity and group traffic is encrypted, so those drops appear only in the totals. +### advert + +```text +filter stats advert +> Advert origins: window 48h, dropped 132, cache 87/256 +``` + +The window in force, the adverts dropped because their origin had already +passed inside it, and how many origins the cache currently remembers. + +### age + +```text +filter stats age +> Message age: max 60m, dropped 37 +``` + +The limit in force and the group texts it dropped. While the clock is not set +the line ends in `(clock not set, inactive)`. + +### path + +```text +filter stats path +A1B2: 412,C3: 0 +``` + +Drops per blocked prefix, so you can tell which entry is doing the work. + +### sender and text + +```text +filter sender list +Bob throttle 60s,Bot* block 50%,SpamBot block + +filter stats sender +Bob: 12 (pass 3),Bot*: 40,SpamBot: 5 +``` + +The setting view shows each rule's mode; the stats view shows the drops it +caused and, for a throttle, how many messages it let pass within budget. + +### air + +```text +filter stats air +> Filter: saved airtime 214500 ms (3m 34s) +``` + +The estimated time-on-air the dropped packets would have taken to retransmit, +using the same estimate the repeater bills its own airtime with. In dry-run it +is what the drops *would* have saved. This is usually the number that matters +on a shared channel. + ## Limits Counters saturate rather than wrap, so a busy repeater reports `4294967295` @@ -532,6 +863,14 @@ The following settings persist across reboots: * Blocked channels * Minimum hash size * Malformed message filtering +* Dry-run +* Advert window (hours) +* Blocked path prefixes +* Sender rules, text rules and the watch list +* Message age limit + +Files written by older firmware are shorter; the settings they do not contain +load at their defaults. The advert origin cache is not persisted. --- @@ -563,6 +902,7 @@ filter malformed on filter rate 05 10 60 filter rate 02 5 60 filter rate 04 5 60 +filter advert 24 filter hops 05 16 filter hops 02 16 filter hops 04 8 @@ -588,14 +928,53 @@ channel, so you can tell which entry is doing the work. Take into account that t --- +## Slowing One Sender Down Instead of Muting Them + +```text +filter watch add #test +filter sender add Bob 60 +filter stats sender # Bob: (pass ) +``` + +Bob still gets one message a minute through this repeater. Like every sender +rule this matches the *name in the message*, not a verified identity: a +renamed sender evades it. + +## Isolating One Upstream Repeater + +```text +filter path add +``` + +Monitor `filter stats path` to confirm the entry is doing the work, and +remember that a short prefix also catches every other repeater whose ID starts +the same way. + +## Sizing a Setting Before Enforcing It + +```text +filter dryrun on +... apply the settings you are considering ... +filter stats air +filter dryrun off +``` + +Nothing is dropped while dry-run is on; the counters show what *would* be. + +--- + # Important Notes * Filtering is disabled by default. You have to enable it. +* In dry-run every drop is counted but nothing is dropped; the status line ends in `(dry-run)`. * Only forwarded packets are filtered. * Direct-routed packets always bypass the filter. * Channel blocking only affects `GRP_TXT` packets. * Malformed message validation only applies to `GRP_TXT` packets. -* Rate limits are applied per packet type, not per sender. +* Rate limits are applied per packet type, not per sender. The advert window is the exception: it is per origin. +* Path prefixes are matched against whole path entries; a prefix longer than the packet's hash size never matches. +* Sender and text rules read only Public and watched `#` channels, only plain group texts, and match names as text, not verified identities. +* The message age limit reads the same channels and trusts the repeater's clock; it is inactive while that clock is not set. * Statistics live in RAM only. They are cleared on reboot and by `clear stats`, and they are never written to `/filter_prefs`. * Counters saturate at their maximum rather than wrapping around to zero. * A reply that does not fit the 160-byte CLI buffer ends in `..`. @@ -611,9 +990,15 @@ channel, so you can tell which entry is doing the work. Take into account that t | `> Filter: error range is 0-11` | Packet type outside `00`–`11` | | `> Filter: error range is 0-64` | Hop limit outside `0`–`64` | | `> Filter: error hash bytes range is 1-3` | Minimum path hash size outside `1`–`3` | +| `> Filter: error range is 0-720` | Advert window outside `0`–`720` | +| `> Filter: error range is 1-10080, or off` | Message age limit out of range or not a number | +| `> Filter: error path prefix is 2-8 hex digits` | Path prefix empty, odd-length, longer than 4 bytes, or not hex | +| `> Filter: error range is 0-65535, range is 1-100` | Sender/text rule argument out of range or not a number | +| `> Filter: error max 15 chars` / `max 23 chars` | Sender name or text pattern too long | +| `> Filter: syntax error 'filter dryrun '` | Wrong argument | | `> Filter: syntax error 'filter hops '` | Wrong number of arguments | | `> Filter: syntax error 'filter rate '` | Wrong number of arguments | | `> Filter: syntax error 'filter channel [list \| add \| remove] <#name \| Public>'` | Wrong number of arguments | -| `Failed` | `filter channel add` with the list full, or `remove` with no such channel | +| `Failed` | `filter channel add` with the list full, or `remove` with no such channel; `filter path add` with the list full or the prefix already blocked, or `remove` with no such prefix; the same for `filter sender`, `filter text` and `filter watch` (`watch add Public` also fails: it is always watched) | An unknown `filter stats` topic is not an error: it returns the list of topics. diff --git a/examples/simple_repeater/AdvertLimiter.h b/examples/simple_repeater/AdvertLimiter.h new file mode 100644 index 0000000000..7e8e460be9 --- /dev/null +++ b/examples/simple_repeater/AdvertLimiter.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include + +// Per-origin advert rate limiter: each node's flood advert is forwarded at +// most once per window, so a node that re-advertises every few minutes stops +// being re-flooded by this repeater while it still stays reachable through it +// once per window. Complements the per-type rate limiter, which caps the total +// advert budget and would otherwise drop legitimate nodes together with the +// chatty one. +// +// Origins are keyed on the first 4 bytes of the advert's public key. The cache +// is a ring of fixed size: once full, the oldest entry is overwritten. Time is +// millis-based and compared with unsigned subtraction, so it survives the +// 49-day wrap. Header-only and dependency-free so it runs on the `native` env. + +#define ADVERT_CACHE_SIZE 256 +#define ADVERT_KEY_LEN 4 +#define ADVERT_MAX_HOURS 720 // 30 days; keeps hours*3600*1000 inside uint32 + +class AdvertLimiter { + struct Entry { + uint8_t key[ADVERT_KEY_LEN]; + uint32_t seen_ms; + }; + + Entry _cache[ADVERT_CACHE_SIZE]; + uint16_t _count; // entries in use + uint16_t _head; // next entry to overwrite once the cache is full + uint16_t _hours; // 0 = off + +public: + AdvertLimiter() : _count(0), _head(0), _hours(0) { memset(_cache, 0, sizeof(_cache)); } + + void setWindowHours(uint16_t hours) { _hours = hours > ADVERT_MAX_HOURS ? ADVERT_MAX_HOURS : hours; } + uint16_t getWindowHours() const { return _hours; } + int getCount() const { return _count; } + int getCapacity() const { return ADVERT_CACHE_SIZE; } + + void clear() { + _count = 0; + _head = 0; + } + + // True when the advert from `key` may be forwarded now (and records it), + // false when the same origin already passed inside the current window. + bool allow(const uint8_t* key, uint32_t now_ms) { + if (_hours == 0 || key == nullptr) return true; + + uint32_t window_ms = (uint32_t)_hours * 3600UL * 1000UL; + + for (int i = 0; i < _count; i++) { + Entry& e = _cache[i]; + if (memcmp(e.key, key, ADVERT_KEY_LEN) != 0) continue; + if (now_ms - e.seen_ms < window_ms) return false; // unsigned: wrap-safe + e.seen_ms = now_ms; // window elapsed: pass, and start a new window + return true; + } + + Entry* e; + if (_count < ADVERT_CACHE_SIZE) { + e = &_cache[_count++]; + } else { + e = &_cache[_head]; // overwrite the oldest entry + _head = (uint16_t)((_head + 1) % ADVERT_CACHE_SIZE); + } + memcpy(e->key, key, ADVERT_KEY_LEN); + e->seen_ms = now_ms; + return true; + } +}; diff --git a/examples/simple_repeater/Filter.cpp b/examples/simple_repeater/Filter.cpp index ea48c5a03a..d3c5a2d40a 100644 --- a/examples/simple_repeater/Filter.cpp +++ b/examples/simple_repeater/Filter.cpp @@ -4,12 +4,32 @@ // close to the limit, so guard it at compile time rather than let a future // subcommand overflow it silently. static const char FILTER_HELP[] = - "> filter [ help | on | off | reset | types | count | stats | hops | rate | channel | hash | malformed ]"; + "> filter [ help | on | off | reset | dryrun | types | count | stats | hops | rate | channel | hash | malformed | advert | path | sender | text | watch | age ]"; static const char FILTER_STATS_HELP[] = - "> filter stats [ hops | rate | channel | hash | malformed | top ]"; + "> filter stats [ hops | rate | channel | hash | malformed | top | advert | path | air | sender | text | age ]"; +static const char FILTER_PATH_HELP[] = + "> filter path [ list | add | remove ] "; +static const char FILTER_RULE_HELP[] = + "> filter sender|text [ list | add [secs] [prob] | remove ]"; +static const char FILTER_WATCH_HELP[] = + "> filter watch [ list | add | remove ] <#name>"; +static const char FILTER_RULE_ARGS_ERR[] = + "> Filter: error range is 0-65535, range is 1-100"; static_assert(sizeof(FILTER_HELP) <= FILTER_REPLY_SIZE, "filter help text no longer fits the reply buffer"); static_assert(sizeof(FILTER_STATS_HELP) <= FILTER_REPLY_SIZE, "filter stats help text no longer fits the reply buffer"); +static_assert(sizeof(FILTER_PATH_HELP) <= FILTER_REPLY_SIZE, "filter path help text no longer fits the reply buffer"); +static_assert(sizeof(FILTER_RULE_HELP) <= FILTER_REPLY_SIZE, "filter rule help text no longer fits the reply buffer"); +static_assert(sizeof(FILTER_WATCH_HELP) <= FILTER_REPLY_SIZE, "filter watch help text no longer fits the reply buffer"); + +bool Filter::drop(const mesh::Packet* packet) { + // The airtime this drop saves is what the repeater would have billed for + // relaying it; in dry-run it is what the drop *would* save. + if (_radio != nullptr) { + FilterStat::recordAir(_cnt, _radio->getEstAirtimeFor(packet->getRawLength())); + } + return _prefs.dryrun; // dry-run: counted, but still forwarded +} bool Filter::allowPacketForward(const mesh::Packet* packet) { if (!_prefs.filter_enabled) return true; @@ -29,7 +49,16 @@ bool Filter::allowPacketForward(const mesh::Packet* packet) { if (packet->getPathHashSize() < _prefs.minimal_hash_bytes) { FilterStat::recordHash(_cnt, packet->getPathHashSize(), type); if (have_src) FilterStat::recordSrc(_cnt, src); - return false; + return drop(packet); + } + + // blocked path prefixes (a rogue upstream repeater) + int path_slot = FilterPath::findMatch(packet->path, packet->getPathHashSize(), packet->getPathHashCount(), + _prefs.path_block, FILTER_PATH_COUNT); + if (path_slot >= 0) { + FilterStat::recordPath(_cnt, path_slot); + if (have_src) FilterStat::recordSrc(_cnt, src); + return drop(packet); } if (type < PAYLOAD_TYPE_COUNT) { @@ -37,13 +66,22 @@ bool Filter::allowPacketForward(const mesh::Packet* packet) { if (packet->getPathHashCount() >= _prefs.payload_prefs[type].hops_max) { FilterStat::recordHops(_cnt, type); if (have_src) FilterStat::recordSrc(_cnt, src); - return false; + return drop(packet); + } + // per-origin advert window, ahead of the per-type limiter so a repeat + // advert never eats the budget of a legitimate one + if (type == PAYLOAD_TYPE_ADVERT && packet->payload_len >= ADVERT_KEY_LEN) { + if (!_advert.allow(packet->payload, millis())) { // payload starts with the origin's pub_key + FilterStat::recordAdvert(_cnt); + if (have_src) FilterStat::recordSrc(_cnt, src); + return drop(packet); + } } // rate limiter (with optional probabilistic soft cutoff) if (!_limiters[type].allow(_rtc->getCurrentTime(), nextRandom())) { FilterStat::recordRate(_cnt, type); if (have_src) FilterStat::recordSrc(_cnt, src); - return false; + return drop(packet); } } @@ -53,7 +91,7 @@ bool Filter::allowPacketForward(const mesh::Packet* packet) { // regardless of the malformed scan setting. Counted so the drop is visible. if (packet->payload_len <= PATH_HASH_SIZE + CIPHER_MAC_SIZE) { FilterStat::recordMalformed(_cnt, MALFORMED_SHORT); - return false; + return drop(packet); } uint8_t channel_hash = packet->payload[0]; @@ -64,19 +102,59 @@ bool Filter::allowPacketForward(const mesh::Packet* packet) { if (ch.name[0] == '\0') continue; if (channel_hash == ch.channel.hash[0]) { FilterStat::recordChannel(_cnt, i); - return false; + return drop(packet); } } - // malformed - if (_prefs.filter_malformed) { - if (channel_hash == PUBLIC_CHANNEL_HASH) { - uint8_t data[MAX_PACKET_PAYLOAD + 1]; - int len = mesh::Utils::MACThenDecrypt(PUBLIC_CHANNEL_SECRET, data, &packet->payload[PATH_HASH_SIZE], packet->payload_len - PATH_HASH_SIZE); + // Content checks need the plaintext: the malformed scan (Public only), and + // the age limit and sender/text rules (Public plus the watch list). Decrypt once. + bool want_malformed = _prefs.filter_malformed && channel_hash == PUBLIC_CHANNEL_HASH; + bool want_rules = hasContentRules(); + const uint8_t* secret = (want_rules || _prefs.age_mins > 0) ? watchedSecret(channel_hash) : nullptr; + if (want_malformed || secret != nullptr) { + uint8_t data[MAX_PACKET_PAYLOAD + 1]; + int len = mesh::Utils::MACThenDecrypt(secret != nullptr ? secret : PUBLIC_CHANNEL_SECRET, data, + &packet->payload[PATH_HASH_SIZE], packet->payload_len - PATH_HASH_SIZE); + + // malformed + if (want_malformed) { uint8_t reason; if (!validMessageContent(data, len, &reason)) { FilterStat::recordMalformed(_cnt, reason); - return false; + return drop(packet); + } + } + + // message age; len < 4 covers a failed MAC (hash collision with a + // channel we do not hold the key for), which has no timestamp to read + if (secret != nullptr && _prefs.age_mins > 0 && len >= 4) { + uint32_t ts; + memcpy(&ts, &data[0], 4); + if (MessageAge::tooOld(ts, _rtc->getCurrentTime(), _prefs.age_mins)) { + FilterStat::recordAge(_cnt); + return drop(packet); + } + } + + // sender / text rules; len == 0 means the MAC failed (hash collision with + // a channel we do not hold the key for), so there is nothing to match + if (secret != nullptr && want_rules && len > 0) { + FilterRules::GroupText gt; + if (FilterRules::parseGroupText(data, len, >)) { + uint32_t now = millis(); + uint8_t roll = nextRandom() % 100; + int slot = FilterRules::evaluate(_prefs.sender_rules, FILTER_RULE_COUNT, now, roll, _sender_last, _cnt.sender_pass, + [&](const SenderRule& r) { return FilterRules::matchName(r.name, gt.sender, gt.sender_len); }); + if (slot >= 0) { + FilterStat::recordSender(_cnt, slot); + return drop(packet); + } + slot = FilterRules::evaluate(_prefs.text_rules, FILTER_RULE_COUNT, now, roll, _text_last, _cnt.text_pass, + [&](const TextRule& r) { return FilterRules::matchText(r.text, gt.text, gt.text_len); }); + if (slot >= 0) { + FilterStat::recordText(_cnt, slot); + return drop(packet); + } } } } @@ -136,7 +214,7 @@ void Filter::handleCommand(FILESYSTEM* fs, char* command, char* reply) { int n = mesh::Utils::parseTextParts(command, parts, 6, ' '); if (n <= 1) { - FilterStat::formatSummary(reply, FILTER_REPLY_SIZE, _cnt, _prefs.filter_enabled); + FilterStat::formatSummary(reply, FILTER_REPLY_SIZE, _cnt, _prefs.filter_enabled, _prefs.dryrun); } if (n == 2) { @@ -170,6 +248,20 @@ void Filter::handleCommand(FILESYSTEM* fs, char* command, char* reply) { sprintf(reply, "> Filter: minimal %d bytes path hash size", _prefs.minimal_hash_bytes); } else if (strcmp(parts[1], "malformed") == 0) { sprintf(reply, "> Filter: malformed text scan %s", _prefs.filter_malformed ? "on" : "off"); + } else if (strcmp(parts[1], "dryrun") == 0) { + sprintf(reply, "> Filter: dry-run %s", _prefs.dryrun ? "on" : "off"); + } else if (strcmp(parts[1], "advert") == 0) { + sprintf(reply, "> Filter: advert origin window %uh (cache %d/%d)", (unsigned)_prefs.advert_hours, + _advert.getCount(), _advert.getCapacity()); + } else if (strcmp(parts[1], "path") == 0) { + strcpy(reply, FILTER_PATH_HELP); + } else if (strcmp(parts[1], "sender") == 0 || strcmp(parts[1], "text") == 0) { + strcpy(reply, FILTER_RULE_HELP); + } else if (strcmp(parts[1], "watch") == 0) { + strcpy(reply, FILTER_WATCH_HELP); + } else if (strcmp(parts[1], "age") == 0) { + FilterStat::formatStatsAge(reply, FILTER_REPLY_SIZE, _cnt, _prefs.age_mins, + MessageAge::clockSet(_rtc->getCurrentTime())); } else { strcpy(reply, "> Filter: command error"); } @@ -191,6 +283,20 @@ void Filter::handleCommand(FILESYSTEM* fs, char* command, char* reply) { FilterStat::formatStatsMalformed(reply, FILTER_REPLY_SIZE, _cnt); } else if (strcmp(parts[2], "top") == 0) { FilterStat::formatStatsTop(reply, FILTER_REPLY_SIZE, _cnt); + } else if (strcmp(parts[2], "advert") == 0) { + FilterStat::formatStatsAdvert(reply, FILTER_REPLY_SIZE, _cnt, _prefs.advert_hours, + _advert.getCount(), _advert.getCapacity()); + } else if (strcmp(parts[2], "path") == 0) { + FilterStat::formatPathList(reply, FILTER_REPLY_SIZE, _prefs.path_block, &_cnt); + } else if (strcmp(parts[2], "air") == 0) { + FilterStat::formatStatsAir(reply, FILTER_REPLY_SIZE, _cnt); + } else if (strcmp(parts[2], "sender") == 0) { + FilterStat::formatRuleList(reply, FILTER_REPLY_SIZE, _prefs.sender_rules, FILTER_RULE_COUNT, _cnt.sender_slot, _cnt.sender_pass); + } else if (strcmp(parts[2], "text") == 0) { + FilterStat::formatRuleList(reply, FILTER_REPLY_SIZE, _prefs.text_rules, FILTER_RULE_COUNT, _cnt.text_slot, _cnt.text_pass); + } else if (strcmp(parts[2], "age") == 0) { + FilterStat::formatStatsAge(reply, FILTER_REPLY_SIZE, _cnt, _prefs.age_mins, + MessageAge::clockSet(_rtc->getCurrentTime())); } else { strcpy(reply, FILTER_STATS_HELP); } @@ -285,12 +391,277 @@ void Filter::handleCommand(FILESYSTEM* fs, char* command, char* reply) { strcpy(reply, "> Filter: malformed scan off"); save(fs); } + + // dryrun + } else if (strcmp(parts[1], "dryrun") == 0) { + if (strcmp(parts[2], "on") == 0) { + _prefs.dryrun = true; + strcpy(reply, "> Filter: dry-run on"); + save(fs); + } else if (strcmp(parts[2], "off") == 0) { + _prefs.dryrun = false; + strcpy(reply, "> Filter: dry-run off"); + save(fs); + } else { + strcpy(reply, "> Filter: syntax error 'filter dryrun '"); + } + + // advert + } else if (strcmp(parts[1], "advert") == 0) { + if (strcmp(parts[2], "clear") == 0) { + _advert.clear(); + strcpy(reply, "> Filter: advert cache cleared"); + } else { + long hours = atol(parts[2]); + if (hours < 0 || hours > ADVERT_MAX_HOURS) { + strcpy(reply, "> Filter: error range is 0-720"); + } else { + _prefs.advert_hours = (uint16_t)hours; + _advert.setWindowHours(_prefs.advert_hours); + save(fs); + strcpy(reply, "> Filter: OK"); + } + } + + // message age + } else if (strcmp(parts[1], "age") == 0) { + const char* v = parts[2]; + bool digits = v[0] != '\0'; + for (const char* c = v; *c; c++) { + if (*c < '0' || *c > '9') { digits = false; break; } + } + long mins = digits ? atol(v) : -1; + if (strcmp(v, "off") == 0) mins = 0; + if (mins < 0 || mins > FILTER_AGE_MAX_MINS) { + strcpy(reply, "> Filter: error range is 1-10080, or off"); + } else { + _prefs.age_mins = (uint16_t)mins; + save(fs); + strcpy(reply, "> Filter: OK"); + } + + // path + } else if (strcmp(parts[1], "path") == 0) { + char hex[2 * FILTER_PATH_MAX_LEN + 1]; + if (strcmp(parts[2], "list") == 0) { + FilterStat::formatPathList(reply, FILTER_REPLY_SIZE, _prefs.path_block, nullptr); + } else if (n >= 4 && strcmp(parts[2], "add") == 0) { + if (addPath(parts[3], hex)) { + sprintf(reply, "> Filter: path %s added", hex); + save(fs); + } else if (hex[0] == '\0') { + strcpy(reply, "> Filter: error path prefix is 2-8 hex digits"); + } else { + strcpy(reply, "Failed"); + } + } else if (n >= 4 && strcmp(parts[2], "remove") == 0) { + if (removePath(parts[3], hex)) { + sprintf(reply, "> Filter: path %s removed", hex); + save(fs); + } else if (hex[0] == '\0') { + strcpy(reply, "> Filter: error path prefix is 2-8 hex digits"); + } else { + strcpy(reply, "Failed"); + } + } else { + strcpy(reply, FILTER_PATH_HELP); + } + + // sender / text rules: list | add [secs] [prob] | remove + } else if (strcmp(parts[1], "sender") == 0 || strcmp(parts[1], "text") == 0) { + bool is_sender = strcmp(parts[1], "sender") == 0; + if (strcmp(parts[2], "list") == 0) { + if (is_sender) { + FilterStat::formatRuleList(reply, FILTER_REPLY_SIZE, _prefs.sender_rules, FILTER_RULE_COUNT, nullptr, nullptr); + } else { + FilterStat::formatRuleList(reply, FILTER_REPLY_SIZE, _prefs.text_rules, FILTER_RULE_COUNT, nullptr, nullptr); + } + } else if (n >= 4 && strcmp(parts[2], "add") == 0) { + uint16_t secs; + uint8_t prob; + size_t max_len = is_sender ? FILTER_SENDER_LEN - 1 : FILTER_TEXT_LEN - 1; + if (strlen(parts[3]) > max_len) { + sprintf(reply, "> Filter: error max %u chars", (unsigned)max_len); + } else if (!FilterRules::parseArgs(n >= 5 ? parts[4] : nullptr, n >= 6 ? parts[5] : nullptr, &secs, &prob)) { + strcpy(reply, FILTER_RULE_ARGS_ERR); + } else if (is_sender ? addSenderRule(parts[3], secs, prob) : addTextRule(parts[3], secs, prob)) { + sprintf(reply, "> Filter: %s %s added", parts[1], parts[3]); + save(fs); + } else { + strcpy(reply, "Failed"); + } + } else if (n >= 4 && strcmp(parts[2], "remove") == 0) { + if (is_sender ? removeSenderRule(parts[3]) : removeTextRule(parts[3])) { + sprintf(reply, "> Filter: %s %s removed", parts[1], parts[3]); + save(fs); + } else { + strcpy(reply, "Failed"); + } + } else { + strcpy(reply, FILTER_RULE_HELP); + } + + // watch list: channels decrypted for the sender/text rules + } else if (strcmp(parts[1], "watch") == 0) { + if (strcmp(parts[2], "list") == 0) { + listWatchNames(reply, FILTER_REPLY_SIZE); + } else if (n >= 4 && strcmp(parts[2], "add") == 0) { + if (addWatch(parts[3])) { + sprintf(reply, "> Filter: watch %s added", parts[3]); + save(fs); + } else { + strcpy(reply, "Failed"); + } + } else if (n >= 4 && strcmp(parts[2], "remove") == 0) { + if (removeWatch(parts[3])) { + sprintf(reply, "> Filter: watch %s removed", parts[3]); + save(fs); + } else { + strcpy(reply, "Failed"); + } + } else { + strcpy(reply, FILTER_WATCH_HELP); + } } else { strcpy(reply, "> Filter: command error"); } } } +// ---- sender / text rules ------------------------------------------------------- + +template +static bool addRule(Rule* rules, char* (*field)(Rule&), const char* pattern, size_t max_len, uint16_t secs, uint8_t prob) { + if (pattern == nullptr || pattern[0] == '\0' || strlen(pattern) > max_len) return false; + + int free_slot = -1; + for (int i = 0; i < FILTER_RULE_COUNT; i++) { + char* pat = field(rules[i]); + if (pat[0] == '\0') { + if (free_slot < 0) free_slot = i; + } else if (strcmp(pat, pattern) == 0) { + return false; // already present + } + } + if (free_slot < 0) return false; + + Rule& r = rules[free_slot]; + memset(&r, 0, sizeof(r)); + strncpy(field(r), pattern, max_len); + r.secs = secs; + r.prob = prob; + return true; +} + +template +static int findRule(Rule* rules, char* (*field)(Rule&), const char* pattern) { + if (pattern == nullptr || pattern[0] == '\0') return -1; + for (int i = 0; i < FILTER_RULE_COUNT; i++) { + if (strcmp(field(rules[i]), pattern) == 0) return i; + } + return -1; +} + +static char* senderField(SenderRule& r) { return r.name; } +static char* textField(TextRule& r) { return r.text; } + +bool Filter::addSenderRule(const char* name, uint16_t secs, uint8_t prob) { + return addRule(_prefs.sender_rules, senderField, name, FILTER_SENDER_LEN - 1, secs, prob); +} + +bool Filter::removeSenderRule(const char* name) { + int i = findRule(_prefs.sender_rules, senderField, name); + if (i < 0) return false; + memset(&_prefs.sender_rules[i], 0, sizeof(SenderRule)); + _sender_last[i] = 0; + return true; +} + +bool Filter::addTextRule(const char* text, uint16_t secs, uint8_t prob) { + return addRule(_prefs.text_rules, textField, text, FILTER_TEXT_LEN - 1, secs, prob); +} + +bool Filter::removeTextRule(const char* text) { + int i = findRule(_prefs.text_rules, textField, text); + if (i < 0) return false; + memset(&_prefs.text_rules[i], 0, sizeof(TextRule)); + _text_last[i] = 0; + return true; +} + +bool Filter::hasContentRules(void) const { + for (int i = 0; i < FILTER_RULE_COUNT; i++) { + if (_prefs.sender_rules[i].name[0] != '\0') return true; + if (_prefs.text_rules[i].text[0] != '\0') return true; + } + return false; +} + +// The key to decrypt a group text with, when the rules may read it: Public is +// always readable, plus every channel on the watch list. nullptr otherwise. +const uint8_t* Filter::watchedSecret(uint8_t channel_hash) const { + if (channel_hash == PUBLIC_CHANNEL_HASH) return PUBLIC_CHANNEL_SECRET; + for (int i = 0; i < FILTER_WATCH_COUNT; i++) { + const ChannelDetails& ch = _prefs.watch_channels[i]; + if (ch.name[0] == '\0') continue; + if (ch.channel.hash[0] == channel_hash) return ch.channel.secret; + } + return nullptr; +} + +bool Filter::addWatch(const char* name) { + if (name == nullptr || name[0] == '\0' || strlen(name) >= sizeof(ChannelDetails::name)) return false; + if (strcmp(name, "Public") == 0) return false; // always watched + + int free_slot = -1; + for (int i = 0; i < FILTER_WATCH_COUNT; i++) { + ChannelDetails& ch = _prefs.watch_channels[i]; + if (ch.name[0] == '\0') { + if (free_slot < 0) free_slot = i; + } else if (strcmp(ch.name, name) == 0) { + return false; + } + } + if (free_slot < 0) return false; + + ChannelDetails& ch = _prefs.watch_channels[free_slot]; + strncpy(ch.name, name, sizeof(ch.name) - 1); + ch.name[sizeof(ch.name) - 1] = '\0'; + getChannelHash(name, &ch.channel); + return true; +} + +bool Filter::removeWatch(const char* name) { + if (name == nullptr || name[0] == '\0') return false; + for (int i = 0; i < FILTER_WATCH_COUNT; i++) { + ChannelDetails& ch = _prefs.watch_channels[i]; + if (strcmp(ch.name, name) == 0) { + ch.name[0] = '\0'; + return true; + } + } + return false; +} + +void Filter::listWatchNames(char* out_buf, size_t out_size) { + if (out_buf == nullptr || out_size == 0) return; + + FilterStat::Buf b(out_buf, out_size); + char channel_hex[4]; + int listed = 0; + + b.add("Public (11)"); + listed++; + for (int i = 0; i < FILTER_WATCH_COUNT; i++) { + const ChannelDetails& ch = _prefs.watch_channels[i]; + if (ch.name[0] == '\0') continue; + mesh::Utils::toHex(channel_hex, ch.channel.hash, 1); + b.add(",%s (%s)", ch.name, channel_hex); + listed++; + } + b.markTruncated(".."); +} + void Filter::formatResponse(char *reply, ResponseType rtype) { FilterStat::Buf b(reply, FILTER_REPLY_SIZE); @@ -337,6 +708,44 @@ bool Filter::removeChannel(const char* name) { return false; } +// `formatted` receives the canonical upper-case prefix, or "" when `hex` did +// not parse, so the caller can tell a bad prefix from a full or missing slot. +bool Filter::addPath(const char* hex, char* formatted) { + formatted[0] = '\0'; + PathPrefix p; + if (!FilterPath::parse(hex, &p)) return false; + FilterPath::format(formatted, 2 * FILTER_PATH_MAX_LEN + 1, p); + + int free_slot = -1; + for (int i = 0; i < FILTER_PATH_COUNT; i++) { + PathPrefix& cur = _prefs.path_block[i]; + if (cur.len == 0) { + if (free_slot < 0) free_slot = i; + } else if (cur.len == p.len && memcmp(cur.bytes, p.bytes, p.len) == 0) { + return false; // already blocked + } + } + if (free_slot < 0) return false; + _prefs.path_block[free_slot] = p; + return true; +} + +bool Filter::removePath(const char* hex, char* formatted) { + formatted[0] = '\0'; + PathPrefix p; + if (!FilterPath::parse(hex, &p)) return false; + FilterPath::format(formatted, 2 * FILTER_PATH_MAX_LEN + 1, p); + + for (int i = 0; i < FILTER_PATH_COUNT; i++) { + PathPrefix& cur = _prefs.path_block[i]; + if (cur.len == p.len && memcmp(cur.bytes, p.bytes, p.len) == 0) { + memset(&cur, 0, sizeof(cur)); + return true; + } + } + return false; +} + bool Filter::getChannelHash(const char* name, mesh::GroupChannel* gc) { if (name == nullptr || name[0] == '\0' || gc == nullptr) return false; @@ -389,10 +798,9 @@ bool Filter::validMessageContent(const uint8_t* data, uint8_t len, uint8_t* reas } // check timestamp - uint32_t now = _rtc->getCurrentTime(); uint32_t timestamp; memcpy(×tamp, &data[0], 4); - if (!timestamp || timestamp < now - INVALID_TIMESTAMP_WINDOW || timestamp > now + INVALID_TIMESTAMP_WINDOW) { + if (MessageAge::implausible(timestamp, _rtc->getCurrentTime())) { *reason = MALFORMED_TIMESTAMP; return false; } @@ -479,9 +887,33 @@ bool Filter::load(FILESYSTEM* fs) { size_t got = file.read(reinterpret_cast(&_prefs), sizeof(_prefs)); - // Pre-soft-cutoff files are shorter: the trailing soft_limit[] bytes weren't - // written, so discard whatever landed there (padding) and default them off. - if (got < sizeof(_prefs)) memset(_prefs.soft_limit, 0, sizeof(_prefs.soft_limit)); + // Files written by older firmware are shorter: each field appended since is + // defaulted when the read did not cover it, so a newer field never inherits + // whatever landed there. + if (!FilterStat::fieldLoaded(got, offsetof(FilterPrefs, soft_limit), sizeof(_prefs.soft_limit))) { + memset(_prefs.soft_limit, 0, sizeof(_prefs.soft_limit)); + } + if (!FilterStat::fieldLoaded(got, offsetof(FilterPrefs, advert_hours), sizeof(_prefs.advert_hours))) { + _prefs.advert_hours = 0; + } + if (!FilterStat::fieldLoaded(got, offsetof(FilterPrefs, dryrun), sizeof(_prefs.dryrun))) { + _prefs.dryrun = false; + } + if (!FilterStat::fieldLoaded(got, offsetof(FilterPrefs, path_block), sizeof(_prefs.path_block))) { + memset(_prefs.path_block, 0, sizeof(_prefs.path_block)); + } + if (!FilterStat::fieldLoaded(got, offsetof(FilterPrefs, sender_rules), sizeof(_prefs.sender_rules))) { + memset(_prefs.sender_rules, 0, sizeof(_prefs.sender_rules)); + } + if (!FilterStat::fieldLoaded(got, offsetof(FilterPrefs, text_rules), sizeof(_prefs.text_rules))) { + memset(_prefs.text_rules, 0, sizeof(_prefs.text_rules)); + } + if (!FilterStat::fieldLoaded(got, offsetof(FilterPrefs, watch_channels), sizeof(_prefs.watch_channels))) { + for (int i = 0; i < FILTER_WATCH_COUNT; i++) _prefs.watch_channels[i] = ChannelDetails(); + } + if (!FilterStat::fieldLoaded(got, offsetof(FilterPrefs, age_mins), sizeof(_prefs.age_mins))) { + _prefs.age_mins = 0; + } _prefs.filter_enabled = constrain(_prefs.filter_enabled, 0, 1); @@ -493,6 +925,26 @@ bool Filter::load(FILESYSTEM* fs) { } _prefs.minimal_hash_bytes = constrain(_prefs.minimal_hash_bytes, 1, 3); _prefs.filter_malformed = constrain(_prefs.filter_malformed, 0, 1); + _prefs.dryrun = constrain(_prefs.dryrun, 0, 1); + _prefs.advert_hours = constrain(_prefs.advert_hours, 0, ADVERT_MAX_HOURS); + _prefs.age_mins = constrain(_prefs.age_mins, 0, FILTER_AGE_MAX_MINS); + _advert.setWindowHours(_prefs.advert_hours); + for (int i = 0; i < FILTER_PATH_COUNT; i++) { + if (_prefs.path_block[i].len > FILTER_PATH_MAX_LEN) memset(&_prefs.path_block[i], 0, sizeof(PathPrefix)); + } + for (int i = 0; i < FILTER_RULE_COUNT; i++) { + SenderRule& sr = _prefs.sender_rules[i]; + sr.name[FILTER_SENDER_LEN - 1] = '\0'; + if (sr.prob == 0 || sr.prob > 100) sr.prob = 100; + TextRule& tr = _prefs.text_rules[i]; + tr.text[FILTER_TEXT_LEN - 1] = '\0'; + if (tr.prob == 0 || tr.prob > 100) tr.prob = 100; + } + for (int i = 0; i < FILTER_WATCH_COUNT; i++) { + ChannelDetails& ch = _prefs.watch_channels[i]; + ch.name[sizeof(ch.name) - 1] = '\0'; + if (ch.name[0] != '\0') getChannelHash(ch.name, &ch.channel); // never trust a stored key + } file.close(); return true; diff --git a/examples/simple_repeater/Filter.h b/examples/simple_repeater/Filter.h index 2abdf61f78..cdfc01e838 100644 --- a/examples/simple_repeater/Filter.h +++ b/examples/simple_repeater/Filter.h @@ -6,8 +6,12 @@ #include #include +#include "AdvertLimiter.h" #include "FilterStats.h" #include "Limiter.h" +#include "MessageAge.h" +#include "PathBlock.h" +#include "SenderRules.h" #define FILTER_PREFS_FILE "/filter_prefs" @@ -20,7 +24,6 @@ static const uint8_t PUBLIC_CHANNEL_SECRET[PUB_KEY_SIZE] = { 0x8B, 0x33, 0x87, 0 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; static const uint8_t PUBLIC_CHANNEL_HASH = 0x11; -static const uint32_t INVALID_TIMESTAMP_WINDOW = (7 * 24 * 60 * 60); // 1 week struct ChannelDetails { mesh::GroupChannel channel; @@ -55,6 +58,15 @@ struct FilterPrefs { // Appended at the end of the struct so older /filter_prefs files (which lack // these bytes) still load: a short read leaves them at 0 = soft cutoff off. uint16_t soft_limit[PAYLOAD_TYPE_COUNT] = {}; + // Likewise appended, in this order; load() defaults each one a shorter file + // did not cover. + uint16_t advert_hours = 0; // per-origin advert window, 0 = off + uint8_t dryrun = false; // count drops but still forward + PathPrefix path_block[FILTER_PATH_COUNT] = {}; // blocked path-ID prefixes + SenderRule sender_rules[FILTER_RULE_COUNT] = {}; // group-text sender rules (ordered) + TextRule text_rules[FILTER_RULE_COUNT] = {}; // group-text text rules (ordered) + ChannelDetails watch_channels[FILTER_WATCH_COUNT]; // channels decrypted for the rules, besides Public + uint16_t age_mins = 0; // drop group text older than this, 0 = off }; class Filter { @@ -62,9 +74,17 @@ class Filter { mesh::RTCClock *_rtc; FilterPrefs _prefs; Limiter _limiters[PAYLOAD_TYPE_COUNT]; + AdvertLimiter _advert; + uint32_t _sender_last[FILTER_RULE_COUNT]; // throttle state per sender rule (millis, 0 = never) + uint32_t _text_last[FILTER_RULE_COUNT]; // throttle state per text rule + mesh::Radio *_radio; // optional: airtime estimate for the saved-airtime stat Counters _cnt; uint32_t _rng_state; + // Every drop funnels through here so dry-run and the airtime estimate apply + // to every reason alike. Returns the forwarding verdict. + bool drop(const mesh::Packet *packet); + // xorshift32: cheap, dependency-free source of a random byte for the soft // cutoff. State is never allowed to reach 0 (that would freeze the sequence). uint8_t nextRandom() { @@ -82,8 +102,18 @@ class Filter { RATE }; - Filter(ClientACL &acl, mesh::RTCClock &rtc) : _acl(&acl), _rtc(&rtc), _rng_state(0xA5A5F00D) {} - void resetPrefs(void) { _prefs = FilterPrefs(); } + Filter(ClientACL &acl, mesh::RTCClock &rtc) : _acl(&acl), _rtc(&rtc), _radio(nullptr), _rng_state(0xA5A5F00D) { + memset(_sender_last, 0, sizeof(_sender_last)); + memset(_text_last, 0, sizeof(_text_last)); + } + void setRadio(mesh::Radio *radio) { _radio = radio; } + void resetPrefs(void) { + _prefs = FilterPrefs(); + _advert.setWindowHours(0); + _advert.clear(); + memset(_sender_last, 0, sizeof(_sender_last)); + memset(_text_last, 0, sizeof(_text_last)); + } void resetStats(void) { _cnt.reset(); } bool allowPacketForward(const mesh::Packet *packet); bool hasPriority(const mesh::Packet *packet); @@ -94,6 +124,17 @@ class Filter { bool removeChannel(const char *name); static bool getChannelHash(const char *name, mesh::GroupChannel *gc); void listChannelNames(char *out_buf, size_t out_size, bool with_counts = false); + bool addPath(const char *hex, char *formatted); + bool removePath(const char *hex, char *formatted); + bool addSenderRule(const char *name, uint16_t secs, uint8_t prob); + bool removeSenderRule(const char *name); + bool addTextRule(const char *text, uint16_t secs, uint8_t prob); + bool removeTextRule(const char *text); + bool addWatch(const char *name); + bool removeWatch(const char *name); + void listWatchNames(char *out_buf, size_t out_size); + bool hasContentRules(void) const; + const uint8_t *watchedSecret(uint8_t channel_hash) const; bool validMessageContent(const uint8_t *data, uint8_t len, uint8_t *reason); static bool isValidUTF8(const uint8_t *data, uint8_t len); bool load(FILESYSTEM *fs); diff --git a/examples/simple_repeater/FilterStats.h b/examples/simple_repeater/FilterStats.h index 543a7abfac..8db19678f5 100644 --- a/examples/simple_repeater/FilterStats.h +++ b/examples/simple_repeater/FilterStats.h @@ -10,6 +10,9 @@ // env and the counting and formatting logic can be unit-tested off-device. #include +#include "PathBlock.h" +#include "SenderRules.h" + #define FILTER_CHANNEL_COUNT 16 // Widest path hash the wire format can encode: (path_len >> 6) + 1. @@ -53,6 +56,20 @@ struct Counters { uint32_t malformed_reason[MALFORMED_REASON_COUNT] = {}; uint16_t src[FILTER_SRC_COUNT] = {}; // drops per source hash byte + uint32_t advert = 0; // adverts dropped by the per-origin window + uint32_t path = 0; // drops by the path-prefix block list + uint32_t path_slot[FILTER_PATH_COUNT] = {}; // drops per blocked prefix + uint32_t air_ms = 0; // estimated TX airtime not spent relaying drops + + uint32_t sender = 0; // drops by sender rules + uint32_t text = 0; // drops by text rules + uint32_t sender_slot[FILTER_RULE_COUNT] = {}; // drops per sender rule + uint32_t text_slot[FILTER_RULE_COUNT] = {}; // drops per text rule + uint32_t sender_pass[FILTER_RULE_COUNT] = {}; // within-budget throttle passes per sender rule + uint32_t text_pass[FILTER_RULE_COUNT] = {}; // within-budget throttle passes per text rule + + uint32_t age = 0; // group texts dropped by `filter age` + void reset() { *this = Counters(); } }; @@ -99,6 +116,39 @@ namespace FilterStat { bump(c.src[src_hash]); } + inline void recordAdvert(Counters& c) { + bump(c.advert); + } + + inline void recordPath(Counters& c, int slot) { + bump(c.path); + if (slot >= 0 && slot < FILTER_PATH_COUNT) bump(c.path_slot[slot]); + } + + inline void recordSender(Counters& c, int slot) { + bump(c.sender); + if (slot >= 0 && slot < FILTER_RULE_COUNT) bump(c.sender_slot[slot]); + } + + inline void recordText(Counters& c, int slot) { + bump(c.text); + if (slot >= 0 && slot < FILTER_RULE_COUNT) bump(c.text_slot[slot]); + } + + inline void recordAge(Counters& c) { + bump(c.age); + } + + inline void recordAir(Counters& c, uint32_t est_ms) { + c.air_ms = (c.air_ms > 0xFFFFFFFFu - est_ms) ? 0xFFFFFFFFu : c.air_ms + est_ms; + } + + // True when a raw prefs read of `got` bytes covered the whole field at + // `offset`; fields appended after the file was written keep their defaults. + inline bool fieldLoaded(size_t got, size_t offset, size_t size) { + return got >= offset + size; + } + // Bounded, always-NUL-terminated text appender. class Buf { char* _buf; @@ -213,7 +263,7 @@ namespace FilterStat { // ---- `filter` and `filter count`: unchanged shapes, now bounded ------------ - inline void formatSummary(char* out, size_t cap, const Counters& c, bool enabled) { + inline void formatSummary(char* out, size_t cap, const Counters& c, bool enabled, bool dryrun = false) { uint32_t hops_total = 0; uint32_t rate_total = 0; for (uint8_t i = 0; i < PAYLOAD_TYPE_COUNT; i++) { @@ -226,6 +276,8 @@ namespace FilterStat { enabled ? "on" : "off", (unsigned long)hops_total, (unsigned long)rate_total, (unsigned long)c.channel, (unsigned long)c.hash, (unsigned long)c.malformed); + // Appended after the bracket so existing parsers of the bracket keep working. + if (dryrun) b.add(" (dry-run)"); b.markTruncated(".."); } @@ -342,4 +394,114 @@ namespace FilterStat { } b.markTruncated(".."); } + + // ---- fork-derived topics: advert origins, path block, saved airtime ------- + + inline void formatStatsAdvert(char* out, size_t cap, const Counters& c, + uint16_t window_hours, int cache_count, int cache_size) { + Buf b(out, cap); + + if (window_hours == 0) { + b.add("> Filter: advert origin limit off"); + return; + } + + b.add("> Advert origins: window %uh, dropped %lu, cache %d/%d", + (unsigned)window_hours, (unsigned long)c.advert, cache_count, cache_size); + b.markTruncated(".."); + } + + inline void formatStatsAge(char* out, size_t cap, const Counters& c, uint16_t max_mins, bool clock_set) { + Buf b(out, cap); + + if (max_mins == 0) { + b.add("> Filter: message age limit off"); + return; + } + + b.add("> Message age: max %um, dropped %lu", (unsigned)max_mins, (unsigned long)c.age); + if (!clock_set) b.add(" (clock not set, inactive)"); + b.markTruncated(".."); + } + + // `filter path list` (counts == nullptr) and `filter stats path` (with drops). + inline void formatPathList(char* out, size_t cap, const PathPrefix* list, const Counters* counts) { + Buf b(out, cap); + char hex[2 * FILTER_PATH_MAX_LEN + 1]; + int listed = 0; + + for (int i = 0; i < FILTER_PATH_COUNT; i++) { + if (list[i].len == 0) continue; + FilterPath::format(hex, sizeof(hex), list[i]); + if (counts != nullptr) { + b.add(listed > 0 ? ",%s: %lu" : "%s: %lu", hex, (unsigned long)counts->path_slot[i]); + } else { + b.add(listed > 0 ? ",%s" : "%s", hex); + } + listed++; + } + + if (listed == 0) { + b.add("None"); + return; + } + b.markTruncated(".."); + } + + inline void formatStatsAir(char* out, size_t cap, const Counters& c) { + Buf b(out, cap); + + if (c.air_ms == 0) { + b.add("> Filter: no airtime saved yet"); + return; + } + + unsigned long secs = c.air_ms / 1000UL; + unsigned long h = secs / 3600UL; + unsigned long m = (secs / 60UL) % 60UL; + unsigned long sec = secs % 60UL; + + b.add("> Filter: saved airtime %lu ms (", (unsigned long)c.air_ms); + if (h > 0) { + b.add("%luh %lum)", h, m); + } else if (m > 0) { + b.add("%lum %lus)", m, sec); + } else { + b.add("%lus)", sec); + } + b.markTruncated(".."); + } + + // `filter sender|text list` (drops == nullptr: pattern + mode) and + // `filter stats sender|text` (drops per rule, with throttle passes when any). + template + inline void formatRuleList(char* out, size_t cap, const Rule* rules, int count, + const uint32_t* drops, const uint32_t* passes) { + Buf b(out, cap); + int listed = 0; + + for (int i = 0; i < count; i++) { + const char* pat = FilterRules::patternOf(rules[i]); + if (pat[0] == '\0') continue; + if (listed > 0) b.add(","); + if (drops != nullptr) { + b.add("%s: %lu", pat, (unsigned long)drops[i]); + if (passes != nullptr && passes[i] > 0) b.add(" (pass %lu)", (unsigned long)passes[i]); + } else { + if (rules[i].secs > 0) { + b.add("%s throttle %us", pat, (unsigned)rules[i].secs); + } else { + b.add("%s block", pat); + } + if (rules[i].prob < 100) b.add(" %u%%", (unsigned)rules[i].prob); + } + listed++; + } + + if (listed == 0) { + b.add("None"); + return; + } + b.markTruncated(".."); + } } diff --git a/examples/simple_repeater/MessageAge.h b/examples/simple_repeater/MessageAge.h new file mode 100644 index 0000000000..189b17861e --- /dev/null +++ b/examples/simple_repeater/MessageAge.h @@ -0,0 +1,40 @@ +#pragma once + +#include + +// Timestamp checks on decrypted group text: the malformed scan's sanity window +// and the configurable `filter age` limit. Header-only and clock-free (the +// caller passes `now`) so they run on `native`. + +// Upper bound of `filter age`, in minutes: one week, the malformed scan's window. +#define FILTER_AGE_MAX_MINS 10080 + +namespace MessageAge { + + // 1 Jan 2026 00:00 UTC. A repeater without a set clock boots at 15 May 2024 + // (VolatileRTCClock, ESP32Board) and would need well over a year of uptime to + // reach this, so a clock below it has not been set. Against such a clock every + // current message looks far in the future, so no timestamp is judged by it. + static const uint32_t CLOCK_SET_AFTER = 1767225600; + + static const uint32_t SANITY_WINDOW_SECS = 7UL * 24UL * 60UL * 60UL; + + inline bool clockSet(uint32_t now) { + return now >= CLOCK_SET_AFTER; + } + + // Malformed scan: a zero stamp is always invalid; one more than a week off + // either way only once the clock has been set. + inline bool implausible(uint32_t ts, uint32_t now) { + if (ts == 0) return true; + if (!clockSet(now)) return false; + return ts < now - SANITY_WINDOW_SECS || ts > now + SANITY_WINDOW_SECS; + } + + // `filter age`: stamped more than `max_mins` before `now`. Off at 0 and while + // the clock is unset. A future stamp is never old; the sanity window covers it. + inline bool tooOld(uint32_t ts, uint32_t now, uint16_t max_mins) { + if (max_mins == 0 || !clockSet(now)) return false; + return ts < now && now - ts > (uint32_t)max_mins * 60UL; + } +} diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index a575aed5c3..1a1c212b5e 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -966,6 +966,7 @@ void MyMesh::begin(FILESYSTEM *fs) { acl.load(_fs, self_id); // TODO: key_store.begin(); region_map.load(_fs); + _filter.setRadio(_radio); // for the saved-airtime estimate on drops _filter.load(_fs); // establish default-scope diff --git a/examples/simple_repeater/PathBlock.h b/examples/simple_repeater/PathBlock.h new file mode 100644 index 0000000000..beb4794237 --- /dev/null +++ b/examples/simple_repeater/PathBlock.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include +#include + +// Path-prefix block list: drop flood packets whose recorded path contains a +// repeater ID starting with one of the configured prefixes. Lets an operator +// isolate one upstream repeater that keeps injecting junk without needing the +// key to any channel. Header-only and dependency-free so it runs on `native`. + +#define FILTER_PATH_COUNT 8 // configurable prefixes +#define FILTER_PATH_MAX_LEN 4 // bytes per prefix (2-8 hex digits) + +struct PathPrefix { + uint8_t len; // 0 = empty slot + uint8_t bytes[FILTER_PATH_MAX_LEN]; +}; + +namespace FilterPath { + + inline int hexVal(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; + } + + // Parses 2-8 hex digits (1-4 bytes). Rejects anything else and leaves `out` + // untouched on failure. + inline bool parse(const char* hex, PathPrefix* out) { + if (hex == nullptr || out == nullptr) return false; + + size_t n = strlen(hex); + if (n < 2 || n > 2 * FILTER_PATH_MAX_LEN || (n & 1)) return false; + + PathPrefix p; + memset(&p, 0, sizeof(p)); + p.len = (uint8_t)(n / 2); + for (uint8_t i = 0; i < p.len; i++) { + int hi = hexVal(hex[2 * i]); + int lo = hexVal(hex[2 * i + 1]); + if (hi < 0 || lo < 0) return false; + p.bytes[i] = (uint8_t)((hi << 4) | lo); + } + *out = p; + return true; + } + + inline void format(char* out, size_t cap, const PathPrefix& p) { + if (out == nullptr || cap == 0) return; + out[0] = '\0'; + size_t len = 0; + for (uint8_t i = 0; i < p.len && i < FILTER_PATH_MAX_LEN; i++) { + if (len + 3 > cap) break; + snprintf(out + len, cap - len, "%02X", (unsigned)p.bytes[i]); + len += 2; + } + } + + // Index of the first prefix that matches the start of any path entry, or -1. + // A prefix longer than the packet's hash size never matches: A1B2 is one + // 2-byte ID, not the two 1-byte IDs A1 then B2. + inline int findMatch(const uint8_t* path, uint8_t hash_size, uint8_t hash_count, + const PathPrefix* list, int count) { + if (path == nullptr || list == nullptr || hash_size == 0) return -1; + + for (int s = 0; s < count; s++) { + const PathPrefix& p = list[s]; + if (p.len == 0 || p.len > hash_size) continue; + for (uint8_t h = 0; h < hash_count; h++) { + if (memcmp(path + (size_t)h * hash_size, p.bytes, p.len) == 0) return s; + } + } + return -1; + } +} diff --git a/examples/simple_repeater/SenderRules.h b/examples/simple_repeater/SenderRules.h new file mode 100644 index 0000000000..49d1092cb9 --- /dev/null +++ b/examples/simple_repeater/SenderRules.h @@ -0,0 +1,150 @@ +#pragma once + +#include +#include +#include +#include + +// Sender and text rules for decrypted group text: block, throttle (one match +// per N seconds slips past, the excess is dropped) and probabilistic dosing +// (the rule decides only that share of its matches). Rules are evaluated top +// to bottom; a rule that steps aside (failed roll, or a within-budget throttle +// pass) leaves the decision to the next rule, and the first rule that decides +// drops the packet. Header-only and dependency-free so it runs on `native`. + +#define FILTER_RULE_COUNT 8 // sender rules, and separately text rules +#define FILTER_SENDER_LEN 16 // sender pattern incl. NUL +#define FILTER_TEXT_LEN 24 // text pattern incl. NUL +#define FILTER_WATCH_COUNT 4 // extra channels decrypted for the rules (Public is always) + +struct SenderRule { + char name[FILTER_SENDER_LEN]; // exact name, or prefix when it ends in '*'; '' = empty slot + uint16_t secs; // 0 = block, else throttle: one pass per secs + uint8_t prob; // 1..100: share of matches the rule decides + uint8_t reserved; +}; + +struct TextRule { + char text[FILTER_TEXT_LEN]; // substring, or prefix when it starts with '^'; '' = empty slot + uint16_t secs; + uint8_t prob; + uint8_t reserved; +}; + +namespace FilterRules { + + inline const char* patternOf(const SenderRule& r) { return r.name; } + inline const char* patternOf(const TextRule& r) { return r.text; } + + struct GroupText { + const char* sender; + size_t sender_len; + const char* text; + size_t text_len; + }; + + // Splits a decrypted GRP_TXT plaintext (4-byte timestamp, flags byte, then + // "Sender: text") into its sender and text. Only plain text (txt_type 0) + // carries that shape; anything else is rejected. Neither slice is + // NUL-terminated. + inline bool parseGroupText(const uint8_t* data, int len, GroupText* out) { + if (data == nullptr || out == nullptr || len < 6) return false; + if ((data[4] >> 2) != 0) return false; // TXT_TYPE_PLAIN only + + const char* body = (const char*)data + 5; + size_t n = (size_t)len - 5; + size_t end = 0; + while (end < n && body[end] != '\0') end++; + n = end; + + for (size_t i = 0; i + 1 < n; i++) { + if (body[i] != ':' || body[i + 1] != ' ') continue; + if (i == 0) return false; // no sender + out->sender = body; + out->sender_len = i; + out->text = body + i + 2; + out->text_len = n - i - 2; + return true; + } + return false; + } + + // Exact match, or prefix match when the pattern ends in '*' (a lone '*' + // matches every sender). Case-sensitive. + inline bool matchName(const char* pattern, const char* s, size_t n) { + if (pattern == nullptr || pattern[0] == '\0' || s == nullptr) return false; + size_t plen = strlen(pattern); + if (pattern[plen - 1] == '*') { + plen--; + return n >= plen && memcmp(s, pattern, plen) == 0; + } + return n == plen && memcmp(s, pattern, plen) == 0; + } + + // Substring match, or prefix match when the pattern starts with '^'. + // Case-sensitive; `s` need not be NUL-terminated. + inline bool matchText(const char* pattern, const char* s, size_t n) { + if (pattern == nullptr || pattern[0] == '\0' || s == nullptr) return false; + if (pattern[0] == '^') { + const char* p = pattern + 1; + size_t plen = strlen(p); + if (plen == 0) return false; + return n >= plen && memcmp(s, p, plen) == 0; + } + size_t plen = strlen(pattern); + if (plen > n) return false; + for (size_t i = 0; i + plen <= n; i++) { + if (memcmp(s + i, pattern, plen) == 0) return true; + } + return false; + } + + // Ordered first-match evaluation. Returns the slot of the rule that decided + // to drop, or -1 when every rule stepped aside or none matched. `rnd100` is + // a roll in 0..99; `last_pass` (millis, 0 = never) and `passes` are per-slot + // throttle state the caller owns. Unsigned subtraction keeps the window + // correct across the millis wrap. + template + inline int evaluate(const Rule* rules, int count, uint32_t now_ms, uint8_t rnd100, + uint32_t* last_pass, uint32_t* passes, Match match) { + if (rules == nullptr || last_pass == nullptr) return -1; + for (int i = 0; i < count; i++) { + const Rule& r = rules[i]; + if (patternOf(r)[0] == '\0') continue; + if (!match(r)) continue; + if (r.prob < 100 && rnd100 >= r.prob) continue; // failed roll: step aside + if (r.secs > 0) { + uint32_t window = (uint32_t)r.secs * 1000UL; + if (last_pass[i] == 0 || now_ms - last_pass[i] >= window) { + last_pass[i] = now_ms != 0 ? now_ms : 1; // within budget: pass, start a new window + if (passes != nullptr) passes[i]++; + continue; + } + } + return i; + } + return -1; + } + + inline bool parseUint(const char* s, long max, long* out) { + if (s == nullptr || s[0] == '\0') return false; + for (const char* p = s; *p; p++) { + if (*p < '0' || *p > '9') return false; + } + long v = strtol(s, nullptr, 10); + if (v < 0 || v > max) return false; + *out = v; + return true; + } + + // Optional CLI arguments of a rule: `[secs] [prob]`. Missing = block, 100%. + // 0% is rejected: a rule that never decides is a disabled rule, remove it. + inline bool parseArgs(const char* secs_str, const char* prob_str, uint16_t* secs, uint8_t* prob) { + long s = 0, p = 100; + if (secs_str != nullptr && !parseUint(secs_str, 65535, &s)) return false; + if (prob_str != nullptr && (!parseUint(prob_str, 100, &p) || p == 0)) return false; + *secs = (uint16_t)s; + *prob = (uint8_t)p; + return true; + } +} diff --git a/prebuilts/Heltec_v3_repeater-v1.17.1-dev-18341f13-merged.bin b/prebuilts/Heltec_v3_repeater-v1.17.1-dev-18341f13-merged.bin new file mode 100644 index 0000000000..ab17888b7a Binary files /dev/null and b/prebuilts/Heltec_v3_repeater-v1.17.1-dev-18341f13-merged.bin differ diff --git a/prebuilts/Heltec_v3_repeater-v1.17.1-dev-18341f13.bin b/prebuilts/Heltec_v3_repeater-v1.17.1-dev-18341f13.bin new file mode 100644 index 0000000000..bfb2aeecbe Binary files /dev/null and b/prebuilts/Heltec_v3_repeater-v1.17.1-dev-18341f13.bin differ diff --git a/prebuilts/RAK_4631_repeater-v1.17.1-dev-18341f13.uf2 b/prebuilts/RAK_4631_repeater-v1.17.1-dev-18341f13.uf2 new file mode 100644 index 0000000000..c2a4f4598f Binary files /dev/null and b/prebuilts/RAK_4631_repeater-v1.17.1-dev-18341f13.uf2 differ diff --git a/prebuilts/RAK_4631_repeater-v1.17.1-dev-18341f13.zip b/prebuilts/RAK_4631_repeater-v1.17.1-dev-18341f13.zip new file mode 100644 index 0000000000..15d5c3ce6c Binary files /dev/null and b/prebuilts/RAK_4631_repeater-v1.17.1-dev-18341f13.zip differ diff --git a/prebuilts/README.md b/prebuilts/README.md new file mode 100644 index 0000000000..c4a059e034 --- /dev/null +++ b/prebuilts/README.md @@ -0,0 +1,30 @@ +# Prebuilt firmware (test builds) + +Non-MQTT DMC repeater test builds of the packet-filter enhancements (advert origin window, path-prefix block, dry-run, saved airtime, sender/text rules with throttle and dosing, watch list). Built from commit `18341f13` of this branch. The `-dev` marker sits inside the version because this branch's build.sh has no DMC_CHANNEL support; these bins are not OTA-capable, so the filename form does not matter here. + +Branch: `enhancement/dmc-dev-filtering` + +Built locally with: + +```bash +FIRMWARE_VERSION=v1.17.1-dev bash build.sh build-firmware +``` + +| File | Bytes | sha256 (first 16) | Embedded version | +| --- | ---: | --- | --- | +| `Heltec_v3_repeater-v1.17.1-dev-18341f13-merged.bin` | 1254592 | `fc09f5759de93fb9` | | +| `Heltec_v3_repeater-v1.17.1-dev-18341f13.bin` | 1189056 | `3e40c033e561c940` | `v1.17.1-dev-dutchmeshcore.nl-18341f13` | +| `RAK_4631_repeater-v1.17.1-dev-18341f13.uf2` | 933376 | `347183273fb3df8d` | `v1.17.1-dev-dutchmeshcore.nl-18341f13` | +| `RAK_4631_repeater-v1.17.1-dev-18341f13.zip` | 467372 | `7df0ad8c4157647e` | | + +## Flashing + +- **ESP32 boards** (`.bin`): flash `-...-merged.bin` at offset `0x0` for a fresh + install (it includes bootloader + partitions + app), or the plain `-...bin` at + the app offset (`0x10000`) / via OTA to update an existing install. `.partsig` + is the partition-table signature the OTA manifest generator uses. +- **nRF52 boards** (`.uf2` / `.zip`): drag the `.uf2` onto the board's UF2 drive, or + flash the `.zip` with `adafruit-nrfutil dfu serial`. + +The hash in each filename is the source commit the binary was built from; the commit +that adds these files necessarily comes after it. diff --git a/test/test_filter_policy/test_filter_policy.cpp b/test/test_filter_policy/test_filter_policy.cpp new file mode 100644 index 0000000000..fcc139148d --- /dev/null +++ b/test/test_filter_policy/test_filter_policy.cpp @@ -0,0 +1,294 @@ +#include + +// Header-only forwarding policies of the repeater packet filter that need no +// radio, filesystem or clock: the per-origin advert limiter and the path-prefix +// block list. Both are pure functions of their inputs so they run on `native`. +#include "../../examples/simple_repeater/AdvertLimiter.h" +#include "../../examples/simple_repeater/MessageAge.h" +#include "../../examples/simple_repeater/PathBlock.h" + +static const uint32_t HOUR_MS = 3600UL * 1000UL; + +static void origin(uint8_t* key, uint32_t n) { + key[0] = (uint8_t)(n >> 24); + key[1] = (uint8_t)(n >> 16); + key[2] = (uint8_t)(n >> 8); + key[3] = (uint8_t)n; +} + +// ---- AdvertLimiter ----------------------------------------------------------- + +TEST(AdvertLimiter, IsOffByDefaultAndRecordsNothing) { + AdvertLimiter lim; + uint8_t key[4]; + origin(key, 1); + + EXPECT_EQ(0u, lim.getWindowHours()); + EXPECT_TRUE(lim.allow(key, 1000)); + EXPECT_TRUE(lim.allow(key, 1001)); + EXPECT_EQ(0, lim.getCount()); +} + +TEST(AdvertLimiter, FirstAdvertFromAnOriginPassesAndIsRemembered) { + AdvertLimiter lim; + lim.setWindowHours(1); + uint8_t key[4]; + origin(key, 1); + + EXPECT_TRUE(lim.allow(key, 1000)); + EXPECT_EQ(1, lim.getCount()); +} + +TEST(AdvertLimiter, RepeatWithinWindowIsDropped) { + AdvertLimiter lim; + lim.setWindowHours(1); + uint8_t key[4]; + origin(key, 1); + ASSERT_TRUE(lim.allow(key, 1000)); + + EXPECT_FALSE(lim.allow(key, 1000 + HOUR_MS - 1)); + EXPECT_EQ(1, lim.getCount()) << "a dropped repeat must not take a new cache slot"; +} + +TEST(AdvertLimiter, RepeatAfterWindowPassesAndRestartsTheWindow) { + AdvertLimiter lim; + lim.setWindowHours(1); + uint8_t key[4]; + origin(key, 1); + ASSERT_TRUE(lim.allow(key, 1000)); + + EXPECT_TRUE(lim.allow(key, 1000 + HOUR_MS)); + EXPECT_FALSE(lim.allow(key, 1000 + HOUR_MS + 10)) << "the pass must start a fresh window"; + EXPECT_EQ(1, lim.getCount()); +} + +TEST(AdvertLimiter, OriginsAreIndependent) { + AdvertLimiter lim; + lim.setWindowHours(1); + uint8_t a[4], b[4]; + origin(a, 1); + origin(b, 2); + ASSERT_TRUE(lim.allow(a, 1000)); + + EXPECT_TRUE(lim.allow(b, 1001)); + EXPECT_FALSE(lim.allow(a, 1002)); + EXPECT_FALSE(lim.allow(b, 1003)); + EXPECT_EQ(2, lim.getCount()); +} + +TEST(AdvertLimiter, OverwritesTheOldestOriginWhenTheCacheIsFull) { + AdvertLimiter lim; + lim.setWindowHours(1); + uint8_t key[4]; + + for (uint32_t n = 0; n < ADVERT_CACHE_SIZE + 1; n++) { + origin(key, n); + ASSERT_TRUE(lim.allow(key, 1000 + n)); + } + EXPECT_EQ(ADVERT_CACHE_SIZE, lim.getCount()); + + origin(key, 0); + EXPECT_TRUE(lim.allow(key, 2000)) << "origin 0 was evicted, so it passes again"; + origin(key, 2); + EXPECT_FALSE(lim.allow(key, 2002)) << "origin 2 is still cached (re-adding 0 evicted only origin 1)"; +} + +TEST(AdvertLimiter, SurvivesMillisWrapAround) { + AdvertLimiter lim; + lim.setWindowHours(1); + uint8_t key[4]; + origin(key, 7); + ASSERT_TRUE(lim.allow(key, 0xFFFFFFFFu - 1000)); + + EXPECT_FALSE(lim.allow(key, 500)) << "1500 ms later, across the wrap, is still inside the window"; + EXPECT_TRUE(lim.allow(key, HOUR_MS)); +} + +TEST(AdvertLimiter, ClearForgetsEveryOrigin) { + AdvertLimiter lim; + lim.setWindowHours(1); + uint8_t key[4]; + origin(key, 1); + ASSERT_TRUE(lim.allow(key, 1000)); + + lim.clear(); + + EXPECT_EQ(0, lim.getCount()); + EXPECT_TRUE(lim.allow(key, 1001)); +} + +TEST(AdvertLimiter, TurningTheWindowOffStopsDropping) { + AdvertLimiter lim; + lim.setWindowHours(1); + uint8_t key[4]; + origin(key, 1); + ASSERT_TRUE(lim.allow(key, 1000)); + + lim.setWindowHours(0); + + EXPECT_TRUE(lim.allow(key, 1001)); +} + +// ---- PathBlock: parsing ------------------------------------------------------- + +TEST(PathBlockParse, AcceptsTwoToEightHexDigits) { + PathPrefix p; + + ASSERT_TRUE(FilterPath::parse("A1", &p)); + EXPECT_EQ(1, p.len); + EXPECT_EQ(0xA1, p.bytes[0]); + + ASSERT_TRUE(FilterPath::parse("a1b2c3d4", &p)); + EXPECT_EQ(4, p.len); + EXPECT_EQ(0xA1, p.bytes[0]); + EXPECT_EQ(0xB2, p.bytes[1]); + EXPECT_EQ(0xC3, p.bytes[2]); + EXPECT_EQ(0xD4, p.bytes[3]); +} + +TEST(PathBlockParse, RejectsMalformedInput) { + PathPrefix p; + + EXPECT_FALSE(FilterPath::parse("", &p)); + EXPECT_FALSE(FilterPath::parse("A", &p)) << "odd digit count"; + EXPECT_FALSE(FilterPath::parse("A1B", &p)) << "odd digit count"; + EXPECT_FALSE(FilterPath::parse("A1B2C3D4E5", &p)) << "more than 4 bytes"; + EXPECT_FALSE(FilterPath::parse("G1", &p)) << "not hex"; + EXPECT_FALSE(FilterPath::parse("0x", &p)) << "not hex"; + EXPECT_FALSE(FilterPath::parse(nullptr, &p)); +} + +TEST(PathBlockParse, FormatsBackToUppercaseHex) { + PathPrefix p; + ASSERT_TRUE(FilterPath::parse("a1b2", &p)); + + char out[12]; + FilterPath::format(out, sizeof(out), p); + + EXPECT_STREQ("A1B2", out); +} + +// ---- PathBlock: matching ------------------------------------------------------ + +static int slots(PathPrefix* list, int n, const char* const* hex) { + for (int i = 0; i < n; i++) { + if (hex[i] == nullptr) { list[i].len = 0; continue; } + if (!FilterPath::parse(hex[i], &list[i])) return -1; + } + return n; +} + +TEST(PathBlockMatch, FindsAOneBytePrefixAnywhereInTheFloodPath) { + const uint8_t path[] = { 0x10, 0xA1, 0xB2 }; + PathPrefix list[2]; + const char* hex[] = { "C3", "A1" }; + ASSERT_EQ(2, slots(list, 2, hex)); + + EXPECT_EQ(1, FilterPath::findMatch(path, 1, 3, list, 2)); +} + +TEST(PathBlockMatch, ReturnsMinusOneWhenNothingMatches) { + const uint8_t path[] = { 0x10, 0xA1, 0xB2 }; + PathPrefix list[1]; + const char* hex[] = { "C3" }; + ASSERT_EQ(1, slots(list, 1, hex)); + + EXPECT_EQ(-1, FilterPath::findMatch(path, 1, 3, list, 1)); + EXPECT_EQ(-1, FilterPath::findMatch(path, 1, 0, list, 1)) << "empty path"; +} + +TEST(PathBlockMatch, NeverMatchesAPrefixLongerThanThePathHashes) { + const uint8_t path[] = { 0xA1, 0xB2 }; // two 1-byte hashes: A1, B2 + PathPrefix list[1]; + const char* hex[] = { "A1B2" }; + ASSERT_EQ(1, slots(list, 1, hex)); + + EXPECT_EQ(-1, FilterPath::findMatch(path, 1, 2, list, 1)) + << "A1B2 is one 2-byte ID, not the two 1-byte IDs A1 then B2"; +} + +TEST(PathBlockMatch, ComparesAlignedToTheHashSize) { + const uint8_t path[] = { 0xA1, 0xB2, 0xC3, 0xD4 }; // two 2-byte hashes: A1B2, C3D4 + PathPrefix list[3]; + const char* hex[] = { "B2C3", "C3", "A1B2" }; + ASSERT_EQ(3, slots(list, 3, hex)); + + EXPECT_EQ(1, FilterPath::findMatch(path, 2, 2, list, 3)) + << "B2C3 straddles two IDs and must not match; C3 is the start of the second ID"; +} + +TEST(PathBlockMatch, SkipsEmptySlotsAndReportsTheFirstHit) { + const uint8_t path[] = { 0xA1, 0xB2 }; + PathPrefix list[3]; + const char* hex[] = { nullptr, "B2", "A1" }; + ASSERT_EQ(3, slots(list, 3, hex)); + + EXPECT_EQ(1, FilterPath::findMatch(path, 1, 2, list, 3)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} + +// ---- MessageAge --------------------------------------------------------------- + +static const uint32_t CLOCK_NOW = 1790000000; // Sep 2026: a set clock +static const uint32_t BOOT_CLOCK = 1715770351; // 15 May 2024: what an unset clock boots at +static const uint32_t MIN_S = 60; + +TEST(MessageAge, ClockSetOnlyFromTheCutoffOn) { + EXPECT_FALSE(MessageAge::clockSet(BOOT_CLOCK)); + EXPECT_FALSE(MessageAge::clockSet(MessageAge::CLOCK_SET_AFTER - 1)); + EXPECT_TRUE(MessageAge::clockSet(MessageAge::CLOCK_SET_AFTER)); + EXPECT_TRUE(MessageAge::clockSet(CLOCK_NOW)); +} + +TEST(MessageAge, OffWhenMaxIsZero) { + EXPECT_FALSE(MessageAge::tooOld(CLOCK_NOW - 30 * 24 * 3600, CLOCK_NOW, 0)); +} + +TEST(MessageAge, DropsOnlyWhatIsOlderThanTheLimit) { + EXPECT_FALSE(MessageAge::tooOld(CLOCK_NOW, CLOCK_NOW, 30)); + EXPECT_FALSE(MessageAge::tooOld(CLOCK_NOW - 30 * MIN_S, CLOCK_NOW, 30)); // exactly at the limit + EXPECT_TRUE(MessageAge::tooOld(CLOCK_NOW - 30 * MIN_S - 1, CLOCK_NOW, 30)); + EXPECT_TRUE(MessageAge::tooOld(CLOCK_NOW - 3 * 24 * 3600, CLOCK_NOW, 30)); +} + +TEST(MessageAge, FutureStampIsNeverTooOld) { + EXPECT_FALSE(MessageAge::tooOld(CLOCK_NOW + 10 * MIN_S, CLOCK_NOW, 1)); +} + +TEST(MessageAge, ZeroStampIsTooOld) { + EXPECT_TRUE(MessageAge::tooOld(0, CLOCK_NOW, 60)); +} + +TEST(MessageAge, InactiveWhileTheClockIsUnset) { + // an unset clock would make every current message look far in the future, + // and a message stamped before boot look old: never judge age against it + EXPECT_FALSE(MessageAge::tooOld(BOOT_CLOCK - 3 * 24 * 3600, BOOT_CLOCK, 30)); + EXPECT_FALSE(MessageAge::tooOld(0, BOOT_CLOCK, 30)); +} + +TEST(MessageAge, LimitIsCappedAtOneWeek) { + EXPECT_EQ(7u * 24u * 60u, (unsigned)FILTER_AGE_MAX_MINS); +} + +TEST(MessageAgeImplausible, ZeroStampIsAlwaysImplausible) { + EXPECT_TRUE(MessageAge::implausible(0, CLOCK_NOW)); + EXPECT_TRUE(MessageAge::implausible(0, BOOT_CLOCK)); +} + +TEST(MessageAgeImplausible, OutsideOneWeekIsImplausibleWithASetClock) { + const uint32_t week = 7 * 24 * 3600; + EXPECT_FALSE(MessageAge::implausible(CLOCK_NOW - week, CLOCK_NOW)); + EXPECT_FALSE(MessageAge::implausible(CLOCK_NOW + week, CLOCK_NOW)); + EXPECT_TRUE(MessageAge::implausible(CLOCK_NOW - week - 1, CLOCK_NOW)); + EXPECT_TRUE(MessageAge::implausible(CLOCK_NOW + week + 1, CLOCK_NOW)); +} + +TEST(MessageAgeImplausible, WindowIsNotAppliedWhileTheClockIsUnset) { + // before this guard, a repeater without a set clock dropped every current + // Public message as "time" malformed once the malformed scan was on + EXPECT_FALSE(MessageAge::implausible(CLOCK_NOW, BOOT_CLOCK)); +} diff --git a/test/test_filter_rules/test_filter_rules.cpp b/test/test_filter_rules/test_filter_rules.cpp new file mode 100644 index 0000000000..e280cad029 --- /dev/null +++ b/test/test_filter_rules/test_filter_rules.cpp @@ -0,0 +1,210 @@ +#include + +#include +#include + +// Header-only sender/text rule engine of the repeater packet filter: group +// text parsing, pattern matching and the ordered first-match evaluation with +// per-rule throttle budgets and probabilistic dosing. Pure functions, so they +// run on `native`. +#include "../../examples/simple_repeater/SenderRules.h" + +// Builds a decrypted GRP_TXT plaintext: 4-byte timestamp, 1 flags byte +// (txt_type << 2), then "Sender: text". +static int plaintext(uint8_t* buf, const char* body, uint8_t txt_type = 0) { + buf[0] = 0x78; buf[1] = 0x56; buf[2] = 0x34; buf[3] = 0x12; + buf[4] = (uint8_t)(txt_type << 2); + size_t n = strlen(body); + memcpy(buf + 5, body, n); + return (int)(5 + n); +} + +static std::string str(const char* s, size_t n) { return std::string(s, n); } + +// ---- parsing ------------------------------------------------------------------- + +TEST(FilterRulesParse, SplitsSenderAndTextAtTheFirstColonSpace) { + uint8_t buf[80]; + int len = plaintext(buf, "SpamBot: BEACON 123: go"); + FilterRules::GroupText gt; + + ASSERT_TRUE(FilterRules::parseGroupText(buf, len, >)); + + EXPECT_EQ("SpamBot", str(gt.sender, gt.sender_len)); + EXPECT_EQ("BEACON 123: go", str(gt.text, gt.text_len)); +} + +TEST(FilterRulesParse, RejectsNonPlainTextAndMalformedBodies) { + uint8_t buf[80]; + FilterRules::GroupText gt; + + EXPECT_FALSE(FilterRules::parseGroupText(buf, plaintext(buf, "SpamBot: hi", 1), >)) << "not plain text"; + EXPECT_FALSE(FilterRules::parseGroupText(buf, plaintext(buf, "no separator"), >)); + EXPECT_FALSE(FilterRules::parseGroupText(buf, plaintext(buf, ": empty sender"), >)); + EXPECT_FALSE(FilterRules::parseGroupText(buf, 4, >)) << "too short"; + EXPECT_FALSE(FilterRules::parseGroupText(nullptr, 20, >)); +} + +TEST(FilterRulesParse, AcceptsAnEmptyTextAfterTheSeparator) { + uint8_t buf[80]; + int len = plaintext(buf, "Alice: "); + FilterRules::GroupText gt; + + ASSERT_TRUE(FilterRules::parseGroupText(buf, len, >)); + + EXPECT_EQ("Alice", str(gt.sender, gt.sender_len)); + EXPECT_EQ(0u, gt.text_len); +} + +// ---- matching ------------------------------------------------------------------ + +TEST(FilterRulesMatch, NameIsExactUnlessThePatternEndsWithAStar) { + EXPECT_TRUE(FilterRules::matchName("SpamBot", "SpamBot", 7)); + EXPECT_FALSE(FilterRules::matchName("SpamBot", "SpamBot2", 8)); + EXPECT_FALSE(FilterRules::matchName("SpamBot", "spambot", 7)) << "case-sensitive"; + EXPECT_TRUE(FilterRules::matchName("Spam*", "SpamBot2", 8)); + EXPECT_FALSE(FilterRules::matchName("Spam*", "MySpam", 6)); + EXPECT_TRUE(FilterRules::matchName("*", "Anyone", 6)) << "lone star matches every sender"; + EXPECT_FALSE(FilterRules::matchName("", "Anyone", 6)) << "empty pattern never matches"; +} + +TEST(FilterRulesMatch, TextIsSubstringUnlessAnchoredWithACaret) { + EXPECT_TRUE(FilterRules::matchText("BEACON", "hello BEACON 123", 16)); + EXPECT_FALSE(FilterRules::matchText("beacon", "hello BEACON 123", 16)) << "case-sensitive"; + EXPECT_TRUE(FilterRules::matchText("^BEACON", "BEACON 123", 10)); + EXPECT_FALSE(FilterRules::matchText("^BEACON", "hello BEACON", 12)); + EXPECT_FALSE(FilterRules::matchText("BEACON", "BEACO", 5)) << "pattern longer than text"; + EXPECT_FALSE(FilterRules::matchText("", "anything", 8)); +} + +// ---- evaluation ------------------------------------------------------------------ + +static SenderRule rule(const char* name, uint16_t secs = 0, uint8_t prob = 100) { + SenderRule r; + memset(&r, 0, sizeof(r)); + strncpy(r.name, name, sizeof(r.name) - 1); + r.secs = secs; + r.prob = prob; + return r; +} + +struct Eval { + uint32_t last_pass[FILTER_RULE_COUNT] = {}; + uint32_t passes[FILTER_RULE_COUNT] = {}; + + int run(const SenderRule* rules, int count, const char* sender, uint32_t now_ms, uint8_t rnd100 = 0) { + size_t n = strlen(sender); + return FilterRules::evaluate(rules, count, now_ms, rnd100, last_pass, passes, + [&](const SenderRule& r) { return FilterRules::matchName(r.name, sender, n); }); + } +}; + +TEST(FilterRulesEvaluate, FirstMatchingBlockRuleDropsAndReportsItsSlot) { + SenderRule rules[3] = { rule("Alice"), rule("Bot*"), rule("Bot1") }; + Eval e; + + EXPECT_EQ(1, e.run(rules, 3, "Bot1", 1000)); + EXPECT_EQ(-1, e.run(rules, 3, "Carol", 1000)); +} + +TEST(FilterRulesEvaluate, SkipsEmptySlots) { + SenderRule rules[3] = { rule(""), rule(""), rule("Bob") }; + Eval e; + + EXPECT_EQ(2, e.run(rules, 3, "Bob", 1000)); +} + +TEST(FilterRulesEvaluate, ThrottleLetsOneMatchPerWindowThroughAndDropsTheExcess) { + SenderRule rules[1] = { rule("Bob", 60) }; + Eval e; + + EXPECT_EQ(-1, e.run(rules, 1, "Bob", 1000)) << "first match after boot is a free pass"; + EXPECT_EQ(0, e.run(rules, 1, "Bob", 1000 + 30000)) << "inside the window: dropped"; + EXPECT_EQ(0, e.run(rules, 1, "Bob", 1000 + 59999)); + EXPECT_EQ(-1, e.run(rules, 1, "Bob", 1000 + 60000)) << "window elapsed: passes again"; + EXPECT_EQ(0, e.run(rules, 1, "Bob", 1000 + 60001)) << "the pass started a new window"; + + EXPECT_EQ(2u, e.passes[0]); +} + +TEST(FilterRulesEvaluate, OverRateFiringsDoNotExtendTheWindow) { + SenderRule rules[1] = { rule("Bob", 10) }; + Eval e; + ASSERT_EQ(-1, e.run(rules, 1, "Bob", 5000)); + for (uint32_t t = 5100; t < 15000; t += 100) ASSERT_EQ(0, e.run(rules, 1, "Bob", t)); + + EXPECT_EQ(-1, e.run(rules, 1, "Bob", 15000)) << "exactly one pass per window however hard it is pushed"; +} + +TEST(FilterRulesEvaluate, AThrottledPassStepsAsideSoALaterRuleStillDecides) { + SenderRule rules[2] = { rule("Bob", 60), rule("*") }; + Eval e; + + EXPECT_EQ(1, e.run(rules, 2, "Bob", 1000)) << "Bob's free pass falls through to the catch-all block"; + EXPECT_EQ(1u, e.passes[0]); +} + +TEST(FilterRulesEvaluate, ProbabilityDecidesOnlyThatShareOfMatches) { + SenderRule rules[1] = { rule("Bob", 0, 30) }; + Eval e; + + EXPECT_EQ(0, e.run(rules, 1, "Bob", 1000, 0)) << "roll 0 < 30: rule decides"; + EXPECT_EQ(0, e.run(rules, 1, "Bob", 1000, 29)); + EXPECT_EQ(-1, e.run(rules, 1, "Bob", 1000, 30)) << "roll 30 >= 30: rule steps aside"; + EXPECT_EQ(-1, e.run(rules, 1, "Bob", 1000, 99)); +} + +TEST(FilterRulesEvaluate, AFailedRollNeverTouchesTheThrottleBudget) { + SenderRule rules[1] = { rule("Bob", 60, 50) }; + Eval e; + + EXPECT_EQ(-1, e.run(rules, 1, "Bob", 1000, 99)) << "failed roll: stepped aside"; + EXPECT_EQ(0u, e.passes[0]); + EXPECT_EQ(-1, e.run(rules, 1, "Bob", 1001, 0)) << "passed roll: this is the free pass"; + EXPECT_EQ(1u, e.passes[0]); + EXPECT_EQ(0, e.run(rules, 1, "Bob", 1002, 0)); +} + +TEST(FilterRulesEvaluate, ThrottleSurvivesMillisWrapAround) { + SenderRule rules[1] = { rule("Bob", 60) }; + Eval e; + ASSERT_EQ(-1, e.run(rules, 1, "Bob", 0xFFFFFFFFu - 1000)); + + EXPECT_EQ(0, e.run(rules, 1, "Bob", 500)) << "1500 ms later, across the wrap, still inside the window"; + EXPECT_EQ(-1, e.run(rules, 1, "Bob", 60000)); +} + +// ---- rule parsing from CLI arguments --------------------------------------------- + +TEST(FilterRulesArgs, ParsesOptionalSecondsAndProbability) { + uint16_t secs = 99; + uint8_t prob = 0; + + EXPECT_TRUE(FilterRules::parseArgs(nullptr, nullptr, &secs, &prob)); + EXPECT_EQ(0, secs); + EXPECT_EQ(100, prob); + + EXPECT_TRUE(FilterRules::parseArgs("60", nullptr, &secs, &prob)); + EXPECT_EQ(60, secs); + EXPECT_EQ(100, prob); + + EXPECT_TRUE(FilterRules::parseArgs("0", "35", &secs, &prob)); + EXPECT_EQ(0, secs); + EXPECT_EQ(35, prob); +} + +TEST(FilterRulesArgs, RejectsOutOfRangeValues) { + uint16_t secs; + uint8_t prob; + + EXPECT_FALSE(FilterRules::parseArgs("65536", nullptr, &secs, &prob)); + EXPECT_FALSE(FilterRules::parseArgs("-1", nullptr, &secs, &prob)); + EXPECT_FALSE(FilterRules::parseArgs("abc", nullptr, &secs, &prob)); + EXPECT_FALSE(FilterRules::parseArgs("60", "0", &secs, &prob)) << "0% is a disabled rule; use remove"; + EXPECT_FALSE(FilterRules::parseArgs("60", "101", &secs, &prob)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_filterstats/test_filterstats.cpp b/test/test_filterstats/test_filterstats.cpp index 388273716a..27260e38b4 100644 --- a/test/test_filterstats/test_filterstats.cpp +++ b/test/test_filterstats/test_filterstats.cpp @@ -2,6 +2,8 @@ // Header-only drop statistics for the repeater packet filter. #include "../../examples/simple_repeater/FilterStats.h" +#include "../../examples/simple_repeater/PathBlock.h" +#include "../../examples/simple_repeater/SenderRules.h" // ---- Buf: bounded, always-terminated appender ------------------------------- @@ -587,7 +589,281 @@ TEST(FilterStatsTop, StaysWithinTheReplyBufferWhenEverySourceDrops) { EXPECT_LT(strlen(out), REPLY_CAP); } +// ---- Counters: fork-derived additions (advert origin, path block, airtime) -- + +TEST(FilterStatsCounters, RecordAdvertCountsOriginDrops) { + Counters c; + + FilterStat::recordAdvert(c); + FilterStat::recordAdvert(c); + + EXPECT_EQ(2u, c.advert); +} + +TEST(FilterStatsCounters, RecordPathSplitsBySlot) { + Counters c; + + FilterStat::recordPath(c, 0); + FilterStat::recordPath(c, 3); + FilterStat::recordPath(c, 3); + + EXPECT_EQ(3u, c.path); + EXPECT_EQ(1u, c.path_slot[0]); + EXPECT_EQ(2u, c.path_slot[3]); +} + +TEST(FilterStatsCounters, RecordPathKeepsTotalWhenSlotIsOutOfRange) { + Counters c; + + FilterStat::recordPath(c, FILTER_PATH_COUNT); + FilterStat::recordPath(c, -1); + + EXPECT_EQ(2u, c.path); + for (int i = 0; i < FILTER_PATH_COUNT; i++) EXPECT_EQ(0u, c.path_slot[i]); +} + +TEST(FilterStatsCounters, RecordAirAccumulatesSaturating) { + Counters c; + + FilterStat::recordAir(c, 1500); + FilterStat::recordAir(c, 250); + EXPECT_EQ(1750u, c.air_ms); + + FilterStat::recordAir(c, 0xFFFFFFFFu); + EXPECT_EQ(0xFFFFFFFFu, c.air_ms); +} + +TEST(FilterStatsCounters, ResetClearsTheNewBucketsToo) { + Counters c; + FilterStat::recordAdvert(c); + FilterStat::recordPath(c, 1); + FilterStat::recordAir(c, 9); + + c.reset(); + + EXPECT_EQ(0u, c.advert); + EXPECT_EQ(0u, c.path); + EXPECT_EQ(0u, c.path_slot[1]); + EXPECT_EQ(0u, c.air_ms); +} + +// ---- `filter` summary: dry-run marker ---------------------------------------- + +TEST(FilterStatsSummary, KeepsTheEstablishedLayoutWhenNotInDryRun) { + Counters c; + char out[160]; + + FilterStat::formatSummary(out, sizeof(out), c, true, false); + + EXPECT_STREQ("> Filter on: Blocked [ Hops: 0 | Rate: 0 | Channel: 0 | Hash: 0 | Malformed: 0 ]", out); +} + +TEST(FilterStatsSummary, AppendsADryRunMarkerAfterTheBracket) { + Counters c; + char out[160]; + + FilterStat::formatSummary(out, sizeof(out), c, true, true); + + EXPECT_STREQ("> Filter on: Blocked [ Hops: 0 | Rate: 0 | Channel: 0 | Hash: 0 | Malformed: 0 ] (dry-run)", out); +} + +// ---- `filter stats advert` ----------------------------------------------------- + +TEST(FilterStatsAdvert, ReportsWindowDropsAndCacheFill) { + Counters c; + c.advert = 42; + char out[160]; + + FilterStat::formatStatsAdvert(out, sizeof(out), c, 48, 17, 256); + + EXPECT_STREQ("> Advert origins: window 48h, dropped 42, cache 17/256", out); +} + +TEST(FilterStatsAdvert, SaysSoWhenTheLimiterIsOff) { + Counters c; + char out[160]; + + FilterStat::formatStatsAdvert(out, sizeof(out), c, 0, 0, 256); + + EXPECT_STREQ("> Filter: advert origin limit off", out); +} + +// ---- `filter stats path` and `filter path list` ------------------------------- + +TEST(FilterStatsPath, ListsEveryPrefixWithItsDrops) { + Counters c; + c.path_slot[0] = 5; + c.path_slot[2] = 0; + PathPrefix list[FILTER_PATH_COUNT] = {}; + ASSERT_TRUE(FilterPath::parse("A1", &list[0])); + ASSERT_TRUE(FilterPath::parse("B2C3", &list[2])); + char out[160]; + + FilterStat::formatPathList(out, sizeof(out), list, &c); + + EXPECT_STREQ("A1: 5,B2C3: 0", out); +} + +TEST(FilterStatsPath, ListsPrefixesWithoutCountsForTheSettingView) { + Counters c; + PathPrefix list[FILTER_PATH_COUNT] = {}; + ASSERT_TRUE(FilterPath::parse("A1", &list[0])); + ASSERT_TRUE(FilterPath::parse("B2C3", &list[2])); + char out[160]; + + FilterStat::formatPathList(out, sizeof(out), list, nullptr); + + EXPECT_STREQ("A1,B2C3", out); +} + +TEST(FilterStatsPath, SaysNoneWhenNothingIsBlocked) { + PathPrefix list[FILTER_PATH_COUNT] = {}; + char out[160]; + + FilterStat::formatPathList(out, sizeof(out), list, nullptr); + + EXPECT_STREQ("None", out); +} + +// ---- `filter stats air` -------------------------------------------------------- + +TEST(FilterStatsAir, ReportsSavedAirtimeInMillisecondsAndHumanUnits) { + Counters c; + c.air_ms = 214500; + char out[160]; + + FilterStat::formatStatsAir(out, sizeof(out), c); + + EXPECT_STREQ("> Filter: saved airtime 214500 ms (3m 34s)", out); +} + +TEST(FilterStatsAir, SaysSoWhenNothingWasSaved) { + Counters c; + char out[160]; + + FilterStat::formatStatsAir(out, sizeof(out), c); + + EXPECT_STREQ("> Filter: no airtime saved yet", out); +} + +// ---- prefs migration helper ---------------------------------------------------- + +TEST(FilterStatsPrefs, FieldLoadedOnlyWhenTheFileCoveredItEntirely) { + EXPECT_TRUE(FilterStat::fieldLoaded(10, 6, 4)); + EXPECT_FALSE(FilterStat::fieldLoaded(9, 6, 4)) << "one byte short"; + EXPECT_FALSE(FilterStat::fieldLoaded(0, 0, 1)); + EXPECT_TRUE(FilterStat::fieldLoaded(100, 6, 4)); +} + +// ---- Counters + formatting: sender/text rules ------------------------------------ + +TEST(FilterStatsCounters, RecordSenderAndTextSplitBySlot) { + Counters c; + + FilterStat::recordSender(c, 2); + FilterStat::recordSender(c, 2); + FilterStat::recordText(c, 0); + FilterStat::recordText(c, FILTER_RULE_COUNT); + + EXPECT_EQ(2u, c.sender); + EXPECT_EQ(2u, c.sender_slot[2]); + EXPECT_EQ(2u, c.text); + EXPECT_EQ(1u, c.text_slot[0]); +} + +static void mkRule(SenderRule& r, const char* name, uint16_t secs, uint8_t prob) { + memset(&r, 0, sizeof(r)); + strncpy(r.name, name, sizeof(r.name) - 1); + r.secs = secs; + r.prob = prob; +} + +TEST(FilterStatsRules, ListsEveryRuleWithItsMode) { + SenderRule rules[FILTER_RULE_COUNT] = {}; + mkRule(rules[0], "Bot*", 60, 100); + mkRule(rules[2], "Spam", 0, 30); + mkRule(rules[3], "Alice", 0, 100); + char out[160]; + + FilterStat::formatRuleList(out, sizeof(out), rules, FILTER_RULE_COUNT, nullptr, nullptr); + + EXPECT_STREQ("Bot* throttle 60s,Spam block 30%,Alice block", out); +} + +TEST(FilterStatsRules, ListsDropsAndPassesForTheStatsView) { + SenderRule rules[FILTER_RULE_COUNT] = {}; + mkRule(rules[0], "Bot*", 60, 100); + mkRule(rules[1], "Spam", 0, 100); + uint32_t drops[FILTER_RULE_COUNT] = { 12, 5 }; + uint32_t passes[FILTER_RULE_COUNT] = { 3, 0 }; + char out[160]; + + FilterStat::formatRuleList(out, sizeof(out), rules, FILTER_RULE_COUNT, drops, passes); + + EXPECT_STREQ("Bot*: 12 (pass 3),Spam: 5", out); +} + +TEST(FilterStatsRules, SaysNoneWhenNoRuleIsSet) { + SenderRule rules[FILTER_RULE_COUNT] = {}; + char out[160]; + + FilterStat::formatRuleList(out, sizeof(out), rules, FILTER_RULE_COUNT, nullptr, nullptr); + + EXPECT_STREQ("None", out); +} + +TEST(FilterStatsRules, WorksForTextRulesToo) { + TextRule rules[FILTER_RULE_COUNT] = {}; + memset(&rules[0], 0, sizeof(rules[0])); + strncpy(rules[0].text, "^BEACON", sizeof(rules[0].text) - 1); + rules[0].secs = 0; + rules[0].prob = 100; + char out[160]; + + FilterStat::formatRuleList(out, sizeof(out), rules, FILTER_RULE_COUNT, nullptr, nullptr); + + EXPECT_STREQ("^BEACON block", out); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); } + +// ---- message age ------------------------------------------------------------- + +TEST(FilterStatsAge, RecordAgeCountsDrops) { + Counters c; + FilterStat::recordAge(c); + FilterStat::recordAge(c); + EXPECT_EQ(2u, c.age); +} + +TEST(FilterStatsAge, SaysOffWhenNoLimitIsSet) { + Counters c; + char out[160]; + FilterStat::formatStatsAge(out, sizeof(out), c, 0, true); + EXPECT_STREQ("> Filter: message age limit off", out); +} + +TEST(FilterStatsAge, ShowsLimitAndDrops) { + Counters c; + c.age = 7; + char out[160]; + FilterStat::formatStatsAge(out, sizeof(out), c, 30, true); + EXPECT_STREQ("> Message age: max 30m, dropped 7", out); +} + +TEST(FilterStatsAge, FlagsAnUnsetClock) { + Counters c; + char out[160]; + FilterStat::formatStatsAge(out, sizeof(out), c, 30, false); + EXPECT_STREQ("> Message age: max 30m, dropped 0 (clock not set, inactive)", out); +} + +TEST(FilterStatsAge, ResetClearsTheCounter) { + Counters c; + c.age = 3; + c.reset(); + EXPECT_EQ(0u, c.age); +}