diff --git a/build.sh b/build.sh index f517537e..3ef1eaa3 100755 --- a/build.sh +++ b/build.sh @@ -360,6 +360,11 @@ prompt_for_debug_build_settings() { } prompt_for_mqtt_bridge_build_setting() { + if [ -n "$SELECTED_TARGET" ] && [ "$(get_platform_for_env "$SELECTED_TARGET")" == "NRF52_PLATFORM" ]; then + MQTT_BRIDGE_OVERRIDE="off" + return 0 + fi + if [ -n "$SELECTED_TARGET" ] && is_mqtt_bridge_target "$SELECTED_TARGET"; then MQTT_BRIDGE_OVERRIDE="on" echo "MQTT bridge enabled by selected target: ${SELECTED_TARGET}" @@ -1726,6 +1731,7 @@ build_firmware() { local env_platform local commit_hash local firmware_build_date + local firmware_build_epoch local firmware_version local firmware_version_string local firmware_filename @@ -1742,7 +1748,8 @@ build_firmware() { fi commit_hash=$(git rev-parse --short HEAD) - firmware_build_date=$(date '+%d-%b-%Y') + firmware_build_date=$(date -u '+%d-%b-%Y') + firmware_build_epoch=$(date -u '+%s') firmware_version=${FIRMWARE_VERSION:-} if [ -z "$firmware_version" ]; then @@ -1796,7 +1803,7 @@ build_firmware() { original_platformio_build_flags="" fi - export PLATFORMIO_BUILD_FLAGS="${original_platformio_build_flags} -DFIRMWARE_BUILD_DATE='\"${firmware_build_date}\"' -DFIRMWARE_VERSION='\"${embedded_version_string}\"' -DOTA_VARIANT='\"${env_name}\"'${mota_target_flag}" + export PLATFORMIO_BUILD_FLAGS="${original_platformio_build_flags} -DFIRMWARE_BUILD_DATE='\"${firmware_build_date}\"' -DFIRMWARE_BUILD_EPOCH=${firmware_build_epoch} -DFIRMWARE_VERSION='\"${embedded_version_string}\"' -DOTA_VARIANT='\"${env_name}\"'${mota_target_flag}" disable_debug_flags apply_debug_overrides apply_mqtt_bridge_override diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 2165deaa..78e32fde 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -13,6 +13,8 @@ This document provides an overview of CLI commands that can be sent to MeshCore - [Radio](#radio) - [System](#system) - [Routing](#routing) + - [Flood Filtering](#filter-flood-packets-by-payload-type-and-hop) + - [Group Text Moderation](#moderate-flood-group-text-by-channel-sender-and-source-path) - [ACL](#acl) - [Region Management](#region-management-v110) - [Region Examples](#region-examples) @@ -518,6 +520,112 @@ send text.flood checking ridge link --- +#### Estimate and correct repeater time after startup + +**Usage:** +- `get clock.sync` +- `get clock.sync.status` +- `get clock.sync.mesh` +- `set clock.sync.mesh ` +- `get clock.sync.internet` +- `set clock.sync.internet ` +- `get clock.sync.drift` +- `set clock.sync.drift <30-86400>` +- `get clock.sync.samples` +- `set clock.sync.samples <3-16>` + +**Defaults:** +- `clock.sync.mesh`: `off` +- `clock.sync.internet`: `off` +- `clock.sync.drift`: `3600` seconds +- `clock.sync.samples`: `9` + +When either source is enabled, the repeater makes its first clock-bootstrap +attempt after 30 minutes of uptime. A successful estimate changes the RTC only +when the absolute difference is **greater than** `clock.sync.drift`; correction +can move the clock forward or backward. A valid estimate within the threshold +completes the one-time check without changing the clock. If no source is +available yet, the repeater retries every 30 minutes. + +`clock.sync.mesh` collects signature-verified advert timestamps and MAC-valid, +decrypted Public-channel plain-text timestamps, but only after the packet passes +every forwarding filter. Sources are deduplicated by advert public key or +case-insensitive Public-channel display name. Every fresh sample must also have +a different full received path; all direct, zero-hop receptions count as the +same empty path. This prevents repeated packets or multiple names arriving over +one route from increasing the vote count. + +At least the configured number of distinct fresh paths (nine by default) and a +strict majority of all fresh samples must fall within ten minutes of the +median. The effective quorum is therefore the larger of `clock.sync.samples` +and half the fresh sample count plus one. For example, a 9-vs-7 split can +succeed but an 8-vs-8 split cannot. The median is used. `clock.sync.samples` +accepts `3` through `16`; samples older than two hours are ignored. Status +reports `collecting` when fewer than the configured number of paths exist, and +`no consensus` when enough paths exist but the effective quorum does not agree. + +A timestamp is eligible for a clock-sync sample only when it falls between the +UTC build epoch embedded by `build.sh` and that time plus ten calendar years. +Direct developer builds that bypass `build.sh` fall back to the compiler +timestamp. Validation happens before a slot is selected or written, so an +advert or Public-channel timestamp outside that window is not recorded as a +clock sample. + +Before voting, the node advances each packet timestamp by an estimated transit +time. The estimate sums the radio airtime at the original packet length and at +each progressively longer relay length, plus the expected midpoint of the +random flood-forward delay at every prior hop. The normal elapsed time since +the radio recorded local receipt is then added when consensus is evaluated, so +local signature/decryption/filter processing time is included too. This is +better than using hop count alone because LoRa airtime changes with packet +length and radio settings. Transit compensation is capped at the ten-minute +consensus window. It cannot know sender queueing, channel contention, or a +remote relay's non-matching `txdelay`, so the consensus window and median still +absorb residual error. + +If a `clock sync` or `time ` CLI command successfully sets the clock, or +a GPS provider writes a valid GPS time, LoRa-derived clock collection and +correction are suppressed for the rest of that boot. Turning +`clock.sync.mesh` off and back on does not clear this safety latch; only a +reboot does. `get clock.sync.mesh` and `get clock.sync.status` report whether +CLI or GPS time caused the suppression. On a WiFi MQTT build, a successful NTP +sync is authoritative and also suppresses LoRa correction for the rest of that +boot. Source selection starts fresh after a reboot, so LoRa remains the fallback +when NTP cannot obtain internet time during that boot. + +Public-channel display names are not authenticated and can be spoofed. Received +path hashes are also truncated, unauthenticated routing hints; requiring unique +paths prevents ordinary duplicate-route inflation but is not a cryptographic +identity check. Signed adverts authenticate the advert contents but do not +prove that the advertising node's own clock is correct. Mesh time is therefore +a consensus estimate, not an authoritative time service. + +`clock.sync.internet` is available on WiFi MQTT repeater-observer builds. Its +30-minute query runs on the MQTT/WiFi task and is read-only until the repeater +applies the configured drift test. On other repeater builds, the preference can +be stored but status reports that internet time is unavailable. MQTT builds +retain their existing startup NTP behavior required for MQTT/TLS/JWT operation; +this setting controls the additional delayed drift check. Startup NTP is always +preferred when it succeeds, regardless of this setting. + +Changing any `clock.sync.*` setting starts a new attempt for the current boot. +Settings are persistent in `/clock_sync`. + +A backward correction is intentionally allowed, but peers that already recorded +a later timestamp from this node may temporarily reject its lower timestamps as +replays until corrected time passes the previously observed value. + +**Example:** +```text +set clock.sync.drift 1800 +set clock.sync.samples 9 +set clock.sync.mesh on +set clock.sync.internet on +get clock.sync.status +``` + +--- + #### View this node's public key **Usage:** `get public.key` @@ -924,6 +1032,135 @@ del flood.channel.block.2 --- +#### Filter flood packets by payload type and hop + +For setup guidance, interactions with the existing forwarding controls, and +worked moderation examples, see [Repeater Flood Filtering and Moderation](flood_filtering.md). + +**Usage:** +- `get flood.filter` +- `get flood.filter.` +- `set flood.filter ` +- `set flood.filter. ` +- `del flood.filter.` +- `del flood.filter all` + +**Parameters:** +- `n`: Rule slot from `1` to `16`. +- `type`: Payload type name, full `PAYLOAD_TYPE_*` name, numeric value `0-15`, or `any`. +- `hops`: + - `N`: Block only at received hop count `N`. + - `N+`: Block at received hop count `N` and higher. + - `N-M`: Block the inclusive received-hop range. + - `all`: Block at every received hop count (`0-63`). + +The payload names follow the [MeshCore packet-format allocation](https://docs.meshcore.io/packet_format/): + +| Value | Short name | Full name | +| --- | --- | --- | +| `0x00` | `req` | `PAYLOAD_TYPE_REQ` | +| `0x01` | `response` | `PAYLOAD_TYPE_RESPONSE` | +| `0x02` | `txt_msg` | `PAYLOAD_TYPE_TXT_MSG` | +| `0x03` | `ack` | `PAYLOAD_TYPE_ACK` | +| `0x04` | `advert` | `PAYLOAD_TYPE_ADVERT` | +| `0x05` | `grp_txt` | `PAYLOAD_TYPE_GRP_TXT` | +| `0x06` | `grp_data` | `PAYLOAD_TYPE_GRP_DATA` | +| `0x07` | `anon_req` | `PAYLOAD_TYPE_ANON_REQ` | +| `0x08` | `path` | `PAYLOAD_TYPE_PATH` | +| `0x09` | `trace` | `PAYLOAD_TYPE_TRACE` | +| `0x0A` | `multipart` | `PAYLOAD_TYPE_MULTIPART` | +| `0x0B` | `control` | `PAYLOAD_TYPE_CONTROL` | +| `0x0C` | `ota` | `PAYLOAD_TYPE_OTA` (this fork's LoRa OTA extension; reserved upstream) | +| `0x0D` | `13` | reserved | +| `0x0E` | `14` | reserved | +| `0x0F` | `raw_custom` | `PAYLOAD_TYPE_RAW_CUSTOM` | + +**Route scope:** Rules are evaluated only for the two flood route values: +`ROUTE_TYPE_TRANSPORT_FLOOD` (`0x00`, flood plus transport codes) and +`ROUTE_TYPE_FLOOD` (`0x01`, unscoped flood). Direct routes `0x02` and `0x03` +are never affected. + +**Behavior:** A match prevents retransmission by this repeater. The packet is +still received and can still be logged. Rules are persistent and default to an +empty table, so upgrading does not change forwarding. A malformed persisted +table fails open (no general rules are applied). + +Without `.n`, `set` returns an identical existing rule or uses the first empty +slot. With `.n`, it replaces that slot. `get flood.filter` gives a compact list; +use `get flood.filter.` for full details. + +**Examples:** +```text +set flood.filter grp_data 4+ +set flood.filter.2 PAYLOAD_TYPE_ADVERT 6+ +set flood.filter ota 2-4 +set flood.filter any 12+ +get flood.filter +get flood.filter.2 +del flood.filter.2 +``` + +--- + +#### Moderate flood group text by channel, sender, and source path + +**Usage:** +- `get flood.moderation` +- `get flood.moderation.` +- `set flood.moderation [action...]` +- `set flood.moderation. [action...]` +- `del flood.moderation.` +- `del flood.moderation all` + +**Parameters:** +- `n`: Moderation slot from `1` to `16`. +- `channel`: + - `public`: Built-in Public channel. + - `#channel`: Derive the well-known hashtag-channel key. + - A 128-bit or 256-bit channel key in hex, for any other/private channel. +- `sender`: Exact group-text display name. Matching is ASCII case-insensitive. + Quote names containing spaces, for example `"Field User"`. +- `drop`: Do not forward matching messages. Equivalent to `rate=0/min`. +- `rate=X/min`: Forward at most `X` matching messages per 60-second local + window. This option requires an exact sender rather than `*`. +- `hops=N`: Do not forward a matching message whose received flood path count + is `N` or higher. `hops=all` removes this constraint. +- `path=H1[,H2,H3]`: Match the start of the flood path. One to three hashes are + accepted; every hash must have the same 1-, 2-, or 3-byte width. +- `path=*`: Match any source path (the default). + +At least one of `drop`, `rate=X/min`, or `hops=N` is required. Rate and hop +limits can be combined. Rate counters are local to this repeater and rule, use a +60-second window beginning with the first matching message, and reset on reboot. + +**Decode and identity behavior:** Moderation applies only to flood +`PAYLOAD_TYPE_GRP_TXT`. The repeater first checks the packet's channel-hash byte, +then validates and decrypts with the configured key. It extracts the text before +the first `:` from the standard `: ` plaintext. The channel key +is stored locally but is never printed by `get`. + +The group-text sender is an **unverified display name**, not a public key. It can +be spoofed. Combining it with the first one to three path hashes makes a more +useful moderation signal, but path hashes are truncated and are not proof of the +originating user. A path-qualified rule begins matching only after the packet +contains all configured starting hops; it cannot identify a first hop on a +zero-hop packet. + +As with general filtering, matching messages are still received/logged; only +retransmission is denied. There are no moderation rules by default. + +**Examples:** +```text +set flood.moderation public "Noisy User" rate=5/min +set flood.moderation #local bot drop path=A1B2C3,D4E5F6 +set flood.moderation.3 00112233445566778899AABBCCDDEEFF alice rate=10/min hops=4 path=71CE82 +get flood.moderation +get flood.moderation.3 +del flood.moderation.3 +``` + +--- + ### ACL #### Add, update or remove permissions for a companion @@ -937,6 +1174,18 @@ del flood.channel.block.2 - `1`: Read-only - `2`: Read-write - `3`: Admin + - `4`: Region manager (repeater delegated region management) + - `5`: Filter manager (repeater delegated forwarding-filter management) + +**Filter manager scope:** Permission `5` can use an explicit allowlist of +non-secret operational/filter status commands and can change the forwarding +controls `repeat`, `loop.detect`, `flood.max*`, `flood.channel.data*`, +`flood.channel.block*`, `flood.filter*`, and `flood.moderation*`. It cannot read +guest, WiFi, MQTT, bridge, or other credentials, and it cannot change regions, +ACL entries, radio settings, or other admin configuration. Permission `4` +remains limited to region commands and the same non-secret status allowlist. +Both delegated manager roles are protected from least-recently-active ACL +eviction like administrators. **Note:** Removes the entry when `permissions` is omitted diff --git a/docs/flood_filtering.md b/docs/flood_filtering.md new file mode 100644 index 00000000..f0109926 --- /dev/null +++ b/docs/flood_filtering.md @@ -0,0 +1,227 @@ +# Repeater Flood Filtering and Moderation + +This guide explains the Keymind repeater forwarding filters. The filters decide +whether this repeater retransmits a packet. They do not stop local reception, +packet logging, or MQTT observation. + +Only flood routes are filtered: + +- `0x00` / `ROUTE_TYPE_TRANSPORT_FLOOD` — flood routing with transport codes +- `0x01` / `ROUTE_TYPE_FLOOD` — unscoped flood routing + +Direct routes `0x02` and `0x03` are never affected by these rules. +The route and payload values follow the upstream +[packet-format reference](https://docs.meshcore.io/packet_format/) and +[payload layouts](https://docs.meshcore.io/payloads/), with this fork's LoRa +OTA assignment noted below. + +## Before making changes + +Show the current forwarding controls: + +```text +get repeat +get flood.max +get flood.max.unscoped +get flood.max.advert +get flood.channel.data +get flood.channel.data.hops +get flood.channel.block +get flood.filter +get flood.moderation +``` + +The `flood.filter` and `flood.moderation` tables each have 16 persistent slots. +They are empty by default. A corrupt or truncated table fails open, so corrupt +storage does not silently enable blocking. + +## Filter by payload type and received hop count + +Use `flood.filter` when the packet type and its current path length are enough +to make the decision: + +```text +set flood.filter +set flood.filter. +get flood.filter +get flood.filter. +del flood.filter. +del flood.filter all +``` + +Without a slot number, `set` reuses an identical rule or selects the first empty +slot. With a slot number, it replaces that slot. + +Hop expressions are based on the path count when this repeater receives the +packet: + +- `N` matches exactly `N` received hops. +- `N+` matches `N` or more received hops. +- `N-M` matches the inclusive range. +- `all` matches `0` through `63` hops. + +Examples: + +```text +# Stop forwarding group data once it arrives with four or more path entries. +set flood.filter grp_data 4+ + +# Stop long adverts, while still allowing shorter adverts. +set flood.filter.2 advert 6+ + +# Keep LoRa OTA floods from crossing this repeater at path counts 2 through 4. +set flood.filter.3 ota 2-4 + +# Apply a hard ceiling to every flood payload type. +set flood.filter any 12+ +``` + +Accepted payload names are: + +| Value | Short name | Full name | +| --- | --- | --- | +| `0x00` | `req` | `PAYLOAD_TYPE_REQ` | +| `0x01` | `response` | `PAYLOAD_TYPE_RESPONSE` | +| `0x02` | `txt_msg` | `PAYLOAD_TYPE_TXT_MSG` | +| `0x03` | `ack` | `PAYLOAD_TYPE_ACK` | +| `0x04` | `advert` | `PAYLOAD_TYPE_ADVERT` | +| `0x05` | `grp_txt` | `PAYLOAD_TYPE_GRP_TXT` | +| `0x06` | `grp_data` | `PAYLOAD_TYPE_GRP_DATA` | +| `0x07` | `anon_req` | `PAYLOAD_TYPE_ANON_REQ` | +| `0x08` | `path` | `PAYLOAD_TYPE_PATH` | +| `0x09` | `trace` | `PAYLOAD_TYPE_TRACE` | +| `0x0A` | `multipart` | `PAYLOAD_TYPE_MULTIPART` | +| `0x0B` | `control` | `PAYLOAD_TYPE_CONTROL` | +| `0x0C` | `ota` | `PAYLOAD_TYPE_OTA` in this fork | +| `0x0D` | `13` | reserved | +| `0x0E` | `14` | reserved | +| `0x0F` | `raw_custom` | `PAYLOAD_TYPE_RAW_CUSTOM` | + +The numeric values `0` through `15`, the full `PAYLOAD_TYPE_*` names, and +`any` are also accepted. Upstream currently reserves `0x0C`; this fork assigns +it to LoRa OTA. + +## Moderate group text by channel and username + +Use `flood.moderation` for flood `GRP_TXT` messages. The repeater validates and +decrypts the selected channel, extracts the display name before the first `:`, +then applies the rule: + +```text +set flood.moderation [action...] +set flood.moderation. [action...] +get flood.moderation +get flood.moderation. +del flood.moderation. +del flood.moderation all +``` + +Channels can be specified as: + +- `public` for the built-in Public channel +- `#name` for a well-known hashtag channel +- a 128-bit or 256-bit channel key in hexadecimal + +The key is stored locally so packets can be authenticated and decrypted. It is +not included in `get flood.moderation` output. + +Available actions are: + +- `drop` — do not retransmit any matching message +- `rate=X/min` — retransmit at most `X` messages per local 60-second window +- `hops=N` — do not retransmit when the received path count is `N` or higher +- `path=H1[,H2,H3]` — require the first one to three path hashes to match +- `path=*` — match every path; this is the default + +At least one of `drop`, `rate=X/min`, or `hops=N` is required. Rate and hop +limits can be combined. `rate=0/min` is equivalent to `drop`. + +### Per-user, per-channel rate limits + +Rate limits require an exact username; `*` is not accepted for a rate rule. +Username comparison is ASCII case-insensitive, and names containing spaces must +be quoted. A rule's counter is independent from rules for the same name on +other channels, so this directly supports “X messages per minute from user X +on channel Y.” Counters are local to this repeater and reset on reboot. + +```text +# At most five Public-channel messages per minute from this display name. +set flood.moderation public "Noisy User" rate=5/min + +# A separate limit for the same name on #local. +set flood.moderation #local "Noisy User" rate=10/min + +# Combine a rate limit with a maximum forwarding distance. +set flood.moderation public alice rate=4/min hops=5 +``` + +### Match the start of a path + +Path matching accepts one-, two-, or three-byte hashes. Every hash in one rule +must use the same width, and matching always starts at the beginning of the +received path: + +```text +set flood.moderation #local bot drop path=A1B2C3,D4E5F6 +set flood.moderation public alice rate=3/min path=71 +``` + +A path-qualified rule cannot match a zero-hop packet and does not match until +the packet contains all path entries listed by the rule. + +## How the forwarding controls combine + +A flood packet is retransmitted only if it passes every applicable control. In +other words, the controls combine as deny rules: + +1. `repeat`, `flood.max*`, and the channel-data gate are checked. +2. `flood.filter` checks payload type and hop range. +3. `flood.channel.block` checks keyed channels. +4. Region and loop-detection rules are checked. +5. `flood.moderation` checks decrypted group text, username, rate, hops, and path. + +The first denial is enough to prevent retransmission. A packet that is denied +can still appear in local logs or MQTT output. Moderation runs last because its +rate counters are charged only for packets that pass every other forwarding +control and will actually be retransmitted. + +## Delegate filter management + +ACL permission `5` is the filter-manager role: + +```text +setperm 5 +``` + +A filter manager can read non-secret operational status and manage `repeat`, +`loop.detect`, `flood.max*`, `flood.channel.data*`, `flood.channel.block*`, +`flood.filter*`, and `flood.moderation*`. Delegated `get` access uses an +explicit allowlist: it cannot retrieve guest, WiFi, MQTT, bridge, or other +credentials, and it cannot change regions, ACL entries, radio settings, or +unrelated administrator settings. + +## Security limitations + +Public and hashtag channels use shared, well-known keys. A valid channel MAC +proves that the sender knew the channel key; it does not identify a person. +The `` value is an unverified display name and can be spoofed. Path +hashes are truncated routing hints and can collide or be manipulated; they are +not authenticated user identities. + +Use username and path rules as traffic moderation, not as an authorization +boundary. For a strict network boundary, combine these tools with region ACLs, +private transport/channel keys, and controlled device access. + +## Remove the custom rules + +To return the two new tables to their default empty state: + +```text +del flood.filter all +del flood.moderation all +get flood.filter +get flood.moderation +``` + +This does not change the older `flood.max*`, channel-block, loop-detection, or +region settings; inspect or reset those separately when troubleshooting. diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index 6ecd9ed9..228383bb 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -2,6 +2,8 @@ This file covers only CLI settings and helper commands added by the Halo or Keymind branches. Use `docs/cli_commands.md` for the general MeshCore CLI. +See [Repeater Flood Filtering and Moderation](flood_filtering.md) for a focused +filter setup and troubleshooting guide. ## Quick Start @@ -81,6 +83,12 @@ set flood.retry.ignore none | `flood.channel.data.hops` | Separate hop gate used only when `flood.channel.data` is `off`; `all` blocks `GRP_DATA` at any hop count, `1`-`7` repeats at that hop count or lower and blocks longer paths. | `get flood.channel.data.hops`, `set flood.channel.data.hops ` | `set flood.channel.data.hops 7` | | `flood.channel.block` | Blocks selected flood `GRP_TXT`/`GRP_DATA` channels when the key validates the packet. New repeater block lists start with editable/deletable `#wardriving h=4`. Add `h=` for a per-channel hop override. | `get flood.channel.block`, `set flood.channel.block[.n] [name] [h=...]`, `del flood.channel.block[.n]` | `set flood.channel.block #wardriving h=4` | | `flood.channel.block.hops` | Limits keyed channel-block matches to short flood paths. `all` blocks matching packets at any hop count; `1`-`7` repeats packets at that hop count or lower and blocks longer matches. This does not restrict unkeyed `GRP_DATA`; use `flood.channel.data.hops` for that. | `get flood.channel.block.hops`, `set flood.channel.block.hops ` | `set flood.channel.block.hops 3` | +| `flood.filter` | Persistent repeater-only forwarding rules for flood routes `0x00`/`0x01`, selected by payload type and received hop count/range. Direct routing and local receive/logging are unchanged. | `get flood.filter[.n]`, `set flood.filter[.n] `, `del flood.filter.|all` | `set flood.filter grp_data 4+` | +| `flood.moderation` | Decrypts keyed `GRP_TXT` channels and applies drop, per-username messages/minute, and maximum-hop controls, optionally matched against the first 1-3 path hashes. Supports `public`, `#channel`, and 128/256-bit channel keys. Sender names and truncated path hashes are moderation hints, not authenticated identities. | `get flood.moderation[.n]`, `set flood.moderation[.n] [path=...]`, `del flood.moderation.|all` | `set flood.moderation public "Noisy User" rate=5/min hops=4` | +| `clock.sync.mesh` | After 30 minutes of uptime, estimates UTC from a configurable consensus of fresh signed-advert or valid Public-channel sources. Only timestamps from firmware build time through build time plus ten years are recorded. Successful CLI, GPS, or WiFi/NTP clock updates suppress LoRa time collection until reboot; after reboot LoRa is the fallback if NTP cannot sync. | `get clock.sync.mesh`, `set clock.sync.mesh `, `get clock.sync.status` | `set clock.sync.mesh on` | +| `clock.sync.internet` | Adds a read-only internet/NTP estimate at the same delayed check on WiFi MQTT repeater-observer builds. Other builds retain the setting but report internet unavailable. | `get clock.sync.internet`, `set clock.sync.internet ` | `set clock.sync.internet on` | +| `clock.sync.drift` | Absolute correction threshold in seconds. The clock is moved forward or backward only when the estimate differs by more than this value. | `get clock.sync.drift`, `set clock.sync.drift <30-86400>` | `set clock.sync.drift 3600` | +| `clock.sync.samples` | Minimum number of distinct, in-window receive paths that must agree before mesh time can be used. A strict majority of all fresh samples is also required. Range `3-16`; default `9`. | `get clock.sync.samples`, `set clock.sync.samples <3-16>` | `set clock.sync.samples 9` | | `outpath` | Overrides the primary direct route used for replies to the current remote client. | `get outpath`, `set outpath `, `set outpath direct`, `set outpath clear`, `set outpath flood` | `set outpath A1B2C3,D4E5F6` | | `altpath` | Adds a secondary direct route for repeater replies to the current remote client. | `get altpath`, `set altpath `, `set altpath direct`, `set altpath clear`, `set altpath flood` | `set altpath 71CE82,BA09F0` | diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b89047cf..0d41588c 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -14,8 +14,10 @@ static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) { return delay_ms > 30000UL ? 30000UL : delay_ms; } +static const uint32_t COMMAND_RADIO_APPLY_TIMEOUT_MS = 5000UL; + #ifndef RXPS_FIXED_ENABLED -#define RXPS_FIXED_ENABLED 1 +#define RXPS_FIXED_ENABLED 0 #endif #ifndef RXPS_FIXED_LEVEL #define RXPS_FIXED_LEVEL 2 @@ -350,11 +352,11 @@ static bool calcFixedRxPowerSaving(uint8_t sf, float bw, uint32_t* rx_us, uint32 return true; } -static bool applyFixedRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) { +static mesh::RadioParamApplyResult applyFixedRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) { uint32_t rx_us, sleep_us; if (!calcFixedRxPowerSaving(sf, bw, &rx_us, &sleep_us)) { MESH_DEBUG_PRINTLN("RX Power Saving fixed profile invalid"); - return false; + return mesh::RadioParamApplyResult::FAILED; } uint32_t timings[2] = {rx_us, sleep_us}; @@ -363,14 +365,17 @@ static bool applyFixedRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) // radio does not implement RX duty cycling. This matches the former // setParams()+setRxPowerSaving() behavior while retaining one atomic // transition on radios that do support it. - bool ok = radio_driver.setParams(freq, bw, sf, cr, supports_rxps ? timings : NULL); + mesh::RadioParamApplyResult result = + radio_driver.trySetParams(freq, bw, sf, cr, supports_rxps ? timings : NULL); MESH_DEBUG_PRINTLN("RX Power Saving fixed level %d p%d: %s (%lu/%lu us)", RXPS_FIXED_LEVEL, RXPS_FIXED_PREAMBLE, - ok && supports_rxps ? "Enabled" : (ok ? "Unsupported" : "Apply failed"), + result == mesh::RadioParamApplyResult::APPLIED + ? (supports_rxps ? "Enabled" : "Unsupported") + : (result == mesh::RadioParamApplyResult::BUSY ? "Busy" : "Apply failed"), (unsigned long)rx_us, (unsigned long)sleep_us); - return ok; + return result; } #endif int MyMesh::calcRxDelay(float score, uint32_t air_time) const { @@ -1115,6 +1120,13 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe saved_radio_apply_pending = false; radio_apply_retry_at = 0; radio_apply_failures = 0; + command_radio_apply_pending = false; + command_radio_freq = 0.0f; + command_radio_bw = 0.0f; + command_radio_sf = 0; + command_radio_cr = 0; + command_radio_repeat = 0; + command_radio_apply_deadline = 0; offline_queue_len = 0; app_target_ver = 0; clearPendingReqs(); @@ -1278,14 +1290,80 @@ void MyMesh::begin(bool has_display) { #endif } -bool MyMesh::applySavedRadioParams() { +mesh::RadioParamApplyResult MyMesh::tryApplyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) { #if RXPS_FIXED_ENABLED - return applyFixedRadioParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + return applyFixedRadioParams(freq, bw, sf, cr); #else - return radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + return radio_driver.trySetParams(freq, bw, sf, cr); #endif } +bool MyMesh::applySavedRadioParams() { + return tryApplyRadioParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr) + == mesh::RadioParamApplyResult::APPLIED; +} + +void MyMesh::finishRadioParamApply(float freq, float bw, uint8_t sf, uint8_t cr, uint8_t repeat) { + _prefs.sf = sf; + _prefs.cr = cr; + _prefs.freq = freq; + _prefs.bw = bw; + _prefs.client_repeat = repeat; + savePrefs(); + + saved_radio_apply_pending = false; + radio_apply_retry_at = 0; + radio_apply_failures = 0; + + MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", + (uint32_t)(freq * 1000.0f), (uint32_t)(bw * 1000.0f), + (uint32_t)sf, (uint32_t)cr); + writeOKFrame(); +} + +void MyMesh::cancelPendingRadioParamApply() { + if (!command_radio_apply_pending) return; + + command_radio_apply_pending = false; + command_radio_apply_deadline = 0; + // The requested tuple was not committed. Reassert the persisted tuple in + // case a failed hardware apply only restored part of the old configuration. + saved_radio_apply_pending = true; + radio_apply_retry_at = 0; + radio_apply_failures = 0; +} + +void MyMesh::servicePendingRadioParamApply() { + if (!command_radio_apply_pending) return; + if (!_serial || !_serial->isConnected()) { + cancelPendingRadioParamApply(); + return; + } + + // Leave enough queue headroom for the eventual command response. + if (_serial->isWriteBusy()) return; + + mesh::RadioParamApplyResult result = tryApplyRadioParams( + command_radio_freq, command_radio_bw, command_radio_sf, command_radio_cr); + if (result == mesh::RadioParamApplyResult::APPLIED) { + float freq = command_radio_freq; + float bw = command_radio_bw; + uint8_t sf = command_radio_sf; + uint8_t cr = command_radio_cr; + uint8_t repeat = command_radio_repeat; + command_radio_apply_pending = false; + command_radio_apply_deadline = 0; + finishRadioParamApply(freq, bw, sf, cr, repeat); + } else if (result == mesh::RadioParamApplyResult::FAILED) { + cancelPendingRadioParamApply(); + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else if (command_radio_apply_deadline == _ms->getMillis() + || millisHasNowPassed(command_radio_apply_deadline)) { + cancelPendingRadioParamApply(); + writeErrFrame(ERR_CODE_BAD_STATE); + } +} + const char *MyMesh::getNodeName() { return _prefs.node_name; } @@ -1408,6 +1486,7 @@ void MyMesh::handleCmdFrame(size_t len) { MESH_DEBUG_PRINTLN("App %s connected", app_name); _iter_started = false; // stop any left-over ContactsIterator + cancelPendingRadioParamApply(); int i = 0; out_frame[i++] = RESP_CODE_SELF_INFO; out_frame[i++] = ADV_TYPE_CHAT; // what this node Advert identifies as (maybe node's pronouns too?? :-) @@ -1809,29 +1888,34 @@ void MyMesh::handleCmdFrame(size_t len) { bw <= 500000) { float new_freq = (float)freq / 1000.0; float new_bw = (float)bw / 1000.0; -#if RXPS_FIXED_ENABLED - bool applied = applyFixedRadioParams(new_freq, new_bw, sf, cr); -#else - bool applied = radio_driver.setParams(new_freq, new_bw, sf, cr); -#endif - if (!applied) { - // Persisted settings remain authoritative after a rejected change. - saved_radio_apply_pending = true; - radio_apply_retry_at = 0; + if (command_radio_apply_pending) { writeErrFrame(ERR_CODE_BAD_STATE); return; } - _prefs.sf = sf; - _prefs.cr = cr; - _prefs.freq = new_freq; - _prefs.bw = new_bw; - _prefs.client_repeat = repeat; - savePrefs(); - MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, - (uint32_t)cr); + mesh::RadioParamApplyResult result = tryApplyRadioParams(new_freq, new_bw, sf, cr); + if (result == mesh::RadioParamApplyResult::BUSY) { + command_radio_apply_pending = true; + command_radio_freq = new_freq; + command_radio_bw = new_bw; + command_radio_sf = sf; + command_radio_cr = cr; + command_radio_repeat = repeat; + command_radio_apply_deadline = futureMillis(COMMAND_RADIO_APPLY_TIMEOUT_MS); + MESH_DEBUG_PRINTLN("Deferred CMD_SET_RADIO_PARAMS while radio is busy"); + return; + } + if (result == mesh::RadioParamApplyResult::FAILED) { + // Persisted settings remain authoritative after a rejected change or + // a hardware apply that had to be rolled back. + saved_radio_apply_pending = true; + radio_apply_retry_at = 0; + radio_apply_failures = 0; + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return; + } - writeOKFrame(); + finishRadioParamApply(new_freq, new_bw, sf, cr, repeat); } else { MESH_DEBUG_PRINTLN("Error: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, (uint32_t)cr); @@ -1938,6 +2022,7 @@ void MyMesh::handleCmdFrame(size_t len) { self_id = identity; writeOKFrame(); // re-load contacts, to invalidate ecdh shared_secrets + _iter_started = false; resetContacts(); _store->loadContacts(this); updateGpsTelemetryPolicy(); @@ -2668,6 +2753,12 @@ void MyMesh::checkCLIRescueCmd() { void MyMesh::checkSerialInterface() { size_t len = _serial->checkRecvFrame(cmd_frame); + if (!_serial->isConnected()) { + _iter_started = false; + cancelPendingRadioParamApply(); + return; + } + if (len > 0) { handleCmdFrame(len); } else if (_iter_started // check if our ContactsIterator is 'running' @@ -2695,7 +2786,7 @@ void MyMesh::checkSerialInterface() { void MyMesh::loop() { BaseChatMesh::loop(); - if (saved_radio_apply_pending && !hasOutbound() + if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound() && (!radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at))) { // A power-saving wake can enter begin() with a complete packet already // waiting. Preserve that packet, then apply the persisted radio settings @@ -2725,6 +2816,7 @@ void MyMesh::loop() { } else { checkSerialInterface(); } + servicePendingRadioParamApply(); // is there are pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { @@ -2755,7 +2847,10 @@ bool MyMesh::advert() { // To check if there is pending work bool MyMesh::hasPendingWork() const { if (radio_driver.isWatchdogObserving() || radio_driver.isCalibratingNoiseFloor()) return true; - return hasQueuedWorkDue() || hasRetryWorkDue() + return (_serial != NULL && _serial->hasPendingIO()) + || (_iter_started && _serial != NULL && _serial->isConnected()) + || command_radio_apply_pending + || hasQueuedWorkDue() || hasRetryWorkDue() || (saved_radio_apply_pending && (!radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at))) || (dirty_contacts_expiry != 0 && millisHasNowPassed(dirty_contacts_expiry)) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index b2c51bdf..3da32813 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -222,7 +222,11 @@ private: bool isValidClientRepeatFreq(uint32_t f) const; bool hasLocationTelemetryRecipient(); void updateGpsTelemetryPolicy(); + mesh::RadioParamApplyResult tryApplyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr); bool applySavedRadioParams(); + void finishRadioParamApply(float freq, float bw, uint8_t sf, uint8_t cr, uint8_t repeat); + void cancelPendingRadioParamApply(); + void servicePendingRadioParamApply(); // helpers, short-cuts void saveChannels() { _store->saveChannels(this); } @@ -253,6 +257,13 @@ private: bool saved_radio_apply_pending; unsigned long radio_apply_retry_at; uint8_t radio_apply_failures; + bool command_radio_apply_pending; + float command_radio_freq; + float command_radio_bw; + uint8_t command_radio_sf; + uint8_t command_radio_cr; + uint8_t command_radio_repeat; + unsigned long command_radio_apply_deadline; bool send_unscoped; // force un-scoped flood (instead of using send_scope) char cli_command[80]; uint8_t app_target_ver; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 9bb44d00..2c2e5c4b 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1,6 +1,7 @@ #include "MyMesh.h" #include #include // for qsort() +#include #include /* ------------------------------ Config -------------------------------- */ @@ -85,6 +86,9 @@ #define LAZY_CONTACTS_WRITE_DELAY 5000 #define FLOOD_CHANNEL_BLOCK_FILE "/flood_ch_block" +#define FLOOD_PACKET_FILTER_FILE "/flood_filter" +#define FLOOD_GROUP_MODERATION_FILE "/flood_grp_mod" +#define CLOCK_SYNC_PREFS_FILE "/clock_sync" #define DEFAULT_FLOOD_CHANNEL_BLOCK_NAME "#wardriving" #ifndef DEFAULT_FLOOD_CHANNEL_BLOCK_HOPS #define DEFAULT_FLOOD_CHANNEL_BLOCK_HOPS 4 @@ -106,6 +110,63 @@ #define LOW_BATTERY_ALERT_INTERVAL (12UL * 60UL * 60UL * 1000UL) #define RX_INACTIVITY_WATCHDOG_INTERVAL (12UL * 60UL * 60UL * 1000UL) +#define CLOCK_SYNC_VALID_YEARS 10 + +enum ClockSyncSource : uint8_t { + CLOCK_SYNC_SOURCE_NONE = 0, + CLOCK_SYNC_SOURCE_MESH = 1, + CLOCK_SYNC_SOURCE_INTERNET = 2 +}; + +enum ClockSyncResult : uint8_t { + CLOCK_SYNC_RESULT_WAITING = 0, + CLOCK_SYNC_RESULT_COLLECTING = 1, + CLOCK_SYNC_RESULT_INTERNET_PENDING = 2, + CLOCK_SYNC_RESULT_NO_CONSENSUS = 3, + CLOCK_SYNC_RESULT_INTERNET_UNAVAILABLE = 4, + CLOCK_SYNC_RESULT_WITHIN_DRIFT = 5, + CLOCK_SYNC_RESULT_CORRECTED_FORWARD = 6, + CLOCK_SYNC_RESULT_CORRECTED_BACKWARD = 7 +}; + +static bool clockSyncLeapYear(uint16_t year) { + return (year % 4U == 0 && year % 100U != 0) || year % 400U == 0; +} + +static uint32_t clockSyncMinimumValidEpoch() { +#if FIRMWARE_BUILD_EPOCH > 0 + return (uint32_t)FIRMWARE_BUILD_EPOCH; +#else + static uint32_t minimum = 0; + if (minimum == 0) minimum = DateTime(__DATE__, __TIME__).unixtime(); + return minimum; +#endif +} + +static uint32_t clockSyncMaximumValidEpoch() { + static uint32_t maximum = 0; + if (maximum == 0) { + DateTime built(clockSyncMinimumValidEpoch()); + uint16_t upper_year = built.year() + CLOCK_SYNC_VALID_YEARS; + uint8_t upper_day = built.day(); + if (built.month() == 2 && upper_day == 29 && !clockSyncLeapYear(upper_year)) upper_day = 28; + maximum = DateTime(upper_year, built.month(), upper_day, + built.hour(), built.minute(), built.second()).unixtime(); + } + return maximum; +} + +static bool clockSyncEpochIsValid(uint32_t epoch) { + return epoch >= clockSyncMinimumValidEpoch() && epoch <= clockSyncMaximumValidEpoch(); +} + +// Channel encryption uses a 128-bit key, while MACThenDecrypt's shared-secret +// buffer is PUB_KEY_SIZE bytes. Keep the unused half zero-padded like GroupChannel. +static const uint8_t FLOOD_PUBLIC_CHANNEL_SECRET[PUB_KEY_SIZE] = { + 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, + 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72 +}; + static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) { uint8_t shift = failure_count < 5 ? failure_count : 5; if (failure_count < 6) failure_count++; @@ -352,7 +413,7 @@ uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secr memcpy(reply_data, &now, 4); // response packets always prefixed with timestamp reply_data[4] = RESP_SERVER_LOGIN_OK; reply_data[5] = 0; // Legacy: was recommended keep-alive interval (secs / 16) - reply_data[6] = (client->isAdmin() || client->isRegionMgr()) ? 1 : 0; + reply_data[6] = (client->isAdmin() || client->isRegionMgr() || client->isFilterMgr()) ? 1 : 0; reply_data[7] = client->permissions; getRNG()->random(&reply_data[8], 4); // random blob to help packet-hash uniqueness reply_data[12] = FIRMWARE_VER_LEVEL; // New field @@ -784,6 +845,7 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { packet->getPathHashCount()); return false; } + if (shouldBlockFloodPacketForward(packet)) return false; if (shouldBlockFloodChannelForward(packet)) return false; } if (packet->isRouteFlood() && recv_pkt_region == NULL) { @@ -804,6 +866,14 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { return false; } } + // Moderation has rate-counter side effects, so evaluate it only after every + // other forwarding gate has accepted the packet. Quota is then spent only + // for a message this repeater will actually retransmit. + if (packet->isRouteFlood() && shouldBlockFloodGroupTextForward(packet)) return false; + // Clock evidence is collected only after every forwarding filter accepts the + // packet. Blocked regions, moderated users, hop/type rules, and looped packets + // therefore cannot influence the estimate. + recordAcceptedFloodClockSample(packet); return true; } @@ -2154,7 +2224,8 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, } else { MESH_DEBUG_PRINTLN("onPeerDataRecv: possible replay attack detected"); } - } else if (type == PAYLOAD_TYPE_TXT_MSG && len > 5 && (client->isAdmin() || client->isRegionMgr())) { // a CLI command + } else if (type == PAYLOAD_TYPE_TXT_MSG && len > 5 + && (client->isAdmin() || client->isRegionMgr() || client->isFilterMgr())) { // a CLI command uint32_t sender_timestamp; memcpy(&sender_timestamp, data, 4); // timestamp (by sender's RTC clock - which could be wrong) uint8_t flags = (data[4] >> 2); // message attempt number, and other flags @@ -2347,6 +2418,25 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc memset(flood_retry_bridge_reachability, 0, sizeof(flood_retry_bridge_reachability)); recv_pkt_region = NULL; memset(flood_channel_blocks, 0, sizeof(flood_channel_blocks)); + memset(flood_packet_filters, 0, sizeof(flood_packet_filters)); + memset(flood_group_moderation, 0, sizeof(flood_group_moderation)); + memset(clock_sync_samples, 0, sizeof(clock_sync_samples)); + clock_sync_mesh_enabled = false; + clock_sync_internet_enabled = false; + clock_sync_complete = false; + clock_sync_internet_pending = false; + clock_sync_mesh_suppressed_by = CLOCK_SYNC_MESH_SUPPRESS_NONE; + clock_sync_last_result = CLOCK_SYNC_RESULT_WAITING; + clock_sync_last_source = CLOCK_SYNC_SOURCE_NONE; + clock_sync_last_sample_count = 0; + clock_sync_last_fresh_count = 0; + clock_sync_last_required_count = CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT; + clock_sync_required_samples = CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT; + clock_sync_drift_seconds = CLOCK_SYNC_DRIFT_DEFAULT_SECONDS; + clock_sync_last_estimate = 0; + clock_sync_last_abs_drift = 0; + clock_sync_internet_requested_millis = 0; + clock_sync_next_attempt_uptime = CLOCK_SYNC_STARTUP_DELAY_MILLIS; #if MAX_NEIGHBOURS memset(neighbours, 0, sizeof(neighbours)); @@ -2456,6 +2546,9 @@ void MyMesh::begin(FILESYSTEM *fs) { // TODO: key_store.begin(); region_map.load(_fs); loadFloodChannelBlocks(); + loadFloodPacketFilters(); + loadFloodGroupModeration(); + loadClockSyncPrefs(); // establish default-scope { @@ -3722,6 +3815,1471 @@ bool MyMesh::saveFloodChannelBlocks() { return success; } +static const char* skipFloodFilterSpaces(const char* text) { + while (text != NULL && *text == ' ') text++; + return text == NULL ? "" : text; +} + +static bool floodFilterAsciiEqual(const char* left, const char* right) { + if (left == NULL || right == NULL) return false; + while (*left && *right) { + char a = *left++; + char b = *right++; + if (a >= 'A' && a <= 'Z') a = (char)(a - 'A' + 'a'); + if (b >= 'A' && b <= 'Z') b = (char)(b - 'A' + 'a'); + if (a != b) return false; + } + return *left == 0 && *right == 0; +} + +static bool floodFilterAsciiStartsWith(const char* text, const char* prefix) { + if (text == NULL || prefix == NULL) return false; + while (*prefix) { + char a = *text++; + char b = *prefix++; + if (a >= 'A' && a <= 'Z') a = (char)(a - 'A' + 'a'); + if (b >= 'A' && b <= 'Z') b = (char)(b - 'A' + 'a'); + if (a != b) return false; + } + return true; +} + +static bool parseFloodFilterUnsigned(const char* text, uint8_t maximum, uint8_t& value) { + if (text == NULL || *text == 0) return false; + uint16_t parsed = 0; + for (const char* p = text; *p; p++) { + if (*p < '0' || *p > '9') return false; + uint16_t digit = (uint16_t)(*p - '0'); + if (digit > maximum || parsed > (uint16_t)(maximum - digit) / 10U) return false; + parsed = (uint16_t)(parsed * 10U + digit); + } + value = (uint8_t)parsed; + return true; +} + +static const char* floodFilterPayloadTypeName(uint8_t type) { + switch (type) { + case PAYLOAD_TYPE_REQ: return "req"; + case PAYLOAD_TYPE_RESPONSE: return "response"; + case PAYLOAD_TYPE_TXT_MSG: return "txt_msg"; + case PAYLOAD_TYPE_ACK: return "ack"; + case PAYLOAD_TYPE_ADVERT: return "advert"; + case PAYLOAD_TYPE_GRP_TXT: return "grp_txt"; + case PAYLOAD_TYPE_GRP_DATA: return "grp_data"; + case PAYLOAD_TYPE_ANON_REQ: return "anon_req"; + case PAYLOAD_TYPE_PATH: return "path"; + case PAYLOAD_TYPE_TRACE: return "trace"; + case PAYLOAD_TYPE_MULTIPART: return "multipart"; + case PAYLOAD_TYPE_CONTROL: return "control"; + case PAYLOAD_TYPE_OTA: return "ota"; + case 0x0D: return "reserved13"; + case 0x0E: return "reserved14"; + case PAYLOAD_TYPE_RAW_CUSTOM: return "raw_custom"; + case FLOOD_PACKET_FILTER_ANY_TYPE: return "any"; + default: return "invalid"; + } +} + +static bool parseFloodFilterPayloadType(const char* text, uint8_t& type) { + uint8_t numeric; + if (parseFloodFilterUnsigned(text, PH_TYPE_MASK, numeric)) { + type = numeric; + return true; + } + if (floodFilterAsciiStartsWith(text, "payload_type_")) text += strlen("payload_type_"); + if (floodFilterAsciiEqual(text, "any")) type = FLOOD_PACKET_FILTER_ANY_TYPE; + else if (floodFilterAsciiEqual(text, "req")) type = PAYLOAD_TYPE_REQ; + else if (floodFilterAsciiEqual(text, "response") || floodFilterAsciiEqual(text, "resp")) type = PAYLOAD_TYPE_RESPONSE; + else if (floodFilterAsciiEqual(text, "txt_msg") || floodFilterAsciiEqual(text, "txt")) type = PAYLOAD_TYPE_TXT_MSG; + else if (floodFilterAsciiEqual(text, "ack")) type = PAYLOAD_TYPE_ACK; + else if (floodFilterAsciiEqual(text, "advert")) type = PAYLOAD_TYPE_ADVERT; + else if (floodFilterAsciiEqual(text, "grp_txt") || floodFilterAsciiEqual(text, "group_text")) type = PAYLOAD_TYPE_GRP_TXT; + else if (floodFilterAsciiEqual(text, "grp_data") || floodFilterAsciiEqual(text, "group_data")) type = PAYLOAD_TYPE_GRP_DATA; + else if (floodFilterAsciiEqual(text, "anon_req")) type = PAYLOAD_TYPE_ANON_REQ; + else if (floodFilterAsciiEqual(text, "path")) type = PAYLOAD_TYPE_PATH; + else if (floodFilterAsciiEqual(text, "trace")) type = PAYLOAD_TYPE_TRACE; + else if (floodFilterAsciiEqual(text, "multipart")) type = PAYLOAD_TYPE_MULTIPART; + else if (floodFilterAsciiEqual(text, "control")) type = PAYLOAD_TYPE_CONTROL; + else if (floodFilterAsciiEqual(text, "ota")) type = PAYLOAD_TYPE_OTA; + else if (floodFilterAsciiEqual(text, "raw") || floodFilterAsciiEqual(text, "raw_custom")) type = PAYLOAD_TYPE_RAW_CUSTOM; + else return false; + return true; +} + +static bool parseFloodFilterHopSpec(const char* text, uint8_t& min_hops, uint8_t& max_hops) { + if (text == NULL || *text == 0) return false; + if (floodFilterAsciiEqual(text, "all")) { + min_hops = 0; + max_hops = FLOOD_PACKET_FILTER_MAX_HOPS; + return true; + } + + char spec[12]; + if (strlen(text) >= sizeof(spec)) return false; + strcpy(spec, text); + size_t len = strlen(spec); + if (len > 1 && spec[len - 1] == '+') { + spec[len - 1] = 0; + if (!parseFloodFilterUnsigned(spec, FLOOD_PACKET_FILTER_MAX_HOPS, min_hops)) return false; + max_hops = FLOOD_PACKET_FILTER_MAX_HOPS; + return true; + } + + char* dash = strchr(spec, '-'); + if (dash != NULL) { + *dash++ = 0; + if (!parseFloodFilterUnsigned(spec, FLOOD_PACKET_FILTER_MAX_HOPS, min_hops) + || !parseFloodFilterUnsigned(dash, FLOOD_PACKET_FILTER_MAX_HOPS, max_hops)) { + return false; + } + return min_hops <= max_hops; + } + + if (!parseFloodFilterUnsigned(spec, FLOOD_PACKET_FILTER_MAX_HOPS, min_hops)) return false; + max_hops = min_hops; + return true; +} + +static void formatFloodFilterHopSpec(char* dest, size_t dest_len, uint8_t min_hops, uint8_t max_hops) { + if (min_hops == 0 && max_hops == FLOOD_PACKET_FILTER_MAX_HOPS) { + snprintf(dest, dest_len, "all"); + } else if (max_hops == FLOOD_PACKET_FILTER_MAX_HOPS) { + snprintf(dest, dest_len, "%u+", (uint32_t)min_hops); + } else if (min_hops == max_hops) { + snprintf(dest, dest_len, "%u", (uint32_t)min_hops); + } else { + snprintf(dest, dest_len, "%u-%u", (uint32_t)min_hops, (uint32_t)max_hops); + } +} + +void MyMesh::loadFloodPacketFilters() { + memset(flood_packet_filters, 0, sizeof(flood_packet_filters)); + if (_fs == NULL || !_fs->exists(FLOOD_PACKET_FILTER_FILE)) return; + + File file = openFloodChannelBlockRead(_fs, FLOOD_PACKET_FILTER_FILE); + if (!file) return; + + FloodPacketFilterEntry loaded[FLOOD_PACKET_FILTER_SLOTS]; + memset(loaded, 0, sizeof(loaded)); + uint8_t magic[4]; + uint8_t count = 0; + bool success = file.read(magic, sizeof(magic)) == sizeof(magic) + && memcmp(magic, "FPF1", sizeof(magic)) == 0 + && file.read(&count, sizeof(count)) == sizeof(count) + && count <= FLOOD_PACKET_FILTER_SLOTS; + + for (int i = 0; success && i < count; i++) { + uint8_t active = 0; + success = file.read(&active, sizeof(active)) == sizeof(active); + success = success && file.read(&loaded[i].payload_type, sizeof(loaded[i].payload_type)) == sizeof(loaded[i].payload_type); + success = success && file.read(&loaded[i].min_hops, sizeof(loaded[i].min_hops)) == sizeof(loaded[i].min_hops); + success = success && file.read(&loaded[i].max_hops, sizeof(loaded[i].max_hops)) == sizeof(loaded[i].max_hops); + loaded[i].active = active != 0; + if (success && loaded[i].active + && !((loaded[i].payload_type <= PH_TYPE_MASK + || loaded[i].payload_type == FLOOD_PACKET_FILTER_ANY_TYPE) + && loaded[i].min_hops <= loaded[i].max_hops + && loaded[i].max_hops <= FLOOD_PACKET_FILTER_MAX_HOPS)) { + success = false; + } + } + file.close(); + + // A truncated or invalid file fails open; filtering must never be enabled by corrupt bytes. + if (success) memcpy(flood_packet_filters, loaded, sizeof(flood_packet_filters)); +} + +bool MyMesh::saveFloodPacketFilters() { + if (_fs == NULL) return false; + File file = openFloodChannelBlockWrite(_fs, FLOOD_PACKET_FILTER_FILE); + if (!file) return false; + + const uint8_t magic[4] = {'F', 'P', 'F', '1'}; + uint8_t count = FLOOD_PACKET_FILTER_SLOTS; + bool success = file.write(magic, sizeof(magic)) == sizeof(magic) + && file.write(&count, sizeof(count)) == sizeof(count); + for (int i = 0; success && i < FLOOD_PACKET_FILTER_SLOTS; i++) { + const auto& entry = flood_packet_filters[i]; + uint8_t active = entry.active ? 1 : 0; + success = file.write(&active, sizeof(active)) == sizeof(active); + success = success && file.write(&entry.payload_type, sizeof(entry.payload_type)) == sizeof(entry.payload_type); + success = success && file.write(&entry.min_hops, sizeof(entry.min_hops)) == sizeof(entry.min_hops); + success = success && file.write(&entry.max_hops, sizeof(entry.max_hops)) == sizeof(entry.max_hops); + } + file.close(); + return success; +} + +bool MyMesh::shouldBlockFloodPacketForward(const mesh::Packet* packet) const { + if (packet == NULL || !packet->isRouteFlood()) return false; + uint8_t type = packet->getPayloadType(); + uint8_t hops = packet->getPathHashCount(); + for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + const auto& entry = flood_packet_filters[i]; + if (entry.active + && (entry.payload_type == FLOOD_PACKET_FILTER_ANY_TYPE || entry.payload_type == type) + && hops >= entry.min_hops && hops <= entry.max_hops) { + MESH_DEBUG_PRINTLN("allowPacketForward: flood.filter matched slot=%d type=%d hops=%d range=%d-%d", + i + 1, type, hops, entry.min_hops, entry.max_hops); + return true; + } + } + return false; +} + +void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len) const { + if (index < 0 || index >= FLOOD_PACKET_FILTER_SLOTS || !flood_packet_filters[index].active) { + snprintf(reply, reply_len, "Err - empty filter slot"); + return; + } + const auto& entry = flood_packet_filters[index]; + char hops[12]; + formatFloodFilterHopSpec(hops, sizeof(hops), entry.min_hops, entry.max_hops); + if (entry.payload_type == FLOOD_PACKET_FILTER_ANY_TYPE) { + snprintf(reply, reply_len, "> %d type=any hops=%s route=flood", index + 1, hops); + } else { + snprintf(reply, reply_len, "> %d type=%s(%u) hops=%s route=flood", index + 1, + floodFilterPayloadTypeName(entry.payload_type), (uint32_t)entry.payload_type, hops); + } +} + +void MyMesh::formatFloodPacketFilters(const char* args, char* reply) const { + const char* selector = skipFloodFilterSpaces(args); + if (*selector == '.') selector = skipFloodFilterSpaces(selector + 1); + if (*selector != 0) { + uint8_t slot; + if (!parseFloodFilterUnsigned(selector, FLOOD_PACKET_FILTER_SLOTS, slot) || slot == 0) { + snprintf(reply, 160, "Err - filter slot must be 1-%d", FLOOD_PACKET_FILTER_SLOTS); + return; + } + formatFloodPacketFilterDetail(slot - 1, reply, 160); + return; + } + + size_t used = (size_t)snprintf(reply, 160, ">"); + int active_count = 0; + bool truncated = false; + for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + const auto& entry = flood_packet_filters[i]; + if (!entry.active) continue; + active_count++; + char hops[12]; + char item[40]; + formatFloodFilterHopSpec(hops, sizeof(hops), entry.min_hops, entry.max_hops); + snprintf(item, sizeof(item), " %d=%s@%s", i + 1, + floodFilterPayloadTypeName(entry.payload_type), hops); + size_t item_len = strlen(item); + if (used + item_len >= 156) { + truncated = true; + break; + } + memcpy(&reply[used], item, item_len + 1); + used += item_len; + } + if (active_count == 0) { + strcpy(reply, "> off"); + } else if (truncated) { + StrHelper::strncpy(&reply[used], " ...", 160 - used); + } +} + +void MyMesh::setFloodPacketFilter(const char* args, char* reply) { + const char* cursor = skipFloodFilterSpaces(args); + int requested_slot = -1; + if (*cursor == '.') { + cursor++; + const char* slot_start = cursor; + while (*cursor >= '0' && *cursor <= '9') cursor++; + size_t slot_len = (size_t)(cursor - slot_start); + char slot_text[8]; + if (slot_len == 0 || slot_len >= sizeof(slot_text)) { + snprintf(reply, 160, "Err - filter slot must be 1-%d", FLOOD_PACKET_FILTER_SLOTS); + return; + } + memcpy(slot_text, slot_start, slot_len); + slot_text[slot_len] = 0; + uint8_t slot; + if (!parseFloodFilterUnsigned(slot_text, FLOOD_PACKET_FILTER_SLOTS, slot) || slot == 0) { + snprintf(reply, 160, "Err - filter slot must be 1-%d", FLOOD_PACKET_FILTER_SLOTS); + return; + } + requested_slot = slot - 1; + if (*cursor != ' ') { + strcpy(reply, "Err - expected packet type and hops"); + return; + } + } + cursor = skipFloodFilterSpaces(cursor); + if (strlen(cursor) >= 64) { + strcpy(reply, "Err - filter parameters too long"); + return; + } + + char params[64]; + strcpy(params, cursor); + char* type_text = params; + char* separator = strchr(type_text, ' '); + if (separator == NULL) { + strcpy(reply, "Err - use: set flood.filter[.n] "); + return; + } + *separator++ = 0; + while (*separator == ' ') separator++; + char* hops_text = separator; + char* extra = strchr(hops_text, ' '); + if (extra != NULL) { + *extra++ = 0; + while (*extra == ' ') extra++; + if (*extra != 0) { + strcpy(reply, "Err - use one packet type and one hop expression"); + return; + } + } + + uint8_t payload_type; + uint8_t min_hops; + uint8_t max_hops; + if (!parseFloodFilterPayloadType(type_text, payload_type)) { + strcpy(reply, "Err - packet type must be name, any, or 0-15"); + return; + } + if (!parseFloodFilterHopSpec(hops_text, min_hops, max_hops)) { + strcpy(reply, "Err - hops must be N, N+, N-M, or all (0-63)"); + return; + } + + int slot = requested_slot; + if (slot < 0) { + for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + const auto& entry = flood_packet_filters[i]; + if (entry.active && entry.payload_type == payload_type + && entry.min_hops == min_hops && entry.max_hops == max_hops) { + slot = i; + break; + } + } + } + if (slot < 0) { + for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + if (!flood_packet_filters[i].active) { + slot = i; + break; + } + } + } + if (slot < 0) { + strcpy(reply, "Err - filter table full"); + return; + } + + FloodPacketFilterEntry previous = flood_packet_filters[slot]; + auto& entry = flood_packet_filters[slot]; + entry.active = true; + entry.payload_type = payload_type; + entry.min_hops = min_hops; + entry.max_hops = max_hops; + if (!saveFloodPacketFilters()) { + entry = previous; + strcpy(reply, "Err - unable to save flood filter"); + return; + } + + char detail[160]; + formatFloodPacketFilterDetail(slot, detail, sizeof(detail)); + snprintf(reply, 160, "OK - %s", detail[0] == '>' ? skipFloodFilterSpaces(detail + 1) : detail); +} + +void MyMesh::deleteFloodPacketFilter(const char* args, char* reply) { + const char* selector = skipFloodFilterSpaces(args); + if (*selector == '.') selector = skipFloodFilterSpaces(selector + 1); + if (floodFilterAsciiEqual(selector, "all")) { + FloodPacketFilterEntry previous[FLOOD_PACKET_FILTER_SLOTS]; + memcpy(previous, flood_packet_filters, sizeof(previous)); + memset(flood_packet_filters, 0, sizeof(flood_packet_filters)); + if (!saveFloodPacketFilters()) { + memcpy(flood_packet_filters, previous, sizeof(flood_packet_filters)); + strcpy(reply, "Err - unable to save flood filter"); + } else { + strcpy(reply, "OK - all flood filters removed"); + } + return; + } + + uint8_t slot; + if (!parseFloodFilterUnsigned(selector, FLOOD_PACKET_FILTER_SLOTS, slot) || slot == 0) { + snprintf(reply, 160, "Err - use: del flood.filter.<1-%d>|all", FLOOD_PACKET_FILTER_SLOTS); + return; + } + int index = slot - 1; + if (!flood_packet_filters[index].active) { + strcpy(reply, "Err - empty filter slot"); + return; + } + FloodPacketFilterEntry previous = flood_packet_filters[index]; + memset(&flood_packet_filters[index], 0, sizeof(flood_packet_filters[index])); + if (!saveFloodPacketFilters()) { + flood_packet_filters[index] = previous; + strcpy(reply, "Err - unable to save flood filter"); + } else { + strcpy(reply, "OK"); + } +} + +static bool parseFloodModerationUnsigned(const char* text, uint32_t maximum, uint32_t& value) { + if (text == NULL || *text == 0) return false; + uint32_t parsed = 0; + for (const char* p = text; *p; p++) { + if (*p < '0' || *p > '9') return false; + uint32_t digit = (uint32_t)(*p - '0'); + if (digit > maximum || parsed > (maximum - digit) / 10U) return false; + parsed = parsed * 10U + digit; + } + value = parsed; + return true; +} + +// Returns 1 for a token, 0 at end of input, and -1 for malformed/oversize input. +static int takeFloodModerationToken(const char*& cursor, char* dest, size_t dest_len) { + cursor = skipFloodFilterSpaces(cursor); + if (*cursor == 0) return 0; + char quote = 0; + if (*cursor == '\'' || *cursor == '"') quote = *cursor++; + const char* start = cursor; + if (quote) { + while (*cursor && *cursor != quote) cursor++; + if (*cursor != quote) return -1; + } else { + while (*cursor && *cursor != ' ') cursor++; + } + size_t len = (size_t)(cursor - start); + if (len >= dest_len) return -1; + memcpy(dest, start, len); + dest[len] = 0; + if (quote) { + cursor++; + if (*cursor != 0 && *cursor != ' ') return -1; + } + return 1; +} + +static bool parseFloodModerationChannel(const char* text, uint8_t secret[PUB_KEY_SIZE], + uint8_t& key_len, uint8_t hash_prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN], + char* name, size_t name_len) { + if (text == NULL || *text == 0) return false; + memset(secret, 0, PUB_KEY_SIZE); + if (floodFilterAsciiEqual(text, "public")) { + key_len = CIPHER_KEY_SIZE; + memcpy(secret, FLOOD_PUBLIC_CHANNEL_SECRET, sizeof(FLOOD_PUBLIC_CHANNEL_SECRET)); + StrHelper::strncpy(name, "public", name_len); + } else if (text[0] == '#' && text[1] != 0) { + key_len = CIPHER_KEY_SIZE; + mesh::Utils::sha256(secret, key_len, (const uint8_t*)text, strlen(text)); + StrHelper::strncpy(name, text, name_len); + } else { + size_t hex_len = strlen(text); + if (hex_len != CIPHER_KEY_SIZE * 2 && hex_len != PUB_KEY_SIZE * 2) return false; + for (size_t i = 0; i < hex_len; i++) { + if (!mesh::Utils::isHexChar(text[i])) return false; + } + key_len = (uint8_t)(hex_len / 2); + if (!mesh::Utils::fromHex(secret, key_len, text)) return false; + mesh::Utils::sha256(hash_prefix, FLOOD_CHANNEL_BLOCK_PREFIX_LEN, secret, key_len); + char prefix_hex[FLOOD_CHANNEL_BLOCK_PREFIX_LEN * 2 + 1]; + mesh::Utils::toHex(prefix_hex, hash_prefix, FLOOD_CHANNEL_BLOCK_PREFIX_LEN); + snprintf(name, name_len, "key:%s", prefix_hex); + } + mesh::Utils::sha256(hash_prefix, FLOOD_CHANNEL_BLOCK_PREFIX_LEN, secret, key_len); + return true; +} + +static bool parseFloodModerationPath(const char* text, uint8_t& hash_size, uint8_t& path_hops, + uint8_t path[FLOOD_GROUP_MODERATION_PATH_BYTES_MAX]) { + memset(path, 0, FLOOD_GROUP_MODERATION_PATH_BYTES_MAX); + if (text == NULL || *text == 0 || strcmp(text, "*") == 0) { + hash_size = 0; + path_hops = 0; + return text != NULL && *text != 0; + } + if (strlen(text) >= 32) return false; + char input[32]; + strcpy(input, text); + char* token = input; + uint8_t parsed_size = 0; + uint8_t count = 0; + while (token != NULL) { + char* comma = strchr(token, ','); + if (comma) *comma = 0; + size_t hex_len = strlen(token); + if (hex_len != 2 && hex_len != 4 && hex_len != 6) return false; + uint8_t token_size = (uint8_t)(hex_len / 2); + if ((parsed_size != 0 && parsed_size != token_size) + || count >= FLOOD_GROUP_MODERATION_PATH_HOPS_MAX) return false; + for (size_t i = 0; i < hex_len; i++) { + if (!mesh::Utils::isHexChar(token[i])) return false; + } + parsed_size = token_size; + if (!mesh::Utils::fromHex(&path[count * parsed_size], parsed_size, token)) return false; + count++; + token = comma ? comma + 1 : NULL; + } + if (count == 0) return false; + hash_size = parsed_size; + path_hops = count; + return true; +} + +static void formatFloodModerationPath(char* dest, size_t dest_len, uint8_t hash_size, uint8_t path_hops, + const uint8_t path[FLOOD_GROUP_MODERATION_PATH_BYTES_MAX]) { + if (hash_size == 0 || path_hops == 0) { + StrHelper::strncpy(dest, "*", dest_len); + return; + } + size_t used = 0; + dest[0] = 0; + for (uint8_t i = 0; i < path_hops; i++) { + char hop[7]; + mesh::Utils::toHex(hop, &path[i * hash_size], hash_size); + int written = snprintf(&dest[used], dest_len - used, "%s%s", i == 0 ? "" : ",", hop); + if (written < 0 || (size_t)written >= dest_len - used) { + dest[dest_len - 1] = 0; + return; + } + used += (size_t)written; + } +} + +static bool floodModerationSenderNameValid(const char* sender) { + if (sender == NULL || *sender == 0 || strlen(sender) >= FLOOD_GROUP_MODERATION_NAME_LEN) return false; + if (strcmp(sender, "*") == 0) return true; + for (const char* p = sender; *p; p++) { + if (*p == ':' || *p == '\r' || *p == '\n' || (uint8_t)*p < 0x20) return false; + } + return true; +} + +static bool floodModerationPathMatches(const mesh::Packet* packet, uint8_t hash_size, uint8_t path_hops, + const uint8_t path[FLOOD_GROUP_MODERATION_PATH_BYTES_MAX]) { + if (path_hops == 0) return true; + if (packet == NULL || packet->getPathHashSize() != hash_size + || packet->getPathHashCount() < path_hops) return false; + return memcmp(packet->path, path, path_hops * hash_size) == 0; +} + +void MyMesh::loadFloodGroupModeration() { + memset(flood_group_moderation, 0, sizeof(flood_group_moderation)); + if (_fs == NULL || !_fs->exists(FLOOD_GROUP_MODERATION_FILE)) return; + File file = openFloodChannelBlockRead(_fs, FLOOD_GROUP_MODERATION_FILE); + if (!file) return; + + FloodGroupModerationEntry loaded[FLOOD_GROUP_MODERATION_SLOTS]; + memset(loaded, 0, sizeof(loaded)); + uint8_t magic[4]; + uint8_t count = 0; + bool success = file.read(magic, sizeof(magic)) == sizeof(magic) + && memcmp(magic, "FGM1", sizeof(magic)) == 0 + && file.read(&count, sizeof(count)) == sizeof(count) + && count <= FLOOD_GROUP_MODERATION_SLOTS; + for (int i = 0; success && i < count; i++) { + auto& entry = loaded[i]; + uint8_t active = 0; + success = file.read(&active, sizeof(active)) == sizeof(active); + success = success && file.read(&entry.key_len, sizeof(entry.key_len)) == sizeof(entry.key_len); + success = success && file.read(entry.secret, sizeof(entry.secret)) == sizeof(entry.secret); + success = success && file.read((uint8_t*)entry.channel_name, sizeof(entry.channel_name)) == sizeof(entry.channel_name); + success = success && file.read((uint8_t*)entry.sender, sizeof(entry.sender)) == sizeof(entry.sender); + success = success && file.read(&entry.path_hash_size, sizeof(entry.path_hash_size)) == sizeof(entry.path_hash_size); + success = success && file.read(&entry.path_hops, sizeof(entry.path_hops)) == sizeof(entry.path_hops); + success = success && file.read(entry.path, sizeof(entry.path)) == sizeof(entry.path); + success = success && file.read(&entry.max_hops, sizeof(entry.max_hops)) == sizeof(entry.max_hops); + success = success && file.read((uint8_t*)&entry.rate_per_minute, sizeof(entry.rate_per_minute)) == sizeof(entry.rate_per_minute); + entry.channel_name[sizeof(entry.channel_name) - 1] = 0; + entry.sender[sizeof(entry.sender) - 1] = 0; + entry.active = active != 0; + if (success && entry.active) { + bool path_valid = (entry.path_hops == 0 && entry.path_hash_size == 0) + || (entry.path_hops >= 1 && entry.path_hops <= FLOOD_GROUP_MODERATION_PATH_HOPS_MAX + && entry.path_hash_size >= 1 && entry.path_hash_size <= 3); + bool hops_valid = entry.max_hops == FLOOD_GROUP_MODERATION_HOPS_ALL + || entry.max_hops <= FLOOD_PACKET_FILTER_MAX_HOPS; + success = (entry.key_len == CIPHER_KEY_SIZE || entry.key_len == PUB_KEY_SIZE) + && entry.channel_name[0] != 0 && floodModerationSenderNameValid(entry.sender) + && path_valid && hops_valid; + if (success) { + mesh::Utils::sha256(entry.hash_prefix, sizeof(entry.hash_prefix), entry.secret, entry.key_len); + } + } + } + file.close(); + // As with the general table, malformed persistence fails open. + if (success) memcpy(flood_group_moderation, loaded, sizeof(flood_group_moderation)); +} + +bool MyMesh::saveFloodGroupModeration() { + if (_fs == NULL) return false; + File file = openFloodChannelBlockWrite(_fs, FLOOD_GROUP_MODERATION_FILE); + if (!file) return false; + const uint8_t magic[4] = {'F', 'G', 'M', '1'}; + uint8_t count = FLOOD_GROUP_MODERATION_SLOTS; + bool success = file.write(magic, sizeof(magic)) == sizeof(magic) + && file.write(&count, sizeof(count)) == sizeof(count); + for (int i = 0; success && i < FLOOD_GROUP_MODERATION_SLOTS; i++) { + const auto& entry = flood_group_moderation[i]; + uint8_t active = entry.active ? 1 : 0; + success = file.write(&active, sizeof(active)) == sizeof(active); + success = success && file.write(&entry.key_len, sizeof(entry.key_len)) == sizeof(entry.key_len); + success = success && file.write(entry.secret, sizeof(entry.secret)) == sizeof(entry.secret); + success = success && file.write((const uint8_t*)entry.channel_name, sizeof(entry.channel_name)) == sizeof(entry.channel_name); + success = success && file.write((const uint8_t*)entry.sender, sizeof(entry.sender)) == sizeof(entry.sender); + success = success && file.write(&entry.path_hash_size, sizeof(entry.path_hash_size)) == sizeof(entry.path_hash_size); + success = success && file.write(&entry.path_hops, sizeof(entry.path_hops)) == sizeof(entry.path_hops); + success = success && file.write(entry.path, sizeof(entry.path)) == sizeof(entry.path); + success = success && file.write(&entry.max_hops, sizeof(entry.max_hops)) == sizeof(entry.max_hops); + success = success && file.write((const uint8_t*)&entry.rate_per_minute, sizeof(entry.rate_per_minute)) == sizeof(entry.rate_per_minute); + } + file.close(); + return success; +} + +bool MyMesh::decodeFloodGroupPlainText(const mesh::Packet* packet, const uint8_t* secret, uint8_t key_len, + uint32_t& timestamp, char* sender, size_t sender_len) const { + if (sender != NULL && sender_len > 0) sender[0] = 0; + if (packet == NULL || secret == NULL || sender == NULL || sender_len < 2 + || !packet->isRouteFlood() || packet->getPayloadType() != PAYLOAD_TYPE_GRP_TXT + || (key_len != CIPHER_KEY_SIZE && key_len != PUB_KEY_SIZE) + || packet->payload_len <= PATH_HASH_SIZE + CIPHER_MAC_SIZE) return false; + + uint8_t channel_hash = 0; + mesh::Utils::sha256(&channel_hash, sizeof(channel_hash), secret, key_len); + if (packet->payload[0] != channel_hash) return false; + + uint8_t data[MAX_PACKET_PAYLOAD]; + int len = mesh::Utils::MACThenDecrypt(secret, data, &packet->payload[PATH_HASH_SIZE], + packet->payload_len - PATH_HASH_SIZE); + if (len <= 5 || (data[4] >> 2) != TXT_TYPE_PLAIN) return false; + + memcpy(×tamp, data, sizeof(timestamp)); + const uint8_t* text = &data[5]; + size_t text_len = (size_t)len - 5; + const uint8_t* colon = (const uint8_t*)memchr(text, ':', text_len); + if (colon == NULL) return false; + + size_t parsed_len = (size_t)(colon - text); + while (parsed_len > 0 && text[parsed_len - 1] == ' ') parsed_len--; + if (parsed_len == 0 || parsed_len >= sender_len) return false; + for (size_t i = 0; i < parsed_len; i++) { + if (text[i] == '\r' || text[i] == '\n' || text[i] < 0x20) return false; + } + memcpy(sender, text, parsed_len); + sender[parsed_len] = 0; + return true; +} + +bool MyMesh::shouldBlockFloodGroupTextForward(const mesh::Packet* packet) { + if (packet == NULL || !packet->isRouteFlood() || packet->getPayloadType() != PAYLOAD_TYPE_GRP_TXT + || packet->payload_len <= PATH_HASH_SIZE + CIPHER_MAC_SIZE) return false; + + uint8_t cached_secret[PUB_KEY_SIZE]; + uint8_t cached_key_len = 0; + bool cache_present = false; + bool cache_valid = false; + char decoded_sender[FLOOD_GROUP_MODERATION_NAME_LEN]; + decoded_sender[0] = 0; + uint8_t hops = packet->getPathHashCount(); + uint8_t rate_slots[FLOOD_GROUP_MODERATION_SLOTS]; + uint8_t rate_slot_count = 0; + uint32_t now = _ms->getMillis(); + + for (int i = 0; i < FLOOD_GROUP_MODERATION_SLOTS; i++) { + auto& entry = flood_group_moderation[i]; + if (!entry.active || packet->payload[0] != entry.hash_prefix[0] + || !floodModerationPathMatches(packet, entry.path_hash_size, entry.path_hops, entry.path)) continue; + + bool same_cached_key = cache_present && cached_key_len == entry.key_len + && memcmp(cached_secret, entry.secret, entry.key_len) == 0; + if (!same_cached_key) { + cache_present = true; + cached_key_len = entry.key_len; + memcpy(cached_secret, entry.secret, entry.key_len); + cache_valid = false; + decoded_sender[0] = 0; + + uint32_t ignored_timestamp = 0; + cache_valid = decodeFloodGroupPlainText(packet, entry.secret, entry.key_len, + ignored_timestamp, decoded_sender, + sizeof(decoded_sender)); + } + if (!cache_valid + || (strcmp(entry.sender, "*") != 0 && !floodFilterAsciiEqual(entry.sender, decoded_sender))) continue; + + bool rule_blocked = entry.max_hops != FLOOD_GROUP_MODERATION_HOPS_ALL && hops >= entry.max_hops; + if (entry.rate_per_minute != FLOOD_GROUP_MODERATION_RATE_UNLIMITED) { + uint16_t effective_count = (!entry.rate_window_active + || now - entry.rate_window_started >= 60000UL) + ? 0 : entry.rate_window_count; + if (entry.rate_per_minute == 0 || effective_count >= entry.rate_per_minute) { + rule_blocked = true; + } + } + if (rule_blocked) { + MESH_DEBUG_PRINTLN("allowPacketForward: flood.moderation slot=%d channel=%s sender=%s hops=%d rate=%d", + i + 1, entry.channel_name, decoded_sender, hops, entry.rate_per_minute); + return true; + } + if (entry.rate_per_minute != FLOOD_GROUP_MODERATION_RATE_UNLIMITED) { + rate_slots[rate_slot_count++] = (uint8_t)i; + } + } + + // All matching deny rules accepted the packet. Only now spend the quota for + // each applicable rate rule, so hop-blocked or otherwise denied packets do + // not reduce the number of messages this repeater may actually forward. + for (uint8_t i = 0; i < rate_slot_count; i++) { + auto& entry = flood_group_moderation[rate_slots[i]]; + if (!entry.rate_window_active || now - entry.rate_window_started >= 60000UL) { + entry.rate_window_active = true; + entry.rate_window_started = now; + entry.rate_window_count = 0; + } + entry.rate_window_count++; + } + return false; +} + +void MyMesh::formatFloodGroupModerationDetail(int index, char* reply, size_t reply_len) const { + if (index < 0 || index >= FLOOD_GROUP_MODERATION_SLOTS || !flood_group_moderation[index].active) { + snprintf(reply, reply_len, "Err - empty moderation slot"); + return; + } + const auto& entry = flood_group_moderation[index]; + char path[32]; + char rate[24]; + char hops[12]; + formatFloodModerationPath(path, sizeof(path), entry.path_hash_size, entry.path_hops, entry.path); + if (entry.rate_per_minute == FLOOD_GROUP_MODERATION_RATE_UNLIMITED) strcpy(rate, "unlimited"); + else snprintf(rate, sizeof(rate), "%u/min", (uint32_t)entry.rate_per_minute); + if (entry.max_hops == FLOOD_GROUP_MODERATION_HOPS_ALL) strcpy(hops, "all"); + else snprintf(hops, sizeof(hops), "%u", (uint32_t)entry.max_hops); + snprintf(reply, reply_len, "> %d channel=%s sender=\"%s\" path=%s rate=%s hops=%s", + index + 1, entry.channel_name, entry.sender, path, rate, hops); +} + +void MyMesh::formatFloodGroupModeration(const char* args, char* reply) const { + const char* selector = skipFloodFilterSpaces(args); + if (*selector == '.') selector = skipFloodFilterSpaces(selector + 1); + if (*selector != 0) { + uint8_t slot; + if (!parseFloodFilterUnsigned(selector, FLOOD_GROUP_MODERATION_SLOTS, slot) || slot == 0) { + snprintf(reply, 160, "Err - moderation slot must be 1-%d", FLOOD_GROUP_MODERATION_SLOTS); + return; + } + formatFloodGroupModerationDetail(slot - 1, reply, 160); + return; + } + + size_t used = (size_t)snprintf(reply, 160, ">"); + int active_count = 0; + bool truncated = false; + for (int i = 0; i < FLOOD_GROUP_MODERATION_SLOTS; i++) { + const auto& entry = flood_group_moderation[i]; + if (!entry.active) continue; + active_count++; + char action[20]; + if (entry.rate_per_minute == 0) strcpy(action, "drop"); + else if (entry.rate_per_minute != FLOOD_GROUP_MODERATION_RATE_UNLIMITED) { + snprintf(action, sizeof(action), "r%u", (uint32_t)entry.rate_per_minute); + } else if (entry.max_hops != FLOOD_GROUP_MODERATION_HOPS_ALL) { + snprintf(action, sizeof(action), "h%u", (uint32_t)entry.max_hops); + } else { + strcpy(action, "off"); + } + char item[72]; + snprintf(item, sizeof(item), " %d=%s/%s@%s", i + 1, entry.channel_name, entry.sender, action); + size_t item_len = strlen(item); + if (used + item_len >= 156) { + truncated = true; + break; + } + memcpy(&reply[used], item, item_len + 1); + used += item_len; + } + if (active_count == 0) strcpy(reply, "> off"); + else if (truncated) StrHelper::strncpy(&reply[used], " ...", 160 - used); +} + +void MyMesh::setFloodGroupModeration(const char* args, char* reply) { + const char* cursor = skipFloodFilterSpaces(args); + int requested_slot = -1; + if (*cursor == '.') { + cursor++; + const char* slot_start = cursor; + while (*cursor >= '0' && *cursor <= '9') cursor++; + size_t slot_len = (size_t)(cursor - slot_start); + char slot_text[8]; + if (slot_len == 0 || slot_len >= sizeof(slot_text)) { + snprintf(reply, 160, "Err - moderation slot must be 1-%d", FLOOD_GROUP_MODERATION_SLOTS); + return; + } + memcpy(slot_text, slot_start, slot_len); + slot_text[slot_len] = 0; + uint8_t slot; + if (!parseFloodFilterUnsigned(slot_text, FLOOD_GROUP_MODERATION_SLOTS, slot) || slot == 0) { + snprintf(reply, 160, "Err - moderation slot must be 1-%d", FLOOD_GROUP_MODERATION_SLOTS); + return; + } + requested_slot = slot - 1; + if (*cursor != ' ') { + strcpy(reply, "Err - expected channel, sender, and action"); + return; + } + } + + char channel_text[80]; + char sender[FLOOD_GROUP_MODERATION_NAME_LEN]; + int token_result = takeFloodModerationToken(cursor, channel_text, sizeof(channel_text)); + if (token_result != 1 || takeFloodModerationToken(cursor, sender, sizeof(sender)) != 1) { + strcpy(reply, "Err - use: set flood.moderation[.n] "); + return; + } + if (!floodModerationSenderNameValid(sender)) { + strcpy(reply, "Err - sender must be 1-31 chars; quote names with spaces"); + return; + } + + uint8_t path_hash_size = 0; + uint8_t path_hops = 0; + uint8_t path[FLOOD_GROUP_MODERATION_PATH_BYTES_MAX]; + memset(path, 0, sizeof(path)); + uint8_t max_hops = FLOOD_GROUP_MODERATION_HOPS_ALL; + uint16_t rate_per_minute = FLOOD_GROUP_MODERATION_RATE_UNLIMITED; + bool action_set = false; + bool rate_set = false; + char option[48]; + while ((token_result = takeFloodModerationToken(cursor, option, sizeof(option))) == 1) { + if (strcmp(option, "drop") == 0) { + if (rate_set) { + strcpy(reply, "Err - use only one rate/drop option"); + return; + } + rate_per_minute = 0; + rate_set = true; + action_set = true; + } else if (strncmp(option, "rate=", 5) == 0) { + if (rate_set) { + strcpy(reply, "Err - use only one rate/drop option"); + return; + } + char rate_text[24]; + StrHelper::strncpy(rate_text, option + 5, sizeof(rate_text)); + char* slash = strchr(rate_text, '/'); + if (slash == NULL || !(strcmp(slash, "/min") == 0 || strcmp(slash, "/m") == 0)) { + strcpy(reply, "Err - rate format is X/min"); + return; + } + *slash = 0; + uint32_t parsed; + if (!parseFloodModerationUnsigned(rate_text, FLOOD_GROUP_MODERATION_RATE_UNLIMITED - 1, parsed)) { + strcpy(reply, "Err - rate must be 0-65534/min"); + return; + } + rate_per_minute = (uint16_t)parsed; + rate_set = true; + action_set = true; + } else if (strncmp(option, "hops=", 5) == 0) { + if (strcmp(option + 5, "all") == 0) { + max_hops = FLOOD_GROUP_MODERATION_HOPS_ALL; + } else { + uint32_t parsed; + if (!parseFloodModerationUnsigned(option + 5, FLOOD_PACKET_FILTER_MAX_HOPS, parsed)) { + strcpy(reply, "Err - hops must be all or 0-63"); + return; + } + max_hops = (uint8_t)parsed; + action_set = true; + } + } else if (strncmp(option, "path=", 5) == 0) { + if (!parseFloodModerationPath(option + 5, path_hash_size, path_hops, path)) { + strcpy(reply, "Err - path is * or 1-3 comma-separated 1/2/3-byte hashes"); + return; + } + } else { + strcpy(reply, "Err - options: drop rate=X/min hops=N|all path=H1[,H2,H3]"); + return; + } + } + if (token_result < 0) { + strcpy(reply, "Err - malformed or overlong moderation option"); + return; + } + if (!action_set) { + strcpy(reply, "Err - set drop, rate=X/min, or hops=N"); + return; + } + if (strcmp(sender, "*") == 0 && rate_per_minute != FLOOD_GROUP_MODERATION_RATE_UNLIMITED) { + strcpy(reply, "Err - rate limiting requires an exact sender name"); + return; + } + + uint8_t secret[PUB_KEY_SIZE]; + uint8_t key_len = 0; + uint8_t hash_prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]; + char channel_name[FLOOD_GROUP_MODERATION_NAME_LEN]; + if (!parseFloodModerationChannel(channel_text, secret, key_len, hash_prefix, + channel_name, sizeof(channel_name))) { + strcpy(reply, "Err - channel must be public, #channel, or 128/256-bit hex key"); + return; + } + + int slot = requested_slot; + if (slot < 0) { + for (int i = 0; i < FLOOD_GROUP_MODERATION_SLOTS; i++) { + const auto& entry = flood_group_moderation[i]; + if (entry.active && entry.key_len == key_len && memcmp(entry.secret, secret, key_len) == 0 + && floodFilterAsciiEqual(entry.sender, sender) + && entry.path_hash_size == path_hash_size && entry.path_hops == path_hops + && memcmp(entry.path, path, sizeof(path)) == 0) { + slot = i; + break; + } + } + } + if (slot < 0) { + for (int i = 0; i < FLOOD_GROUP_MODERATION_SLOTS; i++) { + if (!flood_group_moderation[i].active) { + slot = i; + break; + } + } + } + if (slot < 0) { + strcpy(reply, "Err - moderation table full"); + return; + } + + FloodGroupModerationEntry previous = flood_group_moderation[slot]; + auto& entry = flood_group_moderation[slot]; + memset(&entry, 0, sizeof(entry)); + entry.active = true; + entry.key_len = key_len; + memcpy(entry.hash_prefix, hash_prefix, sizeof(entry.hash_prefix)); + memcpy(entry.secret, secret, sizeof(entry.secret)); + StrHelper::strncpy(entry.channel_name, channel_name, sizeof(entry.channel_name)); + StrHelper::strncpy(entry.sender, sender, sizeof(entry.sender)); + entry.path_hash_size = path_hash_size; + entry.path_hops = path_hops; + memcpy(entry.path, path, sizeof(entry.path)); + entry.max_hops = max_hops; + entry.rate_per_minute = rate_per_minute; + if (!saveFloodGroupModeration()) { + entry = previous; + strcpy(reply, "Err - unable to save moderation rule"); + return; + } + char detail[160]; + formatFloodGroupModerationDetail(slot, detail, sizeof(detail)); + snprintf(reply, 160, "OK - %s", detail[0] == '>' ? skipFloodFilterSpaces(detail + 1) : detail); +} + +void MyMesh::deleteFloodGroupModeration(const char* args, char* reply) { + const char* selector = skipFloodFilterSpaces(args); + if (*selector == '.') selector = skipFloodFilterSpaces(selector + 1); + if (floodFilterAsciiEqual(selector, "all")) { + FloodGroupModerationEntry previous[FLOOD_GROUP_MODERATION_SLOTS]; + memcpy(previous, flood_group_moderation, sizeof(previous)); + memset(flood_group_moderation, 0, sizeof(flood_group_moderation)); + if (!saveFloodGroupModeration()) { + memcpy(flood_group_moderation, previous, sizeof(flood_group_moderation)); + strcpy(reply, "Err - unable to save moderation rules"); + } else { + strcpy(reply, "OK - all moderation rules removed"); + } + return; + } + uint8_t slot; + if (!parseFloodFilterUnsigned(selector, FLOOD_GROUP_MODERATION_SLOTS, slot) || slot == 0) { + snprintf(reply, 160, "Err - use: del flood.moderation.<1-%d>|all", FLOOD_GROUP_MODERATION_SLOTS); + return; + } + int index = slot - 1; + if (!flood_group_moderation[index].active) { + strcpy(reply, "Err - empty moderation slot"); + return; + } + FloodGroupModerationEntry previous = flood_group_moderation[index]; + memset(&flood_group_moderation[index], 0, sizeof(flood_group_moderation[index])); + if (!saveFloodGroupModeration()) { + flood_group_moderation[index] = previous; + strcpy(reply, "Err - unable to save moderation rules"); + } else { + strcpy(reply, "OK"); + } +} + +void MyMesh::loadClockSyncPrefs() { + clock_sync_mesh_enabled = false; + clock_sync_internet_enabled = false; + clock_sync_drift_seconds = CLOCK_SYNC_DRIFT_DEFAULT_SECONDS; + clock_sync_required_samples = CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT; + + if (_fs != NULL && _fs->exists(CLOCK_SYNC_PREFS_FILE)) { + File file = openFloodChannelBlockRead(_fs, CLOCK_SYNC_PREFS_FILE); + if (file) { + uint8_t magic[4]; + uint8_t mesh_enabled = 0; + uint8_t internet_enabled = 0; + uint8_t required_samples = CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT; + uint32_t drift_seconds = 0; + bool valid = file.read(magic, sizeof(magic)) == sizeof(magic); + valid = valid && memcmp(magic, "CTS3", sizeof(magic)) == 0 + && file.read(&mesh_enabled, sizeof(mesh_enabled)) == sizeof(mesh_enabled) + && file.read(&internet_enabled, sizeof(internet_enabled)) == sizeof(internet_enabled) + && file.read((uint8_t*)&drift_seconds, sizeof(drift_seconds)) == sizeof(drift_seconds) + && file.read(&required_samples, sizeof(required_samples)) == sizeof(required_samples); + valid = valid && mesh_enabled <= 1 && internet_enabled <= 1 + && drift_seconds >= CLOCK_SYNC_DRIFT_MIN_SECONDS + && drift_seconds <= CLOCK_SYNC_DRIFT_MAX_SECONDS + && required_samples >= CLOCK_SYNC_REQUIRED_SAMPLES_MIN + && required_samples <= CLOCK_SYNC_REQUIRED_SAMPLES_MAX; + file.close(); + if (valid) { + clock_sync_mesh_enabled = mesh_enabled != 0; + clock_sync_internet_enabled = internet_enabled != 0; + clock_sync_drift_seconds = drift_seconds; + clock_sync_required_samples = required_samples; + } + } + } + resetClockSyncAttempt(); +} + +bool MyMesh::saveClockSyncPrefs() { + if (_fs == NULL) return false; + File file = openFloodChannelBlockWrite(_fs, CLOCK_SYNC_PREFS_FILE); + if (!file) return false; + const uint8_t magic[4] = {'C', 'T', 'S', '3'}; + const uint8_t mesh_enabled = clock_sync_mesh_enabled ? 1 : 0; + const uint8_t internet_enabled = clock_sync_internet_enabled ? 1 : 0; + bool success = file.write(magic, sizeof(magic)) == sizeof(magic) + && file.write(&mesh_enabled, sizeof(mesh_enabled)) == sizeof(mesh_enabled) + && file.write(&internet_enabled, sizeof(internet_enabled)) == sizeof(internet_enabled) + && file.write((const uint8_t*)&clock_sync_drift_seconds, + sizeof(clock_sync_drift_seconds)) == sizeof(clock_sync_drift_seconds) + && file.write(&clock_sync_required_samples, + sizeof(clock_sync_required_samples)) == sizeof(clock_sync_required_samples); + file.close(); + return success; +} + +void MyMesh::resetClockSyncAttempt() { + clock_sync_complete = false; + clock_sync_internet_pending = false; + clock_sync_last_result = CLOCK_SYNC_RESULT_WAITING; + clock_sync_last_source = CLOCK_SYNC_SOURCE_NONE; + clock_sync_last_sample_count = 0; + clock_sync_last_fresh_count = 0; + clock_sync_last_required_count = clock_sync_required_samples; + clock_sync_last_estimate = 0; + clock_sync_last_abs_drift = 0; + clock_sync_internet_requested_millis = 0; + clock_sync_next_attempt_uptime = uptime_millis < CLOCK_SYNC_STARTUP_DELAY_MILLIS + ? CLOCK_SYNC_STARTUP_DELAY_MILLIS : uptime_millis; +} + +static const char* clockSyncMeshSuppressionName(uint8_t source) { + switch (source) { + case CLOCK_SYNC_MESH_SUPPRESS_CLI: return "cli"; + case CLOCK_SYNC_MESH_SUPPRESS_GPS: return "gps"; + case CLOCK_SYNC_MESH_SUPPRESS_INTERNET: return "internet"; + default: return "none"; + } +} + +void MyMesh::suppressMeshClockSyncForBoot(uint8_t source) { + if (source == CLOCK_SYNC_MESH_SUPPRESS_NONE + || clock_sync_mesh_suppressed_by != CLOCK_SYNC_MESH_SUPPRESS_NONE) return; + + clock_sync_mesh_suppressed_by = source; + memset(clock_sync_samples, 0, sizeof(clock_sync_samples)); + MESH_DEBUG_PRINTLN("Clock sync: LoRa estimate suppressed by %s until reboot", + clockSyncMeshSuppressionName(source)); +} + +void MyMesh::onManualClockSet() { + suppressMeshClockSyncForBoot(CLOCK_SYNC_MESH_SUPPRESS_CLI); +} + +void MyMesh::checkGpsClockSyncOverride() { + LocationProvider* location = sensors.getLocationProvider(); + if (location != NULL && location->consumeTimeSyncApplied()) { + suppressMeshClockSyncForBoot(CLOCK_SYNC_MESH_SUPPRESS_GPS); + } +} + +static void deriveClockSyncPathId(const mesh::Packet* packet, + uint8_t path_id[CLOCK_SYNC_PATH_ID_SIZE]) { + uint8_t material[2 + MAX_PATH_SIZE]; + uint8_t count = packet->getPathHashCount(); + uint8_t path_bytes = packet->getPathByteLen(); + // All zero-hop receptions represent the same empty route, regardless of the + // otherwise-unused hash-size bits in path_len. + material[0] = count == 0 ? 0 : packet->getPathHashSize(); + material[1] = count; + if (path_bytes > 0) memcpy(&material[2], packet->path, path_bytes); + mesh::Utils::sha256(path_id, CLOCK_SYNC_PATH_ID_SIZE, material, 2 + path_bytes); +} + +uint32_t MyMesh::estimateClockTransitMillis(const mesh::Packet* packet) const { + if (packet == NULL || _radio == NULL) return 0; + + uint8_t hops = packet->getPathHashCount(); + uint8_t hash_size = packet->getPathHashSize(); + int base_length = packet->getRawLength() - packet->getPathByteLen(); + if (base_length < 2) return 0; + + // The origin sends the packet without a path entry. Each relay then appends + // one entry, waits for its flood jitter, and transmits the longer packet. + uint64_t total = _radio->getEstAirtimeFor(base_length); + const uint64_t maximum = (uint64_t)CLOCK_SYNC_CONSENSUS_WINDOW_SECONDS * 1000ULL; + for (uint8_t relay = 1; relay <= hops; relay++) { + uint32_t airtime = _radio->getEstAirtimeFor(base_length + relay * hash_size); + if (_prefs.tx_delay_factor > 0.0f) { + // Flood forwarding selects uniformly from 0..5*t, where + // t=airtime*tx_delay_factor. Use its midpoint as the best expectation. + float expected_delay = (float)airtime * _prefs.tx_delay_factor * 2.5f; + if (expected_delay > 0.0f) total += (uint32_t)(expected_delay + 0.5f); + } + total += airtime; + if (total >= maximum) return (uint32_t)maximum; + } + return (uint32_t)total; +} + +void MyMesh::recordClockSyncSample(uint8_t source_kind, const uint8_t source_id[4], uint32_t epoch, + const mesh::Packet* packet) { + if (!clock_sync_mesh_enabled || clock_sync_complete + || clock_sync_mesh_suppressed_by != CLOCK_SYNC_MESH_SUPPRESS_NONE || source_id == NULL + || packet == NULL || !packet->isRouteFlood() || !clockSyncEpochIsValid(epoch)) return; + + uint32_t transit_millis = estimateClockTransitMillis(packet); + uint32_t transit_seconds = (transit_millis + 500UL) / 1000UL; + uint32_t maximum = clockSyncMaximumValidEpoch(); + if (epoch > maximum - transit_seconds) return; + epoch += transit_seconds; + + uint8_t path_id[CLOCK_SYNC_PATH_ID_SIZE]; + deriveClockSyncPathId(packet, path_id); + uint32_t now = _ms->getMillis(); + uint32_t received_millis = _radio == NULL ? 0 : _radio->getLastRecvMillis(); + // RadioLib records this immediately after reading the packet. Starting age + // there also accounts for signature/decryption/filter processing before this + // function runs. Fall back for radio backends that do not expose RX time. + if (received_millis == 0 || now - received_millis > 60000UL) received_millis = now; + + int source_slot = -1; + for (int i = 0; i < CLOCK_SYNC_SAMPLE_SLOTS; i++) { + const ClockSyncSample& sample = clock_sync_samples[i]; + if (sample.active && sample.source_kind == source_kind + && memcmp(sample.source_id, source_id, sizeof(sample.source_id)) == 0) { + source_slot = i; + break; + } + } + + if (source_slot >= 0) { + const ClockSyncSample& prior = clock_sync_samples[source_slot]; + // An identical reception must not make old evidence look fresh. + if (prior.epoch == epoch + && memcmp(prior.path_id, path_id, sizeof(prior.path_id)) == 0) return; + } + + // Every fresh vote must arrive through a distinct full received path. This + // also means only one zero-hop vote can be present at a time. + for (int i = 0; i < CLOCK_SYNC_SAMPLE_SLOTS; i++) { + const ClockSyncSample& sample = clock_sync_samples[i]; + if (i == source_slot || !sample.active + || now - sample.received_millis > CLOCK_SYNC_SAMPLE_MAX_AGE_MILLIS) continue; + if (memcmp(sample.path_id, path_id, sizeof(sample.path_id)) == 0) return; + } + + int slot = source_slot; + int reusable = -1; + int oldest = 0; + uint32_t oldest_age = 0; + for (int i = 0; slot < 0 && i < CLOCK_SYNC_SAMPLE_SLOTS; i++) { + const ClockSyncSample& sample = clock_sync_samples[i]; + uint32_t age = sample.active ? now - sample.received_millis : 0; + if ((!sample.active || age > CLOCK_SYNC_SAMPLE_MAX_AGE_MILLIS) && reusable < 0) reusable = i; + if (sample.active && age >= oldest_age) { + oldest_age = age; + oldest = i; + } + } + if (slot < 0) slot = reusable >= 0 ? reusable : oldest; + + ClockSyncSample& sample = clock_sync_samples[slot]; + sample.active = true; + sample.source_kind = source_kind; + memcpy(sample.source_id, source_id, sizeof(sample.source_id)); + memcpy(sample.path_id, path_id, sizeof(sample.path_id)); + sample.epoch = epoch; + sample.received_millis = received_millis; +} + +void MyMesh::recordAcceptedFloodClockSample(const mesh::Packet* packet) { + if (!clock_sync_mesh_enabled || clock_sync_complete + || clock_sync_mesh_suppressed_by != CLOCK_SYNC_MESH_SUPPRESS_NONE || packet == NULL) return; + + if (packet->getPayloadType() == PAYLOAD_TYPE_GRP_TXT) { + recordPublicChannelClockSample(packet); + } else if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT) { + // Mesh::onRecvPacket reaches allowPacketForward() for adverts only after + // verifying the Ed25519 signature over this identity and timestamp. + const size_t minimum = PUB_KEY_SIZE + sizeof(uint32_t) + SIGNATURE_SIZE; + if (packet->payload_len < minimum) return; + uint8_t source_id[4]; + mesh::Utils::sha256(source_id, sizeof(source_id), packet->payload, PUB_KEY_SIZE); + uint32_t timestamp = 0; + memcpy(×tamp, &packet->payload[PUB_KEY_SIZE], sizeof(timestamp)); + recordClockSyncSample(1, source_id, timestamp, packet); + } +} + +void MyMesh::recordPublicChannelClockSample(const mesh::Packet* packet) { + if (!clock_sync_mesh_enabled || clock_sync_complete + || clock_sync_mesh_suppressed_by != CLOCK_SYNC_MESH_SUPPRESS_NONE) return; + uint32_t timestamp = 0; + char sender[FLOOD_GROUP_MODERATION_NAME_LEN]; + if (!decodeFloodGroupPlainText(packet, FLOOD_PUBLIC_CHANNEL_SECRET, CIPHER_KEY_SIZE, + timestamp, sender, sizeof(sender))) return; + + // Treat ASCII case variants of the same display name as one (unverified) source. + for (char* p = sender; *p; p++) { + if (*p >= 'A' && *p <= 'Z') *p = (char)(*p - 'A' + 'a'); + } + uint8_t source_id[4]; + mesh::Utils::sha256(source_id, sizeof(source_id), (const uint8_t*)sender, strlen(sender)); + recordClockSyncSample(2, source_id, timestamp, packet); +} + +bool MyMesh::estimateMeshClock(uint32_t& estimate, uint8_t& fresh_count, + uint8_t& agreeing_count, uint8_t& required_count) const { + uint32_t values[CLOCK_SYNC_SAMPLE_SLOTS]; + uint8_t count = 0; + uint32_t now = _ms->getMillis(); + uint32_t maximum = clockSyncMaximumValidEpoch(); + for (int i = 0; i < CLOCK_SYNC_SAMPLE_SLOTS; i++) { + const ClockSyncSample& sample = clock_sync_samples[i]; + if (!sample.active) continue; + uint32_t age_millis = now - sample.received_millis; + if (age_millis > CLOCK_SYNC_SAMPLE_MAX_AGE_MILLIS) continue; + uint32_t age_seconds = age_millis / 1000UL; + if (sample.epoch > maximum - age_seconds) continue; + values[count++] = sample.epoch + age_seconds; + } + mesh::ClockSyncConsensusResult result = mesh::evaluateClockSyncConsensus( + values, count, clock_sync_required_samples, CLOCK_SYNC_CONSENSUS_WINDOW_SECONDS); + fresh_count = result.fresh_count; + agreeing_count = result.agreeing_count; + required_count = result.required_count; + estimate = result.estimate; + return result.consensus; +} + +static uint32_t rebaseClockTimestamp(uint32_t timestamp, uint32_t old_now, uint32_t new_now) { + if (timestamp == 0) return 0; + uint32_t age = old_now >= timestamp ? old_now - timestamp : 0; + return new_now > age ? new_now - age : 1; +} + +bool MyMesh::applyClockEstimate(uint32_t estimate, uint8_t source, uint8_t sample_count) { + if (!clockSyncEpochIsValid(estimate)) return false; +#ifdef WITH_MQTT_BRIDGE + // Close the normal cross-core race where NTP can finish after checkClockSync() + // selected mesh fallback but before that estimate reaches the RTC. + if (source == CLOCK_SYNC_SOURCE_MESH && mqtt_bridge != NULL && mqtt_bridge->hasNtpTime()) { + suppressMeshClockSyncForBoot(CLOCK_SYNC_MESH_SUPPRESS_INTERNET); + return true; + } +#endif + uint32_t old_now = getRTCClock()->getCurrentTime(); + int64_t delta = (int64_t)estimate - (int64_t)old_now; + uint64_t magnitude = delta < 0 ? (uint64_t)(-delta) : (uint64_t)delta; + + clock_sync_last_source = source; + clock_sync_last_sample_count = sample_count; + clock_sync_last_estimate = estimate; + clock_sync_last_abs_drift = magnitude > UINT32_MAX ? UINT32_MAX : (uint32_t)magnitude; + clock_sync_complete = true; + clock_sync_next_attempt_uptime = 0; + + if (magnitude <= clock_sync_drift_seconds) { + clock_sync_last_result = CLOCK_SYNC_RESULT_WITHIN_DRIFT; + MESH_DEBUG_PRINTLN("Clock sync: within drift (%lu seconds, source=%u)", + (unsigned long)clock_sync_last_abs_drift, (unsigned int)source); + return true; + } + + getRTCClock()->setCurrentTime(estimate); + getRTCClock()->resetUniqueTime(estimate); + clock_sync_last_result = delta > 0 + ? CLOCK_SYNC_RESULT_CORRECTED_FORWARD : CLOCK_SYNC_RESULT_CORRECTED_BACKWARD; + +#if MAX_NEIGHBOURS + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + neighbours[i].heard_timestamp = rebaseClockTimestamp(neighbours[i].heard_timestamp, + old_now, estimate); + } +#endif + for (int i = 0; i < acl.getNumClients(); i++) { + ClientInfo* client = acl.getClientByIdx(i); + client->last_activity = rebaseClockTimestamp(client->last_activity, old_now, estimate); + } + discover_limiter.reset(); + anon_limiter.reset(); + refreshScheduledRadioState(); + + MESH_DEBUG_PRINTLN("Clock sync: corrected %s by %lu seconds (source=%u samples=%u)", + delta > 0 ? "forward" : "backward", + (unsigned long)clock_sync_last_abs_drift, + (unsigned int)source, (unsigned int)sample_count); + return true; +} + +void MyMesh::checkClockSync() { +#ifdef WITH_MQTT_BRIDGE + // MQTT/WiFi builds already set the RTC as soon as an NTP server answers. + // Once that authoritative source succeeds, LoRa remains only a next-boot + // fallback and must not replace internet time later in this boot. + if (mqtt_bridge != NULL && mqtt_bridge->hasNtpTime()) { + suppressMeshClockSyncForBoot(CLOCK_SYNC_MESH_SUPPRESS_INTERNET); + } +#endif + + bool mesh_available = clock_sync_mesh_enabled + && clock_sync_mesh_suppressed_by == CLOCK_SYNC_MESH_SUPPRESS_NONE; + if (clock_sync_complete || (!mesh_available && !clock_sync_internet_enabled) + || uptime_millis < clock_sync_next_attempt_uptime) return; + +#ifdef WITH_MQTT_BRIDGE + if (clock_sync_internet_enabled && mqtt_bridge != NULL && mqtt_bridge->isRunning()) { + if (clock_sync_internet_pending) { + uint32_t estimate = 0; + bool finished = false; + bool success = mqtt_bridge->takeNtpTimeEstimate(estimate, finished); + if (!finished && _ms->getMillis() - clock_sync_internet_requested_millis < 30000UL) return; + clock_sync_internet_pending = false; + if (success && applyClockEstimate(estimate, CLOCK_SYNC_SOURCE_INTERNET, 1)) return; + clock_sync_last_result = CLOCK_SYNC_RESULT_INTERNET_UNAVAILABLE; + } else if (mqtt_bridge->requestNtpTimeEstimate()) { + clock_sync_internet_pending = true; + clock_sync_internet_requested_millis = _ms->getMillis(); + clock_sync_last_result = CLOCK_SYNC_RESULT_INTERNET_PENDING; + return; + } else { + clock_sync_last_result = CLOCK_SYNC_RESULT_INTERNET_UNAVAILABLE; + } + } else if (clock_sync_internet_enabled) { + clock_sync_internet_pending = false; + clock_sync_last_result = CLOCK_SYNC_RESULT_INTERNET_UNAVAILABLE; + } +#else + if (clock_sync_internet_enabled) { + clock_sync_internet_pending = false; + clock_sync_last_result = CLOCK_SYNC_RESULT_INTERNET_UNAVAILABLE; + } +#endif + + if (mesh_available) { + uint32_t estimate = 0; + uint8_t fresh = 0; + uint8_t agreeing = 0; + uint8_t required = clock_sync_required_samples; + bool consensus = estimateMeshClock(estimate, fresh, agreeing, required); + clock_sync_last_fresh_count = fresh; + clock_sync_last_sample_count = agreeing; + clock_sync_last_required_count = required; + if (consensus && applyClockEstimate(estimate, CLOCK_SYNC_SOURCE_MESH, agreeing)) return; + clock_sync_last_result = fresh < clock_sync_required_samples + ? CLOCK_SYNC_RESULT_COLLECTING : CLOCK_SYNC_RESULT_NO_CONSENSUS; + } + clock_sync_next_attempt_uptime = uptime_millis + CLOCK_SYNC_RETRY_INTERVAL_MILLIS; +} + +void MyMesh::formatClockSyncStatus(char* reply, size_t reply_len) const { + if (reply == NULL || reply_len == 0) return; + uint8_t fresh = 0; + uint32_t now = _ms->getMillis(); + for (int i = 0; i < CLOCK_SYNC_SAMPLE_SLOTS; i++) { + if (clock_sync_samples[i].active + && now - clock_sync_samples[i].received_millis <= CLOCK_SYNC_SAMPLE_MAX_AGE_MILLIS) fresh++; + } + bool mesh_available = clock_sync_mesh_enabled + && clock_sync_mesh_suppressed_by == CLOCK_SYNC_MESH_SUPPRESS_NONE; + const char* mesh_state = !clock_sync_mesh_enabled ? "off" + : (mesh_available ? "on" + : (clock_sync_mesh_suppressed_by == CLOCK_SYNC_MESH_SUPPRESS_CLI + ? "suppressed-cli" + : (clock_sync_mesh_suppressed_by == CLOCK_SYNC_MESH_SUPPRESS_GPS + ? "suppressed-gps" : "suppressed-internet"))); + if (!mesh_available && !clock_sync_internet_enabled) { + if (clock_sync_mesh_enabled) { + snprintf(reply, reply_len, "> mesh=%s until reboot, internet=off drift=%lus samples=%u", + mesh_state, (unsigned long)clock_sync_drift_seconds, + (unsigned int)clock_sync_required_samples); + return; + } + snprintf(reply, reply_len, "> off, drift=%lus", (unsigned long)clock_sync_drift_seconds); + return; + } + + const char* result = "waiting"; + switch (clock_sync_last_result) { + case CLOCK_SYNC_RESULT_COLLECTING: result = "collecting"; break; + case CLOCK_SYNC_RESULT_INTERNET_PENDING: result = "internet pending"; break; + case CLOCK_SYNC_RESULT_NO_CONSENSUS: result = "no consensus"; break; + case CLOCK_SYNC_RESULT_INTERNET_UNAVAILABLE: result = "internet unavailable"; break; + case CLOCK_SYNC_RESULT_WITHIN_DRIFT: result = "within drift"; break; + case CLOCK_SYNC_RESULT_CORRECTED_FORWARD: result = "corrected forward"; break; + case CLOCK_SYNC_RESULT_CORRECTED_BACKWARD: result = "corrected backward"; break; + default: break; + } + const char* source = clock_sync_last_source == CLOCK_SYNC_SOURCE_INTERNET ? "internet" + : (clock_sync_last_source == CLOCK_SYNC_SOURCE_MESH ? "mesh" : "none"); + if (clock_sync_complete) { + if (clock_sync_last_source == CLOCK_SYNC_SOURCE_MESH) { + snprintf(reply, reply_len, "> %s %lus via %s epoch=%lu agree=%u/%u paths=%u mesh=%s", + result, (unsigned long)clock_sync_last_abs_drift, source, + (unsigned long)clock_sync_last_estimate, + (unsigned int)clock_sync_last_sample_count, + (unsigned int)clock_sync_last_required_count, + (unsigned int)clock_sync_last_fresh_count, mesh_state); + } else { + snprintf(reply, reply_len, "> %s %lus via %s, epoch=%lu drift=%lus mesh=%s", + result, (unsigned long)clock_sync_last_abs_drift, source, + (unsigned long)clock_sync_last_estimate, + (unsigned long)clock_sync_drift_seconds, mesh_state); + } + } else { + uint64_t remaining_ms = clock_sync_next_attempt_uptime > uptime_millis + ? clock_sync_next_attempt_uptime - uptime_millis : 0; + if (clock_sync_last_result == CLOCK_SYNC_RESULT_NO_CONSENSUS) { + snprintf(reply, reply_len, + "> no consensus, mesh=%s internet=%s agree=%u/%u paths=%u next=%lus", + mesh_state, clock_sync_internet_enabled ? "on" : "off", + (unsigned int)clock_sync_last_sample_count, + (unsigned int)clock_sync_last_required_count, + (unsigned int)clock_sync_last_fresh_count, + (unsigned long)((remaining_ms + 999ULL) / 1000ULL)); + } else { + snprintf(reply, reply_len, "> %s, mesh=%s internet=%s drift=%lus paths=%u/%u next=%lus", + result, mesh_state, + clock_sync_internet_enabled ? "on" : "off", + (unsigned long)clock_sync_drift_seconds, (unsigned int)fresh, + (unsigned int)clock_sync_required_samples, + (unsigned long)((remaining_ms + 999ULL) / 1000ULL)); + } + } +} + static void trimFloodChannelBlockSelector(const char* selector, char* dest, size_t dest_len) { selector = skipLocalSpaces(selector); StrHelper::strncpy(dest, selector == NULL ? "" : selector, dest_len); @@ -4088,23 +5646,60 @@ static void formatPathReply(const uint8_t* path, uint8_t path_len, char* out, si snprintf(out, out_len, "> hs=%u hops=%u hex=%s", (uint32_t)hash_size, (uint32_t)hop_count, hex); } -// Whitelist helper for region manager command perms -static bool isRegionMgrAllowed(const char* cmd) { +static bool commandFamilyMatches(const char* command, const char* family) { + size_t len = strlen(family); + if (strncmp(command, family, len) != 0) return false; + return command[len] == 0 || command[len] == ' ' || command[len] == '.'; +} + +static bool isCommonManagerReadOnlyAllowed(const char* cmd) { while (*cmd == ' ') cmd++; // skip leading spaces - // region commands (read + write region map) - if (memcmp(cmd, "region", 6) == 0) return true; - // read-only getters / status - if (memcmp(cmd, "get ", 4) == 0) return true; - if (memcmp(cmd, "ver", 3) == 0) return true; - if (memcmp(cmd, "board", 5) == 0) return true; - // "neighbors" (plural) is read-only; reject "neighbor.remove" by checking next char - if (memcmp(cmd, "neighbors", 9) == 0) return true; - // bare "clock" is read-only; "clock sync" must be denied - if (memcmp(cmd, "clock", 5) == 0 && memcmp(cmd, "clock sync", 10) != 0) return true; + if (commandFamilyMatches(cmd, "ver") || commandFamilyMatches(cmd, "board") + || commandFamilyMatches(cmd, "neighbors") || strcmp(cmd, "clock") == 0) return true; // sensor reads only - if (memcmp(cmd, "sensor get ", 11) == 0) return true; - if (memcmp(cmd, "sensor list", 11) == 0) return true; - return false; + if (commandFamilyMatches(cmd, "sensor get") || commandFamilyMatches(cmd, "sensor list")) return true; + + // Keep delegated reads explicit. CommonCLI's generic `get` namespace also + // contains credentials (guest.password, WiFi/MQTT secrets, bridge.secret, + // and similar settings), so it must never be granted wholesale. + return commandFamilyMatches(cmd, "get role") + || commandFamilyMatches(cmd, "get public.key") + || commandFamilyMatches(cmd, "get clock.sync") + || commandFamilyMatches(cmd, "get battery.alert") + || commandFamilyMatches(cmd, "get rx.watchdog") + || commandFamilyMatches(cmd, "get recent.repeater") + || commandFamilyMatches(cmd, "get recent.repeaters") + || commandFamilyMatches(cmd, "get outpath") + || commandFamilyMatches(cmd, "get altpath"); +} + +// Whitelist helpers for delegated manager roles. Admins bypass both lists. +static bool isRegionMgrAllowed(const char* cmd) { + while (*cmd == ' ') cmd++; + return commandFamilyMatches(cmd, "region") || isCommonManagerReadOnlyAllowed(cmd); +} + +static bool isFilterMgrAllowed(const char* cmd) { + while (*cmd == ' ') cmd++; + if (isCommonManagerReadOnlyAllowed(cmd)) return true; + if (commandFamilyMatches(cmd, "get repeat") + || commandFamilyMatches(cmd, "get loop.detect") + || commandFamilyMatches(cmd, "get flood.max") + || commandFamilyMatches(cmd, "get flood.channel.data") + || commandFamilyMatches(cmd, "get flood.channel.block") + || commandFamilyMatches(cmd, "get flood.filter") + || commandFamilyMatches(cmd, "get flood.moderation")) return true; + // General payload/hop filters plus the existing keyed-channel and flood-hop gates. + return commandFamilyMatches(cmd, "set flood.filter") + || commandFamilyMatches(cmd, "del flood.filter") + || commandFamilyMatches(cmd, "set flood.moderation") + || commandFamilyMatches(cmd, "del flood.moderation") + || commandFamilyMatches(cmd, "set flood.channel.data") + || commandFamilyMatches(cmd, "set flood.channel.block") + || commandFamilyMatches(cmd, "del flood.channel.block") + || commandFamilyMatches(cmd, "set flood.max") + || commandFamilyMatches(cmd, "set loop.detect") + || commandFamilyMatches(cmd, "set repeat"); } void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *command, char *reply) { @@ -4122,6 +5717,11 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * *--command_end = 0; } + if (region_load_active && sender && !sender->isAdmin() && !sender->isRegionMgr()) { + strcpy(reply, "Err - region load owned by admin/region manager"); + return; + } + if (region_load_active) { if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation region_load_active = false; @@ -4173,17 +5773,128 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * command += 3; } - // Region managers are limited to read-only queries and region commands - // Admins are unrestricted - if (sender && !sender->isAdmin() && sender->isRegionMgr()) { - if (!isRegionMgrAllowed(command)) { + if (sender && !sender->isAdmin()) { + bool allowed = (sender->isRegionMgr() && isRegionMgrAllowed(command)) + || (sender->isFilterMgr() && isFilterMgrAllowed(command)); + if (!allowed) { strcpy(reply, "Err - not permitted"); return; } } // handle ACL related commands - if (memcmp(command, "setperm ", 8) == 0) { // format: setperm {pubkey-hex} {permissions-int8} + if (commandFamilyMatches(command, "get flood.filter")) { + formatFloodPacketFilters(command + strlen("get flood.filter"), reply); + } else if (commandFamilyMatches(command, "set flood.filter")) { + setFloodPacketFilter(command + strlen("set flood.filter"), reply); + } else if (commandFamilyMatches(command, "del flood.filter")) { + deleteFloodPacketFilter(command + strlen("del flood.filter"), reply); + } else if (commandFamilyMatches(command, "get flood.moderation")) { + formatFloodGroupModeration(command + strlen("get flood.moderation"), reply); + } else if (commandFamilyMatches(command, "set flood.moderation")) { + setFloodGroupModeration(command + strlen("set flood.moderation"), reply); + } else if (commandFamilyMatches(command, "del flood.moderation")) { + deleteFloodGroupModeration(command + strlen("del flood.moderation"), reply); + } else if (strcmp(command, "get clock.sync") == 0 + || strcmp(command, "get clock.sync.status") == 0) { + formatClockSyncStatus(reply, 160); + } else if (strcmp(command, "get clock.sync.mesh") == 0) { + if (clock_sync_mesh_enabled + && clock_sync_mesh_suppressed_by != CLOCK_SYNC_MESH_SUPPRESS_NONE) { + sprintf(reply, "> on (suppressed by %s until reboot)", + clockSyncMeshSuppressionName(clock_sync_mesh_suppressed_by)); + } else { + sprintf(reply, "> %s", clock_sync_mesh_enabled ? "on" : "off"); + } + } else if (strcmp(command, "get clock.sync.internet") == 0) { +#ifdef WITH_MQTT_BRIDGE + sprintf(reply, "> %s", clock_sync_internet_enabled ? "on" : "off"); +#else + sprintf(reply, "> %s (unavailable on this build)", clock_sync_internet_enabled ? "on" : "off"); +#endif + } else if (strcmp(command, "get clock.sync.drift") == 0) { + sprintf(reply, "> %lu", (unsigned long)clock_sync_drift_seconds); + } else if (strcmp(command, "get clock.sync.samples") == 0) { + sprintf(reply, "> %u", (unsigned int)clock_sync_required_samples); + } else if (strncmp(command, "set clock.sync.mesh ", 20) == 0) { + const char* value = command + 20; + bool enabled; + if (strcmp(value, "on") == 0) enabled = true; + else if (strcmp(value, "off") == 0) enabled = false; + else { + strcpy(reply, "Err - usage: set clock.sync.mesh "); + return; + } + bool previous = clock_sync_mesh_enabled; + clock_sync_mesh_enabled = enabled; + if (!saveClockSyncPrefs()) { + clock_sync_mesh_enabled = previous; + strcpy(reply, "Err - unable to save clock sync settings"); + } else { + if (enabled && !previous) memset(clock_sync_samples, 0, sizeof(clock_sync_samples)); + resetClockSyncAttempt(); + if (enabled && clock_sync_mesh_suppressed_by != CLOCK_SYNC_MESH_SUPPRESS_NONE) { + sprintf(reply, "OK - enabled; suppressed by %s until reboot", + clockSyncMeshSuppressionName(clock_sync_mesh_suppressed_by)); + } else { + strcpy(reply, enabled ? "OK - mesh clock sync enabled" : "OK - mesh clock sync disabled"); + } + } + } else if (strncmp(command, "set clock.sync.internet ", 24) == 0) { + const char* value = command + 24; + bool enabled; + if (strcmp(value, "on") == 0) enabled = true; + else if (strcmp(value, "off") == 0) enabled = false; + else { + strcpy(reply, "Err - usage: set clock.sync.internet "); + return; + } + bool previous = clock_sync_internet_enabled; + clock_sync_internet_enabled = enabled; + if (!saveClockSyncPrefs()) { + clock_sync_internet_enabled = previous; + strcpy(reply, "Err - unable to save clock sync settings"); + } else { + resetClockSyncAttempt(); +#ifdef WITH_MQTT_BRIDGE + strcpy(reply, enabled ? "OK - internet clock sync enabled" : "OK - internet clock sync disabled"); +#else + strcpy(reply, enabled ? "OK - enabled (internet unavailable on this build)" : "OK - internet clock sync disabled"); +#endif + } + } else if (strncmp(command, "set clock.sync.drift ", 21) == 0) { + uint32_t drift = 0; + if (!parseFloodModerationUnsigned(command + 21, CLOCK_SYNC_DRIFT_MAX_SECONDS, drift) + || drift < CLOCK_SYNC_DRIFT_MIN_SECONDS) { + strcpy(reply, "Err - drift must be 30-86400 seconds"); + } else { + uint32_t previous = clock_sync_drift_seconds; + clock_sync_drift_seconds = drift; + if (!saveClockSyncPrefs()) { + clock_sync_drift_seconds = previous; + strcpy(reply, "Err - unable to save clock sync settings"); + } else { + resetClockSyncAttempt(); + sprintf(reply, "OK - clock drift threshold %lu seconds", (unsigned long)drift); + } + } + } else if (strncmp(command, "set clock.sync.samples ", 23) == 0) { + uint32_t required = 0; + if (!parseFloodModerationUnsigned(command + 23, CLOCK_SYNC_REQUIRED_SAMPLES_MAX, required) + || required < CLOCK_SYNC_REQUIRED_SAMPLES_MIN) { + strcpy(reply, "Err - samples must be 3-16"); + } else { + uint8_t previous = clock_sync_required_samples; + clock_sync_required_samples = (uint8_t)required; + if (!saveClockSyncPrefs()) { + clock_sync_required_samples = previous; + strcpy(reply, "Err - unable to save clock sync settings"); + } else { + resetClockSyncAttempt(); + sprintf(reply, "OK - clock sync requires %u samples", (unsigned int)required); + } + } + } else if (memcmp(command, "setperm ", 8) == 0) { // format: setperm {pubkey-hex} {permissions-int8} char* hex = &command[8]; char* sp = strchr(hex, ' '); // look for separator char if (sp == NULL) { @@ -4439,6 +6150,8 @@ void MyMesh::loop() { uint32_t now = millis(); uptime_millis += now - last_millis; last_millis = now; + checkGpsClockSyncOverride(); + checkClockSync(); #ifdef WITH_MQTT_BRIDGE _alerter.onLoop(now); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index c039936f..0631ffe7 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -98,6 +98,9 @@ struct NeighbourInfo { #ifndef FIRMWARE_BUILD_DATE #define FIRMWARE_BUILD_DATE "6 Jun 2026" #endif +#ifndef FIRMWARE_BUILD_EPOCH + #define FIRMWARE_BUILD_EPOCH 0UL +#endif #ifndef FIRMWARE_VERSION #define FIRMWARE_VERSION "v1.17.0" @@ -113,6 +116,40 @@ struct NeighbourInfo { #define MAX_SCHEDULED_RADIO_SETTINGS (MAX_SCHEDULED_RADIO_SETTINGS_PER_TYPE * 2) +#ifndef FLOOD_PACKET_FILTER_SLOTS + #define FLOOD_PACKET_FILTER_SLOTS 16 +#endif +#define FLOOD_PACKET_FILTER_ANY_TYPE 0xFF +#define FLOOD_PACKET_FILTER_MAX_HOPS 63 + +#ifndef FLOOD_GROUP_MODERATION_SLOTS + #define FLOOD_GROUP_MODERATION_SLOTS 16 +#endif +#define FLOOD_GROUP_MODERATION_NAME_LEN 32 +#define FLOOD_GROUP_MODERATION_PATH_HOPS_MAX 3 +#define FLOOD_GROUP_MODERATION_PATH_BYTES_MAX (FLOOD_GROUP_MODERATION_PATH_HOPS_MAX * 3) +#define FLOOD_GROUP_MODERATION_HOPS_ALL 0xFF +#define FLOOD_GROUP_MODERATION_RATE_UNLIMITED 0xFFFF + +#ifndef CLOCK_SYNC_SAMPLE_SLOTS + #define CLOCK_SYNC_SAMPLE_SLOTS 16 +#endif +#define CLOCK_SYNC_REQUIRED_SAMPLES_MIN 3 +#define CLOCK_SYNC_REQUIRED_SAMPLES_MAX CLOCK_SYNC_SAMPLE_SLOTS +#define CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT 9 +#define CLOCK_SYNC_PATH_ID_SIZE 8 +#define CLOCK_SYNC_STARTUP_DELAY_MILLIS (30ULL * 60ULL * 1000ULL) +#define CLOCK_SYNC_RETRY_INTERVAL_MILLIS (30ULL * 60ULL * 1000ULL) +#define CLOCK_SYNC_SAMPLE_MAX_AGE_MILLIS (2UL * 60UL * 60UL * 1000UL) +#define CLOCK_SYNC_CONSENSUS_WINDOW_SECONDS 600UL +#define CLOCK_SYNC_DRIFT_MIN_SECONDS 30UL +#define CLOCK_SYNC_DRIFT_MAX_SECONDS 86400UL +#define CLOCK_SYNC_DRIFT_DEFAULT_SECONDS 3600UL +#define CLOCK_SYNC_MESH_SUPPRESS_NONE 0 +#define CLOCK_SYNC_MESH_SUPPRESS_CLI 1 +#define CLOCK_SYNC_MESH_SUPPRESS_GPS 2 +#define CLOCK_SYNC_MESH_SUPPRESS_INTERNET 3 + #define RECENT_REPEATER_MAX_AGE_MILLIS (24UL * 60UL * 60UL * 1000UL) #define RECENT_REPEATER_SWEEP_INTERVAL_MILLIS (3UL * 60UL * 60UL * 1000UL) #define RADIO_APPLY_RETRY_INTERVAL_MILLIS 1000UL @@ -176,9 +213,58 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t secret[PUB_KEY_SIZE]; char name[FLOOD_CHANNEL_BLOCK_NAME_LEN]; }; + struct FloodPacketFilterEntry { + bool active; + uint8_t payload_type; + uint8_t min_hops; + uint8_t max_hops; + }; + struct FloodGroupModerationEntry { + bool active; + uint8_t key_len; + uint8_t hash_prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN]; + uint8_t secret[PUB_KEY_SIZE]; + char channel_name[FLOOD_GROUP_MODERATION_NAME_LEN]; + char sender[FLOOD_GROUP_MODERATION_NAME_LEN]; + uint8_t path_hash_size; + uint8_t path_hops; + uint8_t path[FLOOD_GROUP_MODERATION_PATH_BYTES_MAX]; + uint8_t max_hops; + uint16_t rate_per_minute; + uint32_t rate_window_started; + uint16_t rate_window_count; + bool rate_window_active; + }; + struct ClockSyncSample { + bool active; + uint8_t source_kind; + uint8_t source_id[4]; + uint8_t path_id[CLOCK_SYNC_PATH_ID_SIZE]; + uint32_t epoch; + uint32_t received_millis; + }; mutable FloodRetryBridgeState flood_retry_bridge_states[MAX_FLOOD_RETRY_SLOTS]; FloodRetryBridgeReachability flood_retry_bridge_reachability[FLOOD_RETRY_BRIDGE_BUCKETS + 1]; FloodChannelBlockEntry flood_channel_blocks[FLOOD_CHANNEL_BLOCK_SLOTS]; + FloodPacketFilterEntry flood_packet_filters[FLOOD_PACKET_FILTER_SLOTS]; + FloodGroupModerationEntry flood_group_moderation[FLOOD_GROUP_MODERATION_SLOTS]; + ClockSyncSample clock_sync_samples[CLOCK_SYNC_SAMPLE_SLOTS]; + bool clock_sync_mesh_enabled; + bool clock_sync_internet_enabled; + bool clock_sync_complete; + bool clock_sync_internet_pending; + uint8_t clock_sync_mesh_suppressed_by; + uint8_t clock_sync_last_result; + uint8_t clock_sync_last_source; + uint8_t clock_sync_last_sample_count; + uint8_t clock_sync_last_fresh_count; + uint8_t clock_sync_last_required_count; + uint8_t clock_sync_required_samples; + uint32_t clock_sync_drift_seconds; + uint32_t clock_sync_last_estimate; + uint32_t clock_sync_last_abs_drift; + uint32_t clock_sync_internet_requested_millis; + uint64_t clock_sync_next_attempt_uptime; uint32_t pending_discover_tag; unsigned long pending_discover_until; bool region_load_active; @@ -294,6 +380,37 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { bool floodChannelDataHopApplies(const mesh::Packet* packet) const; bool floodChannelBlockMatches(const FloodChannelBlockEntry& entry, const mesh::Packet* packet) const; bool shouldBlockFloodChannelForward(const mesh::Packet* packet) const; + void loadFloodPacketFilters(); + bool saveFloodPacketFilters(); + bool shouldBlockFloodPacketForward(const mesh::Packet* packet) const; + void formatFloodPacketFilters(const char* args, char* reply) const; + void formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len) const; + void setFloodPacketFilter(const char* args, char* reply); + void deleteFloodPacketFilter(const char* args, char* reply); + void loadFloodGroupModeration(); + bool saveFloodGroupModeration(); + bool shouldBlockFloodGroupTextForward(const mesh::Packet* packet); + void formatFloodGroupModeration(const char* args, char* reply) const; + void formatFloodGroupModerationDetail(int index, char* reply, size_t reply_len) const; + void setFloodGroupModeration(const char* args, char* reply); + void deleteFloodGroupModeration(const char* args, char* reply); + bool decodeFloodGroupPlainText(const mesh::Packet* packet, const uint8_t* secret, uint8_t key_len, + uint32_t& timestamp, char* sender, size_t sender_len) const; + void loadClockSyncPrefs(); + bool saveClockSyncPrefs(); + void resetClockSyncAttempt(); + void suppressMeshClockSyncForBoot(uint8_t source); + void checkGpsClockSyncOverride(); + uint32_t estimateClockTransitMillis(const mesh::Packet* packet) const; + void recordClockSyncSample(uint8_t source_kind, const uint8_t source_id[4], uint32_t epoch, + const mesh::Packet* packet); + void recordAcceptedFloodClockSample(const mesh::Packet* packet); + void recordPublicChannelClockSample(const mesh::Packet* packet); + bool estimateMeshClock(uint32_t& estimate, uint8_t& fresh_count, + uint8_t& agreeing_count, uint8_t& required_count) const; + bool applyClockEstimate(uint32_t estimate, uint8_t source, uint8_t sample_count); + void checkClockSync(); + void formatClockSyncStatus(char* reply, size_t reply_len) const; int findFloodChannelBlockBySelector(const char* selector) const; int findFloodChannelBlockSlot(const uint8_t prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN], const char* name) const; void formatFloodChannelBlockDetail(char* reply, int idx) const; @@ -410,6 +527,8 @@ public: _cli.savePrefs(_fs); } + void onManualClockSet() override; + bool sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); // CommonCLICallbacks diff --git a/examples/simple_repeater/RateLimiter.h b/examples/simple_repeater/RateLimiter.h index a6633c0a..36c6cbe3 100644 --- a/examples/simple_repeater/RateLimiter.h +++ b/examples/simple_repeater/RateLimiter.h @@ -10,6 +10,11 @@ class RateLimiter { public: RateLimiter(uint16_t maximum, uint32_t secs): _maximum(maximum), _secs(secs), _start_timestamp(0), _count(0) { } + void reset() { + _start_timestamp = 0; + _count = 0; + } + bool allow(uint32_t now) { if (now < _start_timestamp + _secs) { _count++; @@ -20,4 +25,4 @@ public: } return true; } -}; \ No newline at end of file +}; diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 3c4801a1..8045ddab 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -8,6 +8,12 @@ namespace mesh { +enum class RadioParamApplyResult : uint8_t { + APPLIED, + BUSY, + FAILED +}; + /** * \brief Abstraction of local/volatile clock with Millisecond granularity. */ @@ -89,6 +95,24 @@ public: virtual bool supportsRxPowerSaving() const { return false; } virtual bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) { return !enabled; } + /** + * \brief Attempts to change the radio modulation parameters without waiting. + * + * BUSY means the request is valid but the radio is temporarily unable to + * leave RX/TX. Callers may retry it from their main loop. FAILED means the + * radio attempted or rejected the change and retrying the same tuple is not + * expected to help. + */ + virtual RadioParamApplyResult trySetParams(float freq, float bw, uint8_t sf, uint8_t cr, + const uint32_t* rx_ps_timings = nullptr) { + (void)freq; + (void)bw; + (void)sf; + (void)cr; + (void)rx_ps_timings; + return RadioParamApplyResult::FAILED; + } + /** * \returns true if the radio is currently mid-receive of a packet. */ diff --git a/src/MeshCore.h b/src/MeshCore.h index 63812424..eba1741a 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -140,6 +140,13 @@ public: } return last_unique = t; } + + /** Reset the monotonic timestamp helper after an intentional RTC correction. + * This is needed when a caller explicitly permits moving the wall clock + * backward and wants subsequent generated timestamps to use the new clock. */ + void resetUniqueTime(uint32_t time) { + last_unique = time > 0 ? time - 1 : 0; + } }; } diff --git a/src/helpers/BaseSerialInterface.h b/src/helpers/BaseSerialInterface.h index 8ff110eb..8e058591 100644 --- a/src/helpers/BaseSerialInterface.h +++ b/src/helpers/BaseSerialInterface.h @@ -17,6 +17,10 @@ public: virtual bool isReadBusy() const = 0; virtual bool isWriteBusy() const = 0; + // True while application-level transport work still needs loop service. + // Backends whose busy methods are throttling/high-water signals should + // override this with their actual queue state. + virtual bool hasPendingIO() const { return isReadBusy() || isWriteBusy(); } virtual size_t writeFrame(const uint8_t src[], size_t len) = 0; virtual size_t checkRecvFrame(uint8_t dest[]) = 0; }; diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index a1afe17e..f16d5ecf 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -110,8 +110,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) { ClientInfo* oldest = NULL; for (int i = 0; i < num_clients; i++) { if (id.matches(clients[i].id)) return &clients[i]; // already known - if ( (!clients[i].isAdmin() && !clients[i].isRegionMgr()) - && clients[i].last_activity < min_time) { + if (!clients[i].isProtectedManager() && clients[i].last_activity < min_time) { oldest = &clients[i]; min_time = oldest->last_activity; } diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index e12fe418..5fda9928 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -10,6 +10,7 @@ #define PERM_ACL_READ_WRITE 2 #define PERM_ACL_ADMIN 3 #define PERM_ACL_REGION_MGR 4 +#define PERM_ACL_FILTER_MGR 5 #define OUT_PATH_FORCE_FLOOD 0xFE #define OUT_PATH_UNKNOWN 0xFF @@ -36,6 +37,8 @@ struct ClientInfo { bool isAdmin() const { return (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN; } bool isRegionMgr() const { return (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_REGION_MGR; } + bool isFilterMgr() const { return (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_FILTER_MGR; } + bool isProtectedManager() const { return isAdmin() || isRegionMgr() || isFilterMgr(); } }; #ifndef MAX_CLIENTS diff --git a/src/helpers/ClockSyncUtils.h b/src/helpers/ClockSyncUtils.h new file mode 100644 index 00000000..46fbadde --- /dev/null +++ b/src/helpers/ClockSyncUtils.h @@ -0,0 +1,49 @@ +#pragma once + +#include + +namespace mesh { + +struct ClockSyncConsensusResult { + bool consensus; + uint8_t fresh_count; + uint8_t agreeing_count; + uint8_t required_count; + uint32_t estimate; +}; + +// Evaluate an in-place set of current-time estimates. A configured quorum is +// always required, and when more samples are present the agreeing cluster must +// also be a strict majority. This makes an 8-vs-8 split fail instead of letting +// the upper median choose one side arbitrarily. +inline ClockSyncConsensusResult evaluateClockSyncConsensus(uint32_t* values, + uint8_t count, + uint8_t configured_required, + uint32_t window_seconds) { + ClockSyncConsensusResult result = {false, count, 0, configured_required, 0}; + if (values == nullptr || count < configured_required) return result; + + uint8_t strict_majority = (uint8_t)(count / 2U + 1U); + if (strict_majority > result.required_count) result.required_count = strict_majority; + + for (uint8_t i = 1; i < count; i++) { + uint32_t value = values[i]; + uint8_t j = i; + while (j > 0 && values[j - 1] > value) { + values[j] = values[j - 1]; + j--; + } + values[j] = value; + } + + result.estimate = values[count / 2U]; + for (uint8_t i = 0; i < count; i++) { + uint32_t difference = values[i] > result.estimate + ? values[i] - result.estimate : result.estimate - values[i]; + if (difference <= window_seconds) result.agreeing_count++; + } + result.consensus = result.agreeing_count >= result.required_count; + return result; +} + +} // namespace mesh diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index eff39cfb..fd7d2ae2 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1780,6 +1780,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re uint32_t curr = getRTCClock()->getCurrentTime(); if (sender_timestamp > curr) { getRTCClock()->setCurrentTime(sender_timestamp + 1); + _callbacks->onManualClockSet(); uint32_t now = getRTCClock()->getCurrentTime(); DateTime dt = DateTime(now); sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); @@ -1825,6 +1826,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re uint32_t curr = getRTCClock()->getCurrentTime(); if (secs > curr) { getRTCClock()->setCurrentTime(secs); + _callbacks->onManualClockSet(); uint32_t now = getRTCClock()->getCurrentTime(); DateTime dt = DateTime(now); sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index a91574e3..1a5a6d0b 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -351,6 +351,10 @@ struct LegacyObserverTail { class CommonCLICallbacks { public: virtual void savePrefs() = 0; + // Called only after a CLI command successfully changes the RTC. Roles that + // derive time from untrusted radio traffic can use this to prefer the manual + // value for the remainder of the boot. + virtual void onManualClockSet() { } virtual const char* getFirmwareVer() = 0; virtual const char* getBuildDate() = 0; virtual const char* getRole() = 0; diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index 802ff042..5a9c5166 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -297,8 +297,9 @@ void NRF52Board::sleep(uint32_t secs) { sd_softdevice_is_enabled(&sd_enabled); if (sd_enabled) { - // first call processes pending softdevice events, second call sleeps. - sd_app_evt_wait(); + // A single call is required here. If an interrupt arrived since the last + // wait, SoftDevice returns immediately so the main loop can service the + // flag or BLE queue before sleeping again. sd_app_evt_wait(); } else { // softdevice is disabled, use raw WFE diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index ffe35f54..f125362d 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -413,6 +413,8 @@ MQTTBridge::MQTTBridge(const MQTTNodeInfo& node_info, MQTTPrefs *obs, _ntp_client(_ntp_udp, effectiveNtpPrimary(obs), 0, 60000), _last_ntp_sync(0), _ntp_synced(false), _ntp_sync_pending(false), _slots_setup_done(false), _max_active_slots(RUNTIME_MQTT_SLOTS), _ntp_force_requested(false), _ntp_force_done(false), _ntp_force_result(false), _ntp_diag_requested(false), _ntp_diag_done(false), _ntp_diag_count(0), + _ntp_estimate_requested(false), _ntp_estimate_done(false), + _ntp_estimate_ok(false), _ntp_estimate_epoch(0), // Default to UTC; setRules() will be called from syncTimeWithNTP when a // non-UTC timezone string is configured. Timezone has no default ctor, // so we must pass rules here. @@ -547,6 +549,12 @@ void MQTTBridge::begin() { } _cached_has_connected_slots = false; _slots_setup_done = false; + // A prior bridge session may have ended while a delayed clock estimate was + // queued or being consumed. Never carry that handshake into this session. + _ntp_estimate_requested = false; + _ntp_estimate_done = false; + _ntp_estimate_ok = false; + _ntp_estimate_epoch = 0; // PSRAM diagnostic - helps debug memory fragmentation on boards with external RAM #ifdef BOARD_HAS_PSRAM @@ -838,6 +846,10 @@ void MQTTBridge::end() { _initialized = false; _slots_setup_done = false; // Reset so deferred setup runs again on next begin() + _ntp_estimate_requested = false; + _ntp_estimate_done = false; + _ntp_estimate_ok = false; + _ntp_estimate_epoch = 0; MQTT_DEBUG_PRINTLN("MQTT Bridge stopped"); } @@ -990,6 +1002,17 @@ void MQTTBridge::mqttTaskLoop() { _ntp_diag_done = true; // set last so the waiter sees populated results } + // Delayed clock bootstrap uses a non-blocking request from the radio task. + // Query only; the requester applies its configured drift threshold before + // deciding whether to move the RTC forward or backward. + if (_ntp_estimate_requested) { + runNtpEstimateProbe(); + _ntp_estimate_done = true; + // Keep requested asserted for the entire probe so another caller cannot + // queue work that shares and overwrites the result fields above. + _ntp_estimate_requested = false; + } + // Deferred slot setup: wait until NTP is synced so JWT tokens get valid timestamps. // This avoids wasted TLS handshakes that get rejected due to bad token times. if (_ntp_synced && !_slots_setup_done) { @@ -2188,6 +2211,11 @@ void MQTTBridge::loop() { _ntp_sync_pending = false; syncTimeWithNTP(); } + if (_ntp_estimate_requested) { + runNtpEstimateProbe(); + _ntp_estimate_done = true; + _ntp_estimate_requested = false; + } // Deferred slot setup after NTP sync (non-ESP32 path) if (_ntp_synced && !_slots_setup_done) { @@ -3163,6 +3191,45 @@ void MQTTBridge::runNtpDiagProbe() { r.epoch = ok ? (uint32_t)_ntp_client.getEpochTime() : 0; } _ntp_diag_count = count; + _ntp_client.end(); +} + +void MQTTBridge::runNtpEstimateProbe() { + const char* servers[kMaxNtpServers]; + int count = 0; + fillNtpServerList(_obs, servers, count); + + _ntp_estimate_ok = false; + _ntp_estimate_epoch = 0; + _ntp_client.begin(); + for (int i = 0; i < count && !_ntp_estimate_ok; i++) { + _ntp_client.setPoolServerName(servers[i]); + if (!_ntp_client.forceUpdate()) continue; + uint32_t epoch = (uint32_t)_ntp_client.getEpochTime(); + if (epoch >= 1704067200UL && epoch <= 4102444799UL) { + _ntp_estimate_epoch = epoch; + _ntp_estimate_ok = true; + } + } + _ntp_client.end(); +} + +bool MQTTBridge::requestNtpTimeEstimate() { + if (!isRunning() || WiFi.status() != WL_CONNECTED || _ntp_estimate_requested) return false; + _ntp_estimate_done = false; + _ntp_estimate_ok = false; + _ntp_estimate_epoch = 0; + _ntp_estimate_requested = true; + return true; +} + +bool MQTTBridge::takeNtpTimeEstimate(uint32_t& epoch, bool& finished) { + finished = _ntp_estimate_done; + if (!finished) return false; + bool success = _ntp_estimate_ok; + epoch = success ? _ntp_estimate_epoch : 0; + _ntp_estimate_done = false; // consume the result + return success; } bool MQTTBridge::ntpDiag(char* reply, size_t reply_size, bool verbose) { diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 52b61a3a..2645ae5e 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -199,7 +199,7 @@ private: WiFiUDP _ntp_udp; NTPClient _ntp_client; unsigned long _last_ntp_sync; - bool _ntp_synced; + volatile bool _ntp_synced; bool _ntp_sync_pending; // Flag to trigger NTP sync from loop() instead of event handler bool _slots_setup_done; // Deferred: slots set up after NTP sync // WiFi.onEvent() handler registered once and never removed by end(); the bridge @@ -236,6 +236,12 @@ private: }; NtpDiagResult _ntp_diag_results[kMaxNtpServers]; int _ntp_diag_count; + // Non-blocking, read-only NTP estimate used by the delayed clock-bootstrap + // policy. The MQTT task performs network I/O; the radio/main task only polls. + volatile bool _ntp_estimate_requested; + volatile bool _ntp_estimate_done; + volatile bool _ntp_estimate_ok; + volatile uint32_t _ntp_estimate_epoch; // Timezone handling. // _timezone_storage is inline class storage (zero heap) that is reconfigured @@ -380,6 +386,7 @@ private: bool isAnySlotConnected(); void refreshNTP(); // Lightweight periodic NTP refresh (non-blocking) void runNtpDiagProbe(); // Probe every server for connectivity; never sets the clock. Core 0 only. + void runNtpEstimateProbe(); // Query the first usable server without changing any clock. Core 0 only. // Populates dst_out/std_out with TimeChangeRules for the given IANA or // abbreviation string. Returns false if the string is not recognized // (callers should fall back to UTC). Zero-allocation. @@ -488,6 +495,12 @@ public: * summary in reply; verbose=false fills reply with a compact " ok|fail" list * (for LoRa). Returns false if the bridge is not running. */ bool ntpDiag(char* reply, size_t reply_size, bool verbose); + /** Queue a read-only NTP query on the MQTT task without blocking the radio loop. */ + bool requestNtpTimeEstimate(); + /** Poll/consume the queued estimate. finished=false means it is still pending. */ + bool takeNtpTimeEstimate(uint32_t& epoch, bool& finished); + /** True after this bridge has successfully set the RTC from NTP this boot. */ + bool hasNtpTime() const { return _ntp_synced; } static void formatMqttStatusReply(char* buf, size_t bufsize, const MQTTPrefs* obs); /** True when WiFi is set and at least one MQTT slot can run (preset + custom host if needed). */ static bool isConfigValid(const MQTTPrefs* obs); diff --git a/src/helpers/esp32/ESPNOWRadio.h b/src/helpers/esp32/ESPNOWRadio.h index 758f4c68..28099024 100644 --- a/src/helpers/esp32/ESPNOWRadio.h +++ b/src/helpers/esp32/ESPNOWRadio.h @@ -11,9 +11,18 @@ public: uint32_t getRngSeed(); + mesh::RadioParamApplyResult trySetParams(float freq, float bw, uint8_t sf, uint8_t cr, + const uint32_t* rx_ps_timings = nullptr) override { + (void)freq; + (void)bw; + (void)sf; + (void)cr; + (void)rx_ps_timings; + return mesh::RadioParamApplyResult::APPLIED; + } bool setParams(float freq, float bw, uint8_t sf, uint8_t cr, const uint32_t* rx_ps_timings = nullptr) { - return true; + return trySetParams(freq, bw, sf, cr, rx_ps_timings) == mesh::RadioParamApplyResult::APPLIED; } void powerOff() { /* no-op */ } diff --git a/src/helpers/esp32/SerialBLEInterface.h b/src/helpers/esp32/SerialBLEInterface.h index 19e024b0..a4fa433c 100644 --- a/src/helpers/esp32/SerialBLEInterface.h +++ b/src/helpers/esp32/SerialBLEInterface.h @@ -78,6 +78,7 @@ public: bool isReadBusy() const override; bool isWriteBusy() const override; + bool hasPendingIO() const override { return recv_queue_len > 0 || send_queue_len > 0; } size_t writeFrame(const uint8_t src[], size_t len) override; size_t checkRecvFrame(uint8_t dest[]) override; }; diff --git a/src/helpers/esp32/SerialWifiInterface.h b/src/helpers/esp32/SerialWifiInterface.h index 1ff1d83d..8be7beb2 100644 --- a/src/helpers/esp32/SerialWifiInterface.h +++ b/src/helpers/esp32/SerialWifiInterface.h @@ -54,6 +54,7 @@ public: bool isConnected() const override; bool isReadBusy() const override; bool isWriteBusy() const override; + bool hasPendingIO() const override { return recv_queue_len > 0 || send_queue_len > 0; } size_t writeFrame(const uint8_t src[], size_t len) override; size_t checkRecvFrame(uint8_t dest[]) override; diff --git a/src/helpers/nrf52/SerialBLEInterface.cpp b/src/helpers/nrf52/SerialBLEInterface.cpp index a846e744..cf278341 100644 --- a/src/helpers/nrf52/SerialBLEInterface.cpp +++ b/src/helpers/nrf52/SerialBLEInterface.cpp @@ -408,3 +408,7 @@ bool SerialBLEInterface::isReadBusy() const { bool SerialBLEInterface::isWriteBusy() const { return send_queue_len >= (FRAME_QUEUE_SIZE * 2 / 3); } + +bool SerialBLEInterface::hasPendingIO() const { + return recv_queue_len > 0 || send_queue_len > 0; +} diff --git a/src/helpers/nrf52/SerialBLEInterface.h b/src/helpers/nrf52/SerialBLEInterface.h index d3cc5055..61ee069d 100644 --- a/src/helpers/nrf52/SerialBLEInterface.h +++ b/src/helpers/nrf52/SerialBLEInterface.h @@ -68,6 +68,7 @@ public: bool isConnected() const override; bool isReadBusy() const override; bool isWriteBusy() const override; + bool hasPendingIO() const override; size_t writeFrame(const uint8_t src[], size_t len) override; size_t checkRecvFrame(uint8_t dest[]) override; }; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 4abd5d00..e04a2afa 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -92,12 +92,14 @@ void RadioLibWrapper::endReconfigure(bool resume_rx) { if (resume_rx) startRecv(); } -bool RadioLibWrapper::setParams(float freq, float bw, uint8_t sf, uint8_t cr, - const uint32_t* rx_ps_timings) { - if (rx_ps_timings != NULL && !supportsRxPowerSaving()) return false; +mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, uint8_t sf, uint8_t cr, + const uint32_t* rx_ps_timings) { + if (rx_ps_timings != NULL && !supportsRxPowerSaving()) { + return mesh::RadioParamApplyResult::FAILED; + } uint8_t resume_rx = beginReconfigure(); - if (resume_rx > 1) return false; + if (resume_rx > 1) return mesh::RadioParamApplyResult::BUSY; const bool had_previous_params = _params_valid; const float previous_freq = _cur_freq; @@ -137,7 +139,12 @@ bool RadioLibWrapper::setParams(float freq, float bw, uint8_t sf, uint8_t cr, } endReconfigure(resume_rx); - return success; + return success ? mesh::RadioParamApplyResult::APPLIED : mesh::RadioParamApplyResult::FAILED; +} + +bool RadioLibWrapper::setParams(float freq, float bw, uint8_t sf, uint8_t cr, + const uint32_t* rx_ps_timings) { + return trySetParams(freq, bw, sf, cr, rx_ps_timings) == mesh::RadioParamApplyResult::APPLIED; } bool RadioLibWrapper::setRxBoostedGainMode(bool enabled) { diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 9630e9cc..376d8b42 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -128,6 +128,8 @@ public: // When rx_ps_timings is supplied, update and re-arm the RX duty cycle in the // same standby transition as the modulation change. Values are {rx, sleep}. + mesh::RadioParamApplyResult trySetParams(float freq, float bw, uint8_t sf, uint8_t cr, + const uint32_t* rx_ps_timings = NULL) override; bool setParams(float freq, float bw, uint8_t sf, uint8_t cr, const uint32_t* rx_ps_timings = NULL); uint16_t getDefaultPreambleLength() const override { return preambleLengthForSF(_preamble_sf); } diff --git a/src/helpers/sensors/LocationProvider.h b/src/helpers/sensors/LocationProvider.h index 81d08652..8d79b9db 100644 --- a/src/helpers/sensors/LocationProvider.h +++ b/src/helpers/sensors/LocationProvider.h @@ -6,10 +6,20 @@ class LocationProvider { protected: bool _time_sync_needed = true; + bool _time_sync_applied = false; + + void markTimeSyncApplied() { _time_sync_applied = true; } public: virtual void syncTime() { _time_sync_needed = true; } virtual bool waitingTimeSync() { return _time_sync_needed; } + // Edge-triggered notification for consumers that need to know a GPS time + // was actually written, rather than merely seeing a valid location fix. + bool consumeTimeSyncApplied() { + bool applied = _time_sync_applied; + _time_sync_applied = false; + return applied; + } virtual long getLatitude() = 0; virtual long getLongitude() = 0; virtual long getAltitude() = 0; diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index 6b06259b..cefe4d11 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -150,6 +150,7 @@ public : if (_time_sync_needed && time_valid > 2) { if (_clock != NULL) { _clock->setCurrentTime(getTimestamp()); + markTimeSyncApplied(); _time_sync_needed = false; _last_time_sync = millis(); } diff --git a/test/test_trace_retry/test_trace_retry.cpp b/test/test_trace_retry/test_trace_retry.cpp index 3850d92b..a7455b80 100644 --- a/test/test_trace_retry/test_trace_retry.cpp +++ b/test/test_trace_retry/test_trace_retry.cpp @@ -2,6 +2,7 @@ #include #include +#include #include class TraceTestClock : public mesh::MillisecondClock { @@ -66,6 +67,54 @@ public: } }; +TEST(RTCClock, UniqueSequenceCanFollowAnIntentionalBackwardCorrection) { + TraceTestRTC rtc; + rtc.now = 100; + EXPECT_EQ(100U, rtc.getCurrentTimeUnique()); + EXPECT_EQ(101U, rtc.getCurrentTimeUnique()); + + rtc.setCurrentTime(50); + rtc.resetUniqueTime(50); + EXPECT_EQ(50U, rtc.getCurrentTimeUnique()); +} + +TEST(ClockSyncConsensus, EightVsEightSplitDoesNotChooseTheUpperMedian) { + uint32_t values[16]; + for (int i = 0; i < 8; i++) values[i] = 1000; + for (int i = 8; i < 16; i++) values[i] = 5000; + + mesh::ClockSyncConsensusResult result = + mesh::evaluateClockSyncConsensus(values, 16, 9, 600); + EXPECT_FALSE(result.consensus); + EXPECT_EQ(16, result.fresh_count); + EXPECT_EQ(8, result.agreeing_count); + EXPECT_EQ(9, result.required_count); +} + +TEST(ClockSyncConsensus, NineVsSevenStrictMajorityIsAccepted) { + uint32_t values[16]; + for (int i = 0; i < 7; i++) values[i] = 1000; + for (int i = 7; i < 16; i++) values[i] = 5000; + + mesh::ClockSyncConsensusResult result = + mesh::evaluateClockSyncConsensus(values, 16, 9, 600); + EXPECT_TRUE(result.consensus); + EXPECT_EQ(5000U, result.estimate); + EXPECT_EQ(9, result.agreeing_count); + EXPECT_EQ(9, result.required_count); +} + +TEST(ClockSyncConsensus, ConfiguredEightStillCannotAcceptAnEightVsEightSplit) { + uint32_t values[16]; + for (int i = 0; i < 8; i++) values[i] = 1000; + for (int i = 8; i < 16; i++) values[i] = 5000; + + mesh::ClockSyncConsensusResult result = + mesh::evaluateClockSyncConsensus(values, 16, 8, 600); + EXPECT_FALSE(result.consensus); + EXPECT_EQ(9, result.required_count); +} + static mesh::Packet* makeTrace(TraceTestMesh& node, uint32_t tag, uint32_t auth, const uint8_t* route, uint8_t route_len) { mesh::Packet* packet = node.createTrace(tag, auth, 0);