diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index c9e6741b..808a78e4 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -10,6 +10,20 @@ on: workflow_dispatch: jobs: + management-reports: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + - name: Install real crypto implementations for cross-language tests + run: | + python3 -m pip install platformio pycryptodome==3.23.0 + pio pkg install --global --library 'rweather/Crypto@0.4.0' --storage-dir .pio/libdeps/management-test + - name: Verify protocol, encryption, scheduling and MQTT decoding + run: python3 -B test/test_management_report.py -v + stm32-companion-smoke: runs-on: ubuntu-latest steps: diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 2ae187f6..b6dcb0fe 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -597,6 +597,11 @@ The feature is omitted from flash-constrained STM32 repeater and room images. - `set telemetry.tx ` - `set telemetry.tx schedule ` - `send telemetry.tx now` +- `get data.tx` +- `get data.tx path` +- `set data.tx path ` +- `get data.tx region` +- `set data.tx region ` **Parameters:** @@ -619,12 +624,13 @@ The feature is omitted from flash-constrained STM32 repeater and room images. needed to leave at least 2048 bytes free and replies with the days and pages actually available. For example, a request can return `OK - telemetry.gps days=18 pages=36 requested=30`. -- `direct`: Send the binary temperature and voltage snapshots zero-hop to a - neighboring MQTT observer. +- `direct`: Set the shared `data.tx` path to zero-hop and, in the legacy + combined command, enable the telemetry schedule. - `path`: A comma-separated direct route using the same one-, two-, or three-byte hop hashes accepted by `set outpath`. -- `schedule`: Automatic interval in whole days. The default is `2d`; `off` - retains the configured direct path for manual test sends. +- `schedule`: Automatic interval in whole days. Automatic transmission is off + on a fresh install. The retained cadence defaults to `2d`; `off` retains the + shared route for manual test sends. Local serial and remote administrator CLI sessions can read the history on both roles. Collection uses the MCU temperature, battery voltage, external I2C @@ -645,10 +651,13 @@ Use the browser-based [Telemetry history decoder](telemetry_decoder.md) to turn a reply into a timestamped table or downloadable CSV without uploading the data. -`telemetry.tx` is disabled by default. Its schedule and direct path are stored -across reboots. Configuring `direct` or a routed path enables the default `2d` -schedule; `set telemetry.tx schedule` changes it from one through 30 days or -turns it off. An automatic run waits until 165 half-hour positions are +`telemetry.tx` is disabled by default. Its enabled state and cadence are stored +separately from the shared `data.tx` path and region. `data.tx` defaults to +zero-hop `direct` plus `region=auto`, but it does not enable telemetry or any +other producer. For compatibility, `set telemetry.tx direct|path` updates the +shared path and enables the default `2d` schedule; new configuration should use +`set data.tx path ...` followed by `set telemetry.tx schedule ...`. +An automatic run waits until 165 half-hour positions are available, then sends one maximum-size temperature packet, one maximum-size battery-voltage packet, and up to three `IVB1` packets for every populated I2C voltage channel. GPS is never included in this raw transmission. The history diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index 6c06e4fb..c525aab6 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -78,7 +78,7 @@ set flood.retry.ignore none | Setting | What it does | How to use | Example | | --- | --- | --- | --- | -| `telemetry.temp`, `telemetry.volt`, `telemetry.volt.i2c`, `telemetry.gps` | Repeater and room builds record 30-minute MCU temperature and battery samples for seven days. Detected INA voltage channels retain four days at 0.02 V resolution through 655.34 V; all-zero channels are omitted as disconnected. GPS-capable builds retain GPS separately. Repeater `telemetry.tx` sends temperature, battery, and per-channel I2C RAW_CUSTOM packets over a configured direct path on a persistent 1-30 day schedule (default two days) or immediately for testing; GPS is never included. History resets on reboot. | `get telemetry.temp [page]`, `get telemetry.volt [page]`, `get telemetry.volt.i2c [channel [page]]`, `get telemetry.gps [page]`, `set telemetry.gps <1-30>`, `get telemetry.tx`, `set telemetry.tx `, `set telemetry.tx schedule `, `send telemetry.tx now` | `get telemetry.volt.i2c 2 1` | +| `telemetry.temp`, `telemetry.volt`, `telemetry.volt.i2c`, `telemetry.gps` | Repeater and room builds record 30-minute MCU temperature and battery samples for seven days. Detected INA voltage channels retain four days at 0.02 V resolution through 655.34 V; all-zero channels are omitted as disconnected. GPS-capable builds retain GPS separately. Repeater `telemetry.tx` sends temperature, battery, and per-channel I2C RAW_CUSTOM packets over the shared `data.tx` path on a persistent 1-30 day schedule or immediately for testing; automatic transmission is off by default and its retained cadence starts at two days. `data.tx` defaults to zero-hop direct plus automatic region selection without enabling any producer. GPS is never included. History resets on reboot. | `get/set data.tx path|region`, `get telemetry.temp [page]`, `get telemetry.volt [page]`, `get telemetry.volt.i2c [channel [page]]`, `get telemetry.gps [page]`, `set telemetry.gps <1-30>`, `get telemetry.tx`, `set telemetry.tx schedule `, `send telemetry.tx now` | `get data.tx` | | `battery.alert` | Sends opt-in, region-scoped low-battery warnings to `#repeaters` after 30 minutes of uptime. | `get battery.alert`, `get battery.alert.region`, `set battery.alert on [region]`, `set battery.alert off` | `set battery.alert on sea` | | `battery.alert.low` | Warning threshold percentage. Must be greater than `battery.alert.critical`. | `get battery.alert.low`, `set battery.alert.low <1-100>` | `set battery.alert.low 20` | | `battery.alert.critical` | Critical threshold percentage. Critical and warning alerts use the same 12-hour resend cooldown. | `get battery.alert.critical`, `set battery.alert.critical <0-99>` | `set battery.alert.critical 10` | diff --git a/docs/management_reports.md b/docs/management_reports.md new file mode 100644 index 00000000..5a954094 --- /dev/null +++ b/docs/management_reports.md @@ -0,0 +1,206 @@ +# Management reports (MGR1) + +Available on repeater (including observer), room-server and sensor firmware. +Companions/terminal-chat nodes and KISS modems do not originate these reports. +Off by default. A management password and explicit enable are both required. +No radio settings, existing preferences layout, or OTA authorization policy is +changed by enabling reporting. This protocol does **not** authorize updates. + +## CLI + +Run through the existing local CLI or an authenticated administrator session: + +``` +get data.tx +set data.tx path 1:12ab77 +set data.tx region auto +set mgmt.password <12-to-96-byte password> +set mgmt.direct 5 +set mgmt.flood 21 +set mgmt.enabled on +get mgmt +set mgmt.enabled off +``` + +Use a long randomly generated password. There is no password getter. Firmware +stores the derived 32-byte key, not the plaintext password. That stored key is +equivalent authority to decrypt reports and must also be protected. Passwords +entered into terminal programs may still be recorded by those programs. + +`data.tx` is the single shared route for management reports, telemetry history, +and future scheduled data producers. Its fresh-install defaults are `path=direct` +(zero hops) and `region=auto`; configuring it never enables a producer. Paths +use `1:`, `2:` or `3:` followed by complete hop hashes without separators, or +the comma-separated form accepted by `set outpath`. `none` removes the path. +`get/set mgmt.path` remain compatibility aliases for `get/set data.tx path`. + +`region=auto` uses the radio's configured default region when it is usable; +otherwise it resolves the unique deepest flood-enabled entry in the region +hierarchy. If equally deep candidates make that choice ambiguous, it resolves +to nothing and no fallback is transmitted. `default` requires and follows +`region default`; a region name pins that named scope. `none` disables scoped fallback. The +resolved transport key is looked up when a report starts, so edits to the region +definition take effect without rewriting `/data_tx`. + +The direct path leads to the receiver/uplink's vicinity; this broadcast-radio +datagram has no private destination identity. The radio ID in the payload +identifies the reporter. + +Direct and flood schedules are independently configurable and may each be +turned off. Fresh settings are `direct=5d` and `flood=21d`; global reporting is +still off until `mgmt.enabled on`. Direct accepts 5–90 days and requires the +shared path. Flood accepts 21–90 days and requires a resolvable shared region. +At least one route must remain active while reporting is enabled. The legacy +`set mgmt.interval N` shorthand enables both, setting direct to `N` and flood to +`max(21,N)`. No transmission is sent merely by configuring a password. Initial +reporting waits for the configured schedules. Reports have deterministic +per-radio/per-sequence jitter of up to an hour; pages are spaced at least a +minute apart. + +The radio cannot know that an observer uploaded a packet to MQTT, so the flood +schedule is deliberately independent of direct transmission. A region-scoped +`TRANSPORT_FLOOD` is sent at its independently configured interval. An +unresolved or ambiguous data region blocks that transmission +rather than sending an unscoped flood. If direct and flood become due together, +the flood is sent and replaces the redundant direct copy. +Ordinary reports are never retried in a tight loop; a partial report gives up +after an hour. Existing relay filters, hop limits and duty constraints still apply. + +Schedule state is atomically reserved **before** transmission, and checkpointed +hourly. Timers use elapsed powered-on time rather than the RTC: clock corrections +cannot create floods, and reboot does not clear the budget. Downtime is not +credited; each reboot can delay a report by up to an additional hour. This is a +deliberately conservative tradeoff for nodes with unreliable clocks. Off/on and +password changes do not reset the flood limit. Corrupt/unreadable state or failed +writes stop reporting; `get mgmt` shows `FAULT(no TX)` until storage is repaired +and the radio restarted. `/management` and the shared `/data_tx` are versioned, +CRC-protected, and replaced transactionally. + +Weekly history is collected once a minute while enabled. Hour-bucket extrema +cover 7 days to 7 days + 1 hour (conservative boundary bucket). The first report +after enable/reboot is marked partial where appropriate. Since-report extrema +reset after all pages have been queued, retaining readings taken since the +snapshot for the next report. These statistics are not durable; +reboot loses the history, not the flood countdown. Temperature is MCU temperature, +not ambient. Reporting does not wake GPS, start Wi-Fi, or initialize external OTA +media. Unknown capabilities/readiness are explicitly distinguishable from false. +History/snapshot working memory is allocated only when reporting is enabled and +is bounded to 1.5 KiB, plus a small configuration object and temporary stack use. + +## Routing and MQTT + +Both direct/path and flood reports use **`PAYLOAD_TYPE_GRP_DATA` (`0x06`)**. +The route bits independently select direct or flood. `MGR1` is an application +extension with **literally plaintext public fields**, not a call to the ordinary +encrypted `createGroupDatagram()` builder. A fixed public marker is not an owner +or password-derived channel ID. There is no outer channel encryption. + +The body is padded with zeroes to a group-compatible length `3 + 16*n` (maximum +179 bytes). Existing repeaters in the checked upstream implementation route group +data without requiring a successful channel decryption. This fork recognizes the +management envelope before ordinary channel processing. Reception of a management +packet never exempts it from forwarding policy. Some third-party firmware may +apply additional channel/layout policies; interoperability with every fork is +not guaranteed. Packet logging/uplinks can capture it without the password. + +`RAW_CUSTOM` (`0x0F`) is **not used for reports** because stock upstream does +not flood-route it. + +The observer's existing MQTT `PACKET` JSON supplies the complete frame in `raw`. +No broker configuration or password changes are necessary to capture a report. +The decoder understands all four route forms, 1–3-byte hashes, scope transport +codes, and duplicate copies heard by several uplinks: + +``` +python -m pip install -r tools/management/requirements.txt +python tools/management/report.py --mqtt capture.jsonl +``` + +The password is prompted, not supplied as a process argument. Input may be JSONL +or a JSON array of MQTT messages. Alternatively omit `--mqtt` for a JSON array +of canonical payload hex strings. `--match-admin FULL_PUBLIC_KEY` in canonical +payload mode matches a known administrator against the encrypted fingerprints. +This is an offline capture decoder, not a broker subscriber or downlink service. + +## Canonical payload (little endian) + +| Offset | Bytes | Field | +|---:|---:|---| +| 0 | 4 | `MGR1` | +| 4 | 16 | First 16 bytes of reporter public key | +| 20 | 4 | Persisted report sequence (never wraps; exhaustion stops TX) | +| 24 | 4 | RTC Unix timestamp; advisory, may be wrong | +| 28 | 4 | Firmware major/minor/patch/pre packed as mOTA version | +| 32 | 4 | Bootloader packed version, or unknown | +| 36 | 4 | EndF target ID | +| 40 | 8 | Complete mOTA delta-base body hash | +| 48 | 4 | EndF image length | +| 52 | 4 | Staging capacity; planning still checks actual package geometry | +| 56 | 4 | OTA capability bits | +| 60 | 2 | Uptime hours, saturated at 65535 | +| 62 | 4 | Weekly minimum mV, minimum °C, maximum °C | +| 66 | 4 | Since-report extrema in the same format | +| 70 | 1 | History coverage hours, capped at 168 | +| 71 | 1 | Interval days for this report's direct or flood schedule | +| 72 | 1 | Role: 1 repeater, 2 room server, 3 sensor | +| 73 | 1 | Compiled/detected capability bits | +| 74 | 1 | Active status bits | +| 75 | 1 | Mask of status bits whose state is known | +| 76 | 2 | Validity/partial-history flags | +| 78 | 1 | Zero-based page index | +| 79 | 1 | Total pages (1–6) | +| 80 | 1 | Unique ACL count (0–36) | +| 81 | 1 | First ACL index in this page | +| 82 | 1 | ACL entries on this page (0–6) | +| 83 | 13 × count | AES-SIV encrypted ACL entries | +| after ACL | 16 | Full AES-SIV authentication tag | +| after tag | 0–15 | Zero padding to group-compatible length; not part of canonical payload | + +Each private entry is a per-radio 12-byte keyed fingerprint followed by flags: +bit 0 administrator, bit 1 trusted OTA signer. Duplicate entries combine flags. +An oversized ACL fails closed rather than silently truncating. The reported +allowlist includes all current full administrators and all four possible trusted +OTA signing keys; region/filter managers and ordinary clients are excluded. + +Feature bits: 0 Wi-Fi, 1 GPS, 2 NTP, 3 USB data, 4 LoRa OTA. Wi-Fi active means +connected, GPS means receiver enabled (not necessarily a fix), NTP means an +actual accepted NTP response this boot, USB means observable native USB data +connection (not power or an unobservable external UART bridge). OTA capability +means compiled support; active means an established usable apply/store path. + +Validity bits: 0 firmware version, 1 bootloader version, 2 EndF/base identity, +3 staging capacity, 4 partial week, 5 partial since-report period, 6 MCU temperature. +Voltage is unsigned millivolts, zero missing. Temperatures: zero missing, +1–251 represent −50…200 °C, 252 below range, 253 above range, 254–255 reserved. +OTA bits: 0 protocol compiled, 1 transfer DEFLATE, 2 2-KiB app transfer blocks; +bits 8–23 are apply codec bits (full/sequential/in-place). Transfer DEFLATE is +not compressed bootloader apply. There is **no manifest ID**. Exact old binaries +are still needed on the computer to generate a differential update; a hash alone +cannot reconstruct them. Unknown metadata must not be treated as OTA readiness. + +## Cryptography + +Password root: `SHA256("#" || literal UTF-8 password)` (hashtag-style derivation). +Subkeys: `HMAC-SHA256(root, ASCII_domain || radio_id16)`. +Domains: `MeshCore-MGR1-SIV`, `MeshCore-MGR1-ACL`. +Fingerprint: `HMAC-SHA256(ACL_subkey, radio_id16 || full_admin_key32)[0:12]`. +The same owner has different fingerprints on different radios. A collector +needs a candidate administrator's full key to identify a fingerprint. + +Encryption is [RFC 5297 AES-SIV-CMAC-256](https://www.rfc-editor.org/rfc/rfc5297), +using the existing rweather AES primitive. The single associated-data string is +the complete 83-byte clear header. Ciphertext is exactly the ACL byte length. +Full 16-byte tag, no truncation. This deterministic misuse-resistant mode avoids +reliance on the firmware's noncryptographic general-purpose RNG or RTC nonces. +An identical restored snapshot may repeat ciphertext, but does not expose XORs +of different ACL plaintexts as nonce-reused stream encryption would. + +All public fields are readable without a password but are authenticated only to +password holders. A shared password authenticates knowledge of that password, +**not** a unique individual radio identity; another holder can forge reports. +There is no per-device signature. Collectors must enforce their own persisted +sequence/replay policy and explicitly handle factory resets/restored backups. +The decoder verifies snapshot consistency but does not maintain a database. +This fast password derivation permits offline guessing, so twelve characters +is only a minimum length, not a guarantee of password strength. Packet timing, +public metadata and entry/page counts remain visible. diff --git a/docs/telemetry_decoder.md b/docs/telemetry_decoder.md index 99aef73b..febe30d2 100644 --- a/docs/telemetry_decoder.md +++ b/docs/telemetry_decoder.md @@ -20,19 +20,19 @@ observer that will receive and upload the raw packets. Use `direct` when the observer is a zero-hop neighbor: ```text -set telemetry.tx direct +set data.tx path direct send telemetry.tx now ``` For a routed observer, provide its comma-separated hop hashes instead: ```text -set telemetry.tx A1B2,C3D4 +set data.tx path A1B2,C3D4 send telemetry.tx now ``` -Configuring the route also enables the default two-day schedule. To keep the -route but use it only for manual tests, turn off the schedule before sending: +The shared route does not enable telemetry. Automatic telemetry is off on a +fresh install; to explicitly keep the route manual-only before sending: ```text set telemetry.tx schedule off diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 9e08a5ff..9219a004 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -3493,8 +3493,6 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc #if MESH_ENABLE_TELEMETRY_HISTORY telemetry_history_tx_enabled = false; - memset(telemetry_history_tx_path, 0, sizeof(telemetry_history_tx_path)); - telemetry_history_tx_path_len = OUT_PATH_UNKNOWN; telemetry_history_tx_interval_days = TELEMETRY_HISTORY_TX_DEFAULT_DAYS; telemetry_history_tx_pending = 0; telemetry_history_tx_manual = false; @@ -3641,6 +3639,7 @@ void MyMesh::begin(FILESYSTEM *fs) { _fs = fs; // load persisted prefs _cli.loadPrefs(_fs); + _cli.beginManagement(*this, _fs); #if MESH_ENABLE_TELEMETRY_HISTORY loadTelemetryHistoryTxPrefs(); #endif @@ -11431,10 +11430,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * strcpy(reply, "Err - not permitted"); return; } - if (telemetry_history_tx_path_len == OUT_PATH_UNKNOWN - || telemetry_history_tx_path_len == OUT_PATH_FORCE_FLOOD - || !mesh::Packet::isValidPathLen(telemetry_history_tx_path_len)) { - strcpy(reply, "Err - configure telemetry.tx direct or path first"); + const uint8_t* data_path = NULL; + uint8_t data_path_len = OUT_PATH_UNKNOWN; + if (!_cli.getDataTxPath(data_path, data_path_len)) { + strcpy(reply, "Err - configure data.tx path first"); return; } if (telemetry_history_tx_pending != 0) { @@ -11506,10 +11505,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * strcpy(reply, "Err - use: set telemetry.tx schedule "); return; } - if (telemetry_history_tx_path_len == OUT_PATH_UNKNOWN - || telemetry_history_tx_path_len == OUT_PATH_FORCE_FLOOD - || !mesh::Packet::isValidPathLen(telemetry_history_tx_path_len)) { - strcpy(reply, "Err - configure telemetry.tx direct or path first"); + const uint8_t* data_path = NULL; + uint8_t data_path_len = OUT_PATH_UNKNOWN; + if (!_cli.getDataTxPath(data_path, data_path_len)) { + strcpy(reply, "Err - configure data.tx path first"); return; } enable = true; @@ -11569,7 +11568,6 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * } const bool previous_enabled = telemetry_history_tx_enabled; - const uint8_t previous_path_len = telemetry_history_tx_path_len; const uint8_t previous_pending = telemetry_history_tx_pending; const bool previous_manual = telemetry_history_tx_manual; const uint8_t previous_external_channel = @@ -11578,9 +11576,6 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * telemetry_history_tx_external_chunk; const uint64_t previous_next_tx = telemetry_history_next_tx_uptime; const uint64_t previous_resume_tx = telemetry_history_tx_resume_uptime; - uint8_t previous_path[MAX_PATH_SIZE]; - memcpy(previous_path, telemetry_history_tx_path, sizeof(previous_path)); - if (strcmp(spec, "off") == 0) { telemetry_history_tx_enabled = false; telemetry_history_tx_pending = 0; @@ -11590,42 +11585,24 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * telemetry_history_next_tx_uptime = 0; telemetry_history_tx_resume_uptime = 0; } else { - uint8_t path[MAX_PATH_SIZE]; - uint8_t path_len = OUT_PATH_UNKNOWN; - const char* err = NULL; - if (!parsePathCommand(spec, path, path_len, err) - || path_len == OUT_PATH_UNKNOWN - || path_len == OUT_PATH_FORCE_FLOOD) { - strcpy(reply, err != NULL ? err - : "Err - telemetry.tx needs a direct path"); - return; - } + if (!_cli.setDataTxPath(spec, reply, 160)) return; telemetry_history_tx_enabled = true; - telemetry_history_tx_path_len = path_len; telemetry_history_tx_pending = 0; telemetry_history_tx_manual = false; telemetry_history_tx_external_channel = 0; telemetry_history_tx_external_chunk = 0; telemetry_history_next_tx_uptime = 0; telemetry_history_tx_resume_uptime = 0; - memset(telemetry_history_tx_path, 0, - sizeof(telemetry_history_tx_path)); - if ((path_len & 63U) != 0) { - mesh::Packet::copyPath(telemetry_history_tx_path, path, path_len); - } } if (!saveTelemetryHistoryTxPrefs()) { telemetry_history_tx_enabled = previous_enabled; - telemetry_history_tx_path_len = previous_path_len; telemetry_history_tx_pending = previous_pending; telemetry_history_tx_manual = previous_manual; telemetry_history_tx_external_channel = previous_external_channel; telemetry_history_tx_external_chunk = previous_external_chunk; telemetry_history_next_tx_uptime = previous_next_tx; telemetry_history_tx_resume_uptime = previous_resume_tx; - memcpy(telemetry_history_tx_path, previous_path, - sizeof(telemetry_history_tx_path)); strcpy(reply, "Err - unable to save telemetry.tx"); } else if (telemetry_history_tx_enabled) { snprintf(reply, 160, "OK - telemetry.tx schedule=%ud temp=165 volt=165", @@ -12251,8 +12228,6 @@ uint8_t MyMesh::resizeTelemetryGpsDays(uint8_t requested_days) { void MyMesh::loadTelemetryHistoryTxPrefs() { telemetry_history_tx_enabled = false; - memset(telemetry_history_tx_path, 0, sizeof(telemetry_history_tx_path)); - telemetry_history_tx_path_len = OUT_PATH_UNKNOWN; telemetry_history_tx_interval_days = TELEMETRY_HISTORY_TX_DEFAULT_DAYS; telemetry_history_tx_pending = 0; telemetry_history_tx_manual = false; @@ -12265,23 +12240,29 @@ void MyMesh::loadTelemetryHistoryTxPrefs() { File file = openFloodSettingsRead(_fs, TELEMETRY_HISTORY_TX_PREFS_FILE); if (!file) return; - uint8_t magic[4]; + uint8_t magic[4] = {}; uint8_t enabled = 0; uint8_t path_len = OUT_PATH_UNKNOWN; uint8_t interval_days = TELEMETRY_HISTORY_TX_DEFAULT_DAYS; - uint8_t path[MAX_PATH_SIZE]; - bool valid = file.read(magic, sizeof(magic)) == sizeof(magic) - && memcmp(magic, "THT2", sizeof(magic)) == 0 - && file.read(&enabled, sizeof(enabled)) == sizeof(enabled) - && file.read(&path_len, sizeof(path_len)) == sizeof(path_len) - && file.read(&interval_days, sizeof(interval_days)) - == sizeof(interval_days) - && file.read(path, sizeof(path)) == sizeof(path); + uint8_t path[MAX_PATH_SIZE] = {}; + bool valid = file.read(magic, sizeof(magic)) == sizeof(magic); + const bool legacy = valid && memcmp(magic, "THT2", sizeof(magic)) == 0; + if (legacy) { + valid = file.read(&enabled, sizeof(enabled)) == sizeof(enabled) + && file.read(&path_len, sizeof(path_len)) == sizeof(path_len) + && file.read(&interval_days, sizeof(interval_days)) == sizeof(interval_days) + && file.read(path, sizeof(path)) == sizeof(path); + } else if (valid && memcmp(magic, "THT3", sizeof(magic)) == 0) { + valid = file.read(&enabled, sizeof(enabled)) == sizeof(enabled) + && file.read(&interval_days, sizeof(interval_days)) == sizeof(interval_days); + } else { + valid = false; + } file.close(); valid = valid && enabled <= 1 && interval_days >= 1 && interval_days <= TELEMETRY_HISTORY_TX_MAX_DAYS; - if (enabled != 0) { + if (legacy && enabled != 0) { valid = valid && path_len != OUT_PATH_UNKNOWN && path_len != OUT_PATH_FORCE_FLOOD && mesh::Packet::isValidPathLen(path_len); @@ -12289,9 +12270,11 @@ void MyMesh::loadTelemetryHistoryTxPrefs() { if (!valid) return; telemetry_history_tx_enabled = enabled != 0; - telemetry_history_tx_path_len = path_len; telemetry_history_tx_interval_days = interval_days; - memcpy(telemetry_history_tx_path, path, sizeof(telemetry_history_tx_path)); + if (legacy && path_len != OUT_PATH_UNKNOWN + && mesh::Packet::isValidPathLen(path_len)) { + _cli.adoptLegacyDataTxPath(path, path_len); + } } bool MyMesh::saveTelemetryHistoryTxPrefs() { @@ -12299,19 +12282,13 @@ bool MyMesh::saveTelemetryHistoryTxPrefs() { File file = openFloodSettingsWrite(_fs, TELEMETRY_HISTORY_TX_PREFS_FILE); if (!file) return false; - const uint8_t magic[4] = {'T', 'H', 'T', '2'}; + const uint8_t magic[4] = {'T', 'H', 'T', '3'}; const uint8_t enabled = telemetry_history_tx_enabled ? 1 : 0; bool success = file.write(magic, sizeof(magic)) == sizeof(magic) && file.write(&enabled, sizeof(enabled)) == sizeof(enabled) - && file.write(&telemetry_history_tx_path_len, - sizeof(telemetry_history_tx_path_len)) - == sizeof(telemetry_history_tx_path_len) && file.write(&telemetry_history_tx_interval_days, sizeof(telemetry_history_tx_interval_days)) - == sizeof(telemetry_history_tx_interval_days) - && file.write(telemetry_history_tx_path, - sizeof(telemetry_history_tx_path)) - == sizeof(telemetry_history_tx_path); + == sizeof(telemetry_history_tx_interval_days); file.close(); return success; } @@ -12321,9 +12298,11 @@ void MyMesh::formatTelemetryHistoryTxStatus(char* reply, char source_id[mesh::TelemetryHistory::BINARY_SOURCE_ID_SIZE * 2U + 1U]; mesh::Utils::toHex(source_id, self_id.pub_key, mesh::TelemetryHistory::BINARY_SOURCE_ID_SIZE); + const uint8_t* data_path = NULL; + uint8_t data_path_len = OUT_PATH_UNKNOWN; + const bool have_path = _cli.getDataTxPath(data_path, data_path_len); char path_reply[132]; - formatPathReply(telemetry_history_tx_path, - telemetry_history_tx_path_len, + formatPathReply(data_path, have_path ? data_path_len : OUT_PATH_UNKNOWN, path_reply, sizeof(path_reply)); snprintf(reply, reply_size, "> %s%ud id=%s i2c=%u p=%s", telemetry_history_tx_enabled ? "on " : "off ", @@ -12336,6 +12315,9 @@ void MyMesh::formatTelemetryHistoryTxStatus(char* reply, bool MyMesh::sendTelemetryHistorySnapshot( mesh::TelemetryHistory::Series series) { + const uint8_t* data_path = NULL; + uint8_t data_path_len = OUT_PATH_UNKNOWN; + if (!_cli.getDataTxPath(data_path, data_path_len)) return false; uint8_t payload[mesh::TelemetryHistory::BINARY_PAYLOAD_SIZE]; const size_t payload_len = series == mesh::TelemetryHistory::SERIES_VOLTAGE ? telemetry_history.formatVoltageBinarySnapshot( @@ -12346,12 +12328,14 @@ bool MyMesh::sendTelemetryHistorySnapshot( mesh::Packet* packet = createRawData(payload, payload_len); if (packet == NULL) return false; - return sendDirect(packet, telemetry_history_tx_path, - telemetry_history_tx_path_len); + return sendDirect(packet, data_path, data_path_len); } bool MyMesh::sendExternalVoltageHistorySnapshot(uint8_t channel_index, uint8_t chunk_index) { + const uint8_t* data_path = NULL; + uint8_t data_path_len = OUT_PATH_UNKNOWN; + if (!_cli.getDataTxPath(data_path, data_path_len)) return false; const uint8_t channel = external_voltage_history.populatedChannelAt(channel_index); if (channel == 0) return false; @@ -12365,8 +12349,7 @@ bool MyMesh::sendExternalVoltageHistorySnapshot(uint8_t channel_index, mesh::Packet* packet = createRawData(payload, payload_len); if (packet == NULL) return false; - return sendDirect(packet, telemetry_history_tx_path, - telemetry_history_tx_path_len); + return sendDirect(packet, data_path, data_path_len); } void MyMesh::serviceTelemetryHistoryTx() { @@ -12383,6 +12366,9 @@ void MyMesh::serviceTelemetryHistoryTx() { < mesh::TelemetryHistory::BINARY_MAX_SAMPLES) { return; } + const uint8_t* data_path = NULL; + uint8_t data_path_len = OUT_PATH_UNKNOWN; + if (!_cli.getDataTxPath(data_path, data_path_len)) return; telemetry_history_tx_manual = false; telemetry_history_tx_resume_uptime = 0; telemetry_history_tx_pending = TELEMETRY_HISTORY_TX_TEMPERATURE diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index a3a8d79e..be0029b3 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -536,8 +536,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks mesh::TelemetryHistory telemetry_history; mesh::ExternalVoltageHistory external_voltage_history; bool telemetry_history_tx_enabled; - uint8_t telemetry_history_tx_path[MAX_PATH_SIZE]; - uint8_t telemetry_history_tx_path_len; uint8_t telemetry_history_tx_interval_days; uint8_t telemetry_history_tx_pending; bool telemetry_history_tx_manual; @@ -978,6 +976,13 @@ public: const char* getFirmwareVer() override { return FIRMWARE_VERSION; } const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; } const char* getRole() override { return FIRMWARE_ROLE; } + bool managementNtpSynced() const override { +#ifdef WITH_MQTT_BRIDGE + return mqtt_bridge && mqtt_bridge->hasFreshNtpThisBoot(); +#else + return false; +#endif + } const char* getNodeName() { return _prefs.node_name; } NodePrefs* getNodePrefs() { return &_prefs; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 9fa43f9c..0040cc5f 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1350,6 +1350,7 @@ void MyMesh::begin(FILESYSTEM *fs) { _fs = fs; // load persisted prefs _cli.loadPrefs(_fs); + _cli.beginManagement(*this, _fs); acl.load(_fs, self_id); region_map.load(_fs); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index a36dcf30..837ba17e 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -414,6 +414,7 @@ public: const char* getFirmwareVer() override { return FIRMWARE_VERSION; } const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; } const char* getRole() override { return FIRMWARE_ROLE; } + bool managementNtpSynced() const override { return hasAuthoritativeClock(); } const char* getNodeName() { return _prefs.node_name; } NodePrefs* getNodePrefs() { return &_prefs; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index addfe490..8297fa4b 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -1016,6 +1016,7 @@ void SensorMesh::begin(FILESYSTEM* fs) { _fs = fs; // load persisted prefs _cli.loadPrefs(_fs); + _cli.beginManagement(*this, _fs); acl.load(_fs, self_id); region_map.load(_fs); diff --git a/src/Mesh.cpp b/src/Mesh.cpp index d5abf537..be7ed9e2 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -1,4 +1,6 @@ #include "Mesh.h" +#include +#include #include "helpers/ota/OtaFormat.h" // request types used by TempRadio relay-pressure tracking //#include #if defined(ENABLE_OTA) @@ -1146,6 +1148,18 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { break; } + // MGR1 is a plaintext management extension, not an encrypted channel + // datagram. Keep the legacy group payload length shape for opaque relay + // compatibility. Recognize it before any channel decryption/delivery. + if (pkt->getPayloadType() == PAYLOAD_TYPE_GRP_DATA && + management::validPage(pkt->payload, pkt->payload_len, true)) { + if (!_tables->wasSeen(pkt)) { + _tables->markSeen(pkt); + action = routeRecvPacket(pkt); + } + break; + } + int i = 0; uint8_t channel_hash = pkt->payload[i++]; @@ -2964,6 +2978,42 @@ bool Mesh::sendOtaFlood(Packet* packet, uint32_t delay_millis) { } #endif +bool Mesh::sendManagementData(Packet* packet, bool flood, const uint8_t* path, + uint8_t path_len, uint8_t flood_hash_size, + const uint8_t* scope_key) { + if (!packet) return false; + bool scope_valid = false; + if (scope_key) { + for (unsigned i = 0; i < 16; ++i) scope_valid |= scope_key[i] != 0; + } + if (isAnyTempRadioActive() || packet->getPayloadType() != PAYLOAD_TYPE_RAW_CUSTOM || + !management::validPage(packet->payload, packet->payload_len) || + (flood && !scope_valid) || + (!flood && (!Packet::isValidPathLen(path_len) || ((path_len & 63) && !path)))) { + releasePacket(packet); return false; + } + packet->header = (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT) + | (flood ? ROUTE_TYPE_TRANSPORT_FLOOD : ROUTE_TYPE_DIRECT); + const size_t padded = management::floodSize(packet->payload_len); + memset(packet->payload + packet->payload_len, 0, padded - packet->payload_len); + packet->payload_len = padded; + if (flood) { + SHA256 sha; + sha.resetHMAC(scope_key, 16); + const uint8_t type = packet->getPayloadType(); + sha.update(&type, 1); sha.update(packet->payload, packet->payload_len); + sha.finalizeHMAC(scope_key, 16, + reinterpret_cast(&packet->transport_codes[0]), 2); + if (packet->transport_codes[0] == 0) ++packet->transport_codes[0]; + else if (packet->transport_codes[0] == 0xffff) --packet->transport_codes[0]; + packet->transport_codes[1] = 0; + packet->setPathHashSizeAndCount(flood_hash_size >= 1 && flood_hash_size <= 3 ? flood_hash_size : 1, 0); + } + else packet->path_len = Packet::copyPath(packet->path, path, path_len); + _tables->markSent(packet); + return sendPacket(packet, 3, 1000); +} + bool Mesh::sendFlood(Packet* packet, uint32_t delay_millis, uint8_t path_hash_size) { if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) { MESH_DEBUG_PRINTLN("%s Mesh::sendFlood(): TRACE type not suspported", getLogDateTime()); diff --git a/src/Mesh.h b/src/Mesh.h index 4b0a26d2..c302d131 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -607,6 +607,11 @@ public: Packet* createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len); Packet* createPathReturn(const Identity& dest, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len); Packet* createRawData(const uint8_t* data, size_t len); + // Background management traffic, without direct/flood automatic retries. + // Takes ownership on success AND failure, like sendDirect/sendFlood. + bool sendManagementData(Packet* packet, bool flood, const uint8_t* path, + uint8_t path_len, uint8_t flood_hash_size = 1, + const uint8_t* scope_key = nullptr); #if defined(ENABLE_OTA) // Build a PAYLOAD_TYPE_OTA packet from raw OTA message bytes (route set by sendOtaFlood). diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 962fc8cf..c76a6a05 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -149,6 +149,7 @@ static bool isGpioConfig(const char* config) { void CommonCLI::loop() { _radio_profiles.loop(); + loopManagement(); #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) _user_gpio.loop(); UserGpio::Completion completion; @@ -2559,6 +2560,7 @@ uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) { void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) { PrefsSaveReplyGuard save_reply(_prefs_save_failures, reply); mesh::cli::normalizeCommandVerb(command); + if (handleManagementCommand(command, reply)) return; if (mesh::wireless::control().handle(command, reply, 160, millis(), _callbacks->wirelessCommandSource(sender_timestamp))) return; if (_radio_profiles.handle(command, reply, 160, sender_timestamp != 0)) return; diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 64cbbe1e..a2947f74 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -453,6 +453,8 @@ struct LegacyObserverTail { class CommonCLICallbacks { public: + // True only after an actual NTP response this boot, not a plausible RTC. + virtual bool managementNtpSynced() const { return false; } virtual mesh::Radio* getProfileRadio() { return nullptr; } virtual mesh::FloodAdvertLimiter* getFloodAdvertLimiter() { return nullptr; } // Ordinary CommonCLI setters mutate NodePrefs and therefore save only the @@ -729,7 +731,19 @@ class LegacyUpgradeGate; } #endif +namespace mesh { +class ManagementReporter; +struct DataRouteState; +} class CommonCLI { + mesh::ManagementReporter* _management = nullptr; + mesh::DataRouteState* _data_route = nullptr; + mesh::Mesh* _management_mesh = nullptr; + FILESYSTEM* _management_fs = nullptr; + uint64_t _management_uptime_ms = 0; + uint32_t _management_last_ms = 0; + bool handleManagementCommand(char* command, char* reply); + bool handleDataTxCommand(char* command, char* reply); mesh::RTCClock* _rtc; NodePrefs* _prefs; CommonCLICallbacks* _callbacks; @@ -802,6 +816,17 @@ class CommonCLI { bool handleObserverCommand(uint32_t sender_timestamp, char* command, char* reply); public: + void beginManagement(mesh::Mesh& mesh, FILESYSTEM* fs); + void loopManagement(); + // Shared delivery configuration for scheduled/background data producers. + // Fresh installs use a zero-hop direct path and automatic region scope; + // producers retain independent disabled-by-default schedules. + bool getDataTxPath(const uint8_t*& path, uint8_t& path_len) const; + bool resolveDataTxScope(TransportKey& scope, char* resolved_name = nullptr, + size_t resolved_name_size = 0, + bool* ambiguous = nullptr) const; + bool adoptLegacyDataTxPath(const uint8_t* path, uint8_t path_len); + bool setDataTxPath(const char* spec, char* reply, size_t reply_size); bool saveCommonPrefs(); bool savePrimaryRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t preamble); diff --git a/src/helpers/CommonCLI_Management.cpp b/src/helpers/CommonCLI_Management.cpp new file mode 100644 index 00000000..f845b5d6 --- /dev/null +++ b/src/helpers/CommonCLI_Management.cpp @@ -0,0 +1,389 @@ +#include "CommonCLI.h" +#include "ManagementReporter.h" +#include "FileRead.h" +#include "PersistentStoreFormat.h" +#include +#include +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#include "AtomicFileWriter.h" +#else +#include "ContactFileTransaction.h" +#endif + +namespace mesh { +static constexpr char DATA_ROUTE_FILE[] = "/data_tx"; +static constexpr size_t DATA_ROUTE_SIZE = 108; +enum DataRegionMode : uint8_t { + DATA_REGION_AUTO = 0, + DATA_REGION_DEFAULT = 1, + DATA_REGION_NAMED = 2, + DATA_REGION_NONE = 3, +}; + +struct DataRouteState { + FILESYSTEM* fs = nullptr; + RegionMap* regions = nullptr; + uint8_t path[MAX_PATH_SIZE] = {}; + uint8_t path_len = 0; // safe fresh-install default: zero-hop direct + uint8_t region_mode = DATA_REGION_AUTO; + char region[31] = {}; + bool persisted = false; + bool healthy = true; +}; + +static uint32_t readRoute32(const uint8_t* p) { + return uint32_t(p[0]) | (uint32_t(p[1]) << 8) + | (uint32_t(p[2]) << 16) | (uint32_t(p[3]) << 24); +} + +static void writeRoute32(uint8_t* p, uint32_t value) { + p[0] = uint8_t(value); p[1] = uint8_t(value >> 8); + p[2] = uint8_t(value >> 16); p[3] = uint8_t(value >> 24); +} + +static uint8_t nibble(char c) { + if (c >= '0' && c <= '9') return uint8_t(c - '0'); + if (c >= 'a' && c <= 'f') return uint8_t(c - 'a' + 10); + if (c >= 'A' && c <= 'F') return uint8_t(c - 'A' + 10); + return 0xff; +} + +static char* trimDataRoute(char* text) { + while (*text == ' ') ++text; + char* end = text + strlen(text); + while (end > text && end[-1] == ' ') --end; + *end = 0; + return text; +} + +static bool parseDataPath(char* raw, uint8_t path[MAX_PATH_SIZE], + uint8_t& path_len, const char*& error) { + char* spec = trimDataRoute(raw); + memset(path, 0, MAX_PATH_SIZE); + if (!strcmp(spec, "direct")) { path_len = 0; return true; } + if (!strcmp(spec, "none") || !strcmp(spec, "clear") || !strcmp(spec, "-")) { + path_len = OUT_PATH_UNKNOWN; return true; + } + + // Accept the compact management form (2:12abcd34) as well as the normal + // telemetry/outpath comma form (12ab,cd34). + if (spec[0] >= '1' && spec[0] <= '3' && spec[1] == ':') { + const uint8_t width = uint8_t(spec[0] - '0'); + const char* hex = spec + 2; + const size_t chars = strlen(hex); + if (!chars || chars % (width * 2) || chars / 2 > MAX_PATH_SIZE) { + error = "Err - path must be direct, none, or 1|2|3:hex"; return false; + } + const size_t bytes = chars / 2, hops = bytes / width; + if (!hops || hops > 63) { error = "Err - path too long"; return false; } + for (size_t i = 0; i < bytes; ++i) { + const uint8_t hi = nibble(hex[i * 2]), lo = nibble(hex[i * 2 + 1]); + if (hi > 15 || lo > 15) { error = "Err - invalid path hex"; return false; } + path[i] = uint8_t((hi << 4) | lo); + } + path_len = uint8_t(((width - 1) << 6) | hops); + return Packet::isValidPathLen(path_len); + } + + uint8_t width = 0, hops = 0; + char* token = spec; + while (token && *token) { + char* comma = strchr(token, ','); + if (comma) *comma = 0; + token = trimDataRoute(token); + const size_t chars = strlen(token); + const uint8_t token_width = uint8_t(chars / 2); + if ((chars != 2 && chars != 4 && chars != 6) + || (width && width != token_width) || hops >= 63 + || size_t(hops + 1) * token_width > MAX_PATH_SIZE) { + error = "Err - path hashes must have one consistent 1-3 byte width"; + return false; + } + if (!width) width = token_width; + for (uint8_t i = 0; i < width; ++i) { + const uint8_t hi = nibble(token[i * 2]), lo = nibble(token[i * 2 + 1]); + if (hi > 15 || lo > 15) { error = "Err - invalid path hex"; return false; } + path[hops * width + i] = uint8_t((hi << 4) | lo); + } + ++hops; token = comma ? comma + 1 : nullptr; + } + if (!width || !hops) { error = "Err - missing path"; return false; } + path_len = uint8_t(((width - 1) << 6) | hops); + return Packet::isValidPathLen(path_len); +} + +static void formatDataPath(const DataRouteState& route, char* out, size_t size) { + if (route.path_len == OUT_PATH_UNKNOWN) { snprintf(out, size, "none"); return; } + if (!Packet::isValidPathLen(route.path_len)) { snprintf(out, size, "invalid"); return; } + const uint8_t hops = route.path_len & 63; + if (!hops) { snprintf(out, size, "direct"); return; } + const uint8_t width = (route.path_len >> 6) + 1; + size_t used = snprintf(out, size, "%u:", unsigned(width)); + for (unsigned i = 0; i < unsigned(hops) * width && used + 2 < size; ++i) { + used += snprintf(out + used, size - used, "%02x", route.path[i]); + } +} + +static bool dataRouteSave(DataRouteState& route) { + if (!route.fs) return false; + uint8_t data[DATA_ROUTE_SIZE] = {}; + memcpy(data, "DTX1", 4); data[4] = route.path_len; data[5] = route.region_mode; + memcpy(data + 8, route.path, MAX_PATH_SIZE); + memcpy(data + 72, route.region, sizeof(route.region)); + writeRoute32(data + 104, storage::updateCRC32(0xffffffff, data, 104)); +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + AtomicFileWriter writer(route.fs, DATA_ROUTE_FILE); +#else + ContactFileTransaction writer(route.fs, DATA_ROUTE_FILE); +#endif + const bool ok = writer && writer.write(data, sizeof(data)) == sizeof(data) + && writer.commit(); + if (ok) route.persisted = true; + return ok; +} + +static bool dataRouteLoad(DataRouteState& route) { +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + if (!ContactFileTransaction::recover(route.fs, DATA_ROUTE_FILE)) return false; +#endif + if (!route.fs->exists(DATA_ROUTE_FILE)) return true; + uint8_t data[DATA_ROUTE_SIZE] = {}; + for (unsigned retry = 0; retry < 3; ++retry) { + auto file = openFileRead(route.fs, DATA_ROUTE_FILE); + const bool read = file && file.size() == sizeof(data) + && file.read(data, sizeof(data)) == sizeof(data); + if (file) file.close(); + const bool named = data[5] == DATA_REGION_NAMED; + if (!read || memcmp(data, "DTX1", 4) + || (data[4] != OUT_PATH_UNKNOWN && !Packet::isValidPathLen(data[4])) + || data[5] > DATA_REGION_NONE || (named && !data[72]) + || data[102] != 0 + || readRoute32(data + 104) + != storage::updateCRC32(0xffffffff, data, 104)) continue; + route.path_len = data[4]; route.region_mode = data[5]; + memcpy(route.path, data + 8, MAX_PATH_SIZE); + memcpy(route.region, data + 72, sizeof(route.region)); + route.region[sizeof(route.region) - 1] = 0; + route.persisted = true; + return true; + } + return false; +} + +static uint8_t regionDepth(const RegionMap& regions, const RegionEntry* region) { + if (!region || region->isWildcard()) return 0; + uint8_t depth = 1; + uint16_t parent = region->parent; + for (int walked = 0; parent; ++walked) { + if (walked >= regions.getCount()) return 0; + const RegionEntry* entry = const_cast(regions).findById(parent); + if (!entry || entry->isWildcard()) return 0; + ++depth; parent = entry->parent; + } + return depth; +} + +static bool usableScope(DataRouteState& route, const RegionEntry* region, + TransportKey& scope) { + return region && !region->isWildcard() && !(region->flags & REGION_DENY_FLOOD) + && route.regions->getTransportKeysFor(*region, &scope, 1) > 0 + && !scope.isNull(); +} + +static bool resolveDataScope(DataRouteState& route, TransportKey& scope, + char* resolved, size_t resolved_size, + bool* ambiguity) { + if (resolved && resolved_size) resolved[0] = 0; + if (ambiguity) *ambiguity = false; + const RegionEntry* selected = nullptr; + if (route.region_mode == DATA_REGION_NONE) return false; + if (route.region_mode == DATA_REGION_DEFAULT) { + selected = route.regions->getDefaultRegion(); + } else if (route.region_mode == DATA_REGION_NAMED) { + selected = route.regions->findByName(route.region); + } else { + // Auto follows an explicit radio default when it is usable. Radios + // without one fall back to the single narrowest usable region in the + // definition, which keeps fresh installs deterministic without silently + // choosing between equally specific scopes. + selected = route.regions->getDefaultRegion(); + TransportKey default_scope; + if (usableScope(route, selected, default_scope)) { + scope = default_scope; + if (resolved && resolved_size) { + strncpy(resolved, selected->name, resolved_size - 1); + resolved[resolved_size - 1] = 0; + } + return true; + } + selected = nullptr; + uint8_t best_depth = 0; + bool tied = false; + for (int i = 0; i < route.regions->getCount(); ++i) { + const RegionEntry* candidate = route.regions->getByIdx(i); + TransportKey candidate_scope; + if (!usableScope(route, candidate, candidate_scope)) continue; + const uint8_t depth = regionDepth(*route.regions, candidate); + if (depth > best_depth) { selected = candidate; best_depth = depth; tied = false; } + else if (depth && depth == best_depth) tied = true; + } + if (tied) { if (ambiguity) *ambiguity = true; return false; } + } + if (!usableScope(route, selected, scope)) return false; + if (resolved && resolved_size) { + strncpy(resolved, selected->name, resolved_size - 1); + resolved[resolved_size - 1] = 0; + } + return true; +} +} // namespace mesh + +void CommonCLI::beginManagement(mesh::Mesh& mesh, FILESYSTEM* fs) { + _management_mesh = &mesh; _management_fs = fs; + _management_last_ms = millis(); _management_uptime_ms = _management_last_ms; + if (!_data_route) { + _data_route = new (std::nothrow) mesh::DataRouteState; + if (_data_route) { + _data_route->fs = fs; _data_route->regions = _region_map; + _data_route->healthy = mesh::dataRouteLoad(*_data_route); + } + } + // No reporter/history allocation for the default unconfigured installation. + if (fs->exists("/management") || fs->exists("/management.bak")) { + _management = new (std::nothrow) mesh::ManagementReporter( + mesh, *_board, *_sensors, *_acl, *_prefs, *_callbacks, *this, fs); + } +} + +void CommonCLI::loopManagement() { + const uint32_t now = millis(); + _management_uptime_ms += uint32_t(now - _management_last_ms); _management_last_ms = now; + if (_management) _management->loop(_management_uptime_ms / 1000); +} + +bool CommonCLI::getDataTxPath(const uint8_t*& path, uint8_t& path_len) const { + static const uint8_t direct[MAX_PATH_SIZE] = {}; + if (!_data_route || !_data_route->healthy) { path = direct; path_len = OUT_PATH_UNKNOWN; return false; } + path = _data_route->path; path_len = _data_route->path_len; + return path_len != OUT_PATH_UNKNOWN && mesh::Packet::isValidPathLen(path_len); +} + +bool CommonCLI::resolveDataTxScope(TransportKey& scope, char* name, + size_t name_size, bool* ambiguous) const { + return _data_route && _data_route->healthy + && mesh::resolveDataScope(*_data_route, scope, name, name_size, ambiguous); +} + +bool CommonCLI::adoptLegacyDataTxPath(const uint8_t* path, uint8_t path_len) { + if (!_data_route || !_data_route->healthy || _data_route->persisted + || path_len == OUT_PATH_UNKNOWN || !mesh::Packet::isValidPathLen(path_len) + || ((path_len & 63) && !path)) return false; + const uint8_t previous_len = _data_route->path_len; + uint8_t previous[MAX_PATH_SIZE]; memcpy(previous, _data_route->path, sizeof(previous)); + _data_route->path_len = path_len; memset(_data_route->path, 0, sizeof(_data_route->path)); + if (path_len & 63) mesh::Packet::copyPath(_data_route->path, path, path_len); + if (mesh::dataRouteSave(*_data_route)) return true; + _data_route->path_len = previous_len; memcpy(_data_route->path, previous, sizeof(previous)); + return false; +} + +bool CommonCLI::setDataTxPath(const char* spec, char* reply, size_t reply_size) { + if (!spec || strlen(spec) > 140) { + snprintf(reply, reply_size, "Err - invalid data.tx path"); return false; + } + char command[160]; + snprintf(command, sizeof(command), "set data.tx path %s", spec); + char result[160] = {}; + const bool handled = handleDataTxCommand(command, result); + snprintf(reply, reply_size, "%s", result); + return handled && !strncmp(result, "OK", 2); +} + +bool CommonCLI::handleDataTxCommand(char* command, char* reply) { + const bool get_all = !strcmp(command, "get data.tx"); + const bool get_path = !strcmp(command, "get data.tx path") || !strcmp(command, "get data.tx.path"); + const bool get_region = !strcmp(command, "get data.tx region") || !strcmp(command, "get data.tx.region"); + const char* path_prefix = !strncmp(command, "set data.tx path ", 17) ? "set data.tx path " + : (!strncmp(command, "set data.tx.path ", 17) ? "set data.tx.path " : nullptr); + const char* region_prefix = !strncmp(command, "set data.tx region ", 19) ? "set data.tx region " + : (!strncmp(command, "set data.tx.region ", 19) ? "set data.tx.region " : nullptr); + if (!get_all && !get_path && !get_region && !path_prefix && !region_prefix) return false; + if (!_data_route) { strcpy(reply, "Err - data.tx unavailable"); return true; } + if (!_data_route->healthy) { strcpy(reply, "Err - data.tx storage fault"); return true; } + + char path_text[132]; mesh::formatDataPath(*_data_route, path_text, sizeof(path_text)); + if (get_path) { snprintf(reply, 160, "> %s", path_text); return true; } + if (get_region) { + if (_data_route->region_mode == mesh::DATA_REGION_AUTO) { + char resolved[31]; bool ambiguous = false; TransportKey scope; + const bool ok = resolveDataTxScope(scope, resolved, sizeof(resolved), &ambiguous); + snprintf(reply, 160, "> auto (%s)", ok ? resolved : ambiguous ? "ambiguous" : "unresolved"); + } else if (_data_route->region_mode == mesh::DATA_REGION_DEFAULT) strcpy(reply, "> default"); + else if (_data_route->region_mode == mesh::DATA_REGION_NONE) strcpy(reply, "> none"); + else snprintf(reply, 160, "> %s", _data_route->region); + return true; + } + if (get_all) { + char resolved[31]; bool ambiguous = false; TransportKey scope; + const bool scoped = resolveDataTxScope(scope, resolved, sizeof(resolved), &ambiguous); + const char* mode = _data_route->region_mode == mesh::DATA_REGION_AUTO ? "auto" + : _data_route->region_mode == mesh::DATA_REGION_DEFAULT ? "default" + : _data_route->region_mode == mesh::DATA_REGION_NONE ? "none" : _data_route->region; + snprintf(reply, 160, "> path=%s region=%s resolved=%s", path_text, mode, + scoped ? resolved : ambiguous ? "ambiguous" : "none"); + return true; + } + + const mesh::DataRouteState previous = *_data_route; + if (path_prefix) { + char* value = command + strlen(path_prefix); const char* error = nullptr; + uint8_t candidate[MAX_PATH_SIZE], encoded = OUT_PATH_UNKNOWN; + if (!mesh::parseDataPath(value, candidate, encoded, error)) { + snprintf(reply, 160, "%s", error ? error : "Err - invalid path"); return true; + } + _data_route->path_len = encoded; memcpy(_data_route->path, candidate, sizeof(candidate)); + } else { + char* value = mesh::trimDataRoute(command + strlen(region_prefix)); + if (!strcmp(value, "auto")) { _data_route->region_mode = mesh::DATA_REGION_AUTO; _data_route->region[0] = 0; } + else if (!strcmp(value, "default")) { _data_route->region_mode = mesh::DATA_REGION_DEFAULT; _data_route->region[0] = 0; } + else if (!strcmp(value, "none") || !strcmp(value, "clear") || !strcmp(value, "-")) { + _data_route->region_mode = mesh::DATA_REGION_NONE; _data_route->region[0] = 0; + } else { + RegionEntry* region = _region_map->findByNamePrefix(value); + TransportKey scope; + if (!region || !mesh::usableScope(*_data_route, region, scope)) { + strcpy(reply, "Err - unknown, ambiguous, denied, or unusable region"); return true; + } + _data_route->region_mode = mesh::DATA_REGION_NAMED; + strncpy(_data_route->region, region->name, sizeof(_data_route->region) - 1); + _data_route->region[sizeof(_data_route->region) - 1] = 0; + } + } + if (!mesh::dataRouteSave(*_data_route)) { + *_data_route = previous; strcpy(reply, "Err - unable to save data.tx"); return true; + } + strcpy(reply, "OK"); return true; +} + +bool CommonCLI::handleManagementCommand(char* command, char* reply) { + if (handleDataTxCommand(command, reply)) return true; + if (!strcmp(command, "get mgmt.path")) { + char alias[] = "get data.tx path"; + return handleDataTxCommand(alias, reply); + } + if (!strncmp(command, "set mgmt.path ", 14)) { + char alias[160]; + snprintf(alias, sizeof(alias), "set data.tx path %s", command + 14); + return handleDataTxCommand(alias, reply); + } + if (strcmp(command, "get mgmt") && strncmp(command, "get mgmt.", 9) + && strncmp(command, "set mgmt.", 9)) return false; + if (!_management && _management_mesh && _management_fs) { + _management = new (std::nothrow) mesh::ManagementReporter( + *_management_mesh, *_board, *_sensors, *_acl, *_prefs, *_callbacks, *this, + _management_fs); + } + if (!_management) { strcpy(reply, "ERR: management unavailable"); return true; } + if (!_management->command(command, reply, 160)) strcpy(reply, "ERR: unknown management setting"); + return true; +} diff --git a/src/helpers/ManagementReport.cpp b/src/helpers/ManagementReport.cpp new file mode 100644 index 00000000..430f6aa1 --- /dev/null +++ b/src/helpers/ManagementReport.cpp @@ -0,0 +1,82 @@ +#include "ManagementReport.h" +#include +#include + +namespace mesh { namespace management { +void passwordKey(const char* password, uint8_t key[32]) { + SHA256 hash; hash.reset(); hash.update("#", 1); + hash.update(password, strlen(password)); hash.finalize(key, 32); +} +void deriveKey(const uint8_t key[32], const char* domain, const uint8_t radio[16], uint8_t out[32]) { + SHA256 hash; hash.resetHMAC(key, 32); hash.update(domain, strlen(domain)); + hash.update(radio, 16); hash.finalizeHMAC(key, 32, out, 32); +} +void fingerprint(const uint8_t key[32], const uint8_t radio[16], const uint8_t admin[32], uint8_t out[12]) { + uint8_t derived[32]; deriveKey(key, "MeshCore-MGR1-ACL", radio, derived); + SHA256 hash; hash.resetHMAC(derived, 32); hash.update(radio, 16); hash.update(admin, 32); + hash.finalizeHMAC(derived, 32, out, 12); erase(derived, sizeof(derived)); +} +bool equal(const uint8_t* a, const uint8_t* b, size_t size) { + uint8_t d = 0; while (size--) d |= *a++ ^ *b++; return d == 0; +} +static void dbl(uint8_t b[16]) { + const uint8_t carry = b[0] >> 7; + for (unsigned i = 0; i < 15; ++i) b[i] = (b[i] << 1) | (b[i + 1] >> 7); + b[15] = (b[15] << 1) ^ (carry ? 0x87 : 0); +} +// NIST SP 800-38B CMAC, using the existing rweather AES implementation. +static void cmac(AES128& aes, const uint8_t* data, size_t len, uint8_t out[16]) { + uint8_t subkey[16] = {}, block[16] = {}; + aes.encryptBlock(subkey, subkey); dbl(subkey); + while (len > 16) { + for (unsigned i = 0; i < 16; ++i) block[i] ^= data[i]; + aes.encryptBlock(block, block); data += 16; len -= 16; + } + for (size_t i = 0; i < len; ++i) block[i] ^= data[i]; + if (len < 16) { dbl(subkey); block[len] ^= 0x80; } + for (unsigned i = 0; i < 16; ++i) block[i] ^= subkey[i]; + aes.encryptBlock(out, block); erase(subkey, 16); erase(block, 16); +} +static void s2v(const uint8_t key[32], const uint8_t* aad, size_t aad_len, + const uint8_t* data, size_t len, uint8_t tag[16]) { + AES128 aes; aes.setKey(key, 16); + uint8_t d[16] = {}, t[16], buf[MAX_PAYLOAD] = {}; + cmac(aes, d, 16, d); dbl(d); cmac(aes, aad, aad_len, t); + for (unsigned i = 0; i < 16; ++i) d[i] ^= t[i]; + if (len >= 16) { + memcpy(buf, data, len); + for (unsigned i = 0; i < 16; ++i) buf[len - 16 + i] ^= d[i]; + cmac(aes, buf, len, tag); + } else { + dbl(d); memcpy(buf, data, len); buf[len] = 0x80; + for (unsigned i = 0; i < 16; ++i) buf[i] ^= d[i]; + cmac(aes, buf, 16, tag); + } + erase(buf, sizeof(buf)); erase(d, 16); erase(t, 16); +} +static void ctr(const uint8_t key[32], uint8_t* data, size_t len, const uint8_t tag[16]) { + AES128 aes; aes.setKey(key + 16, 16); + uint8_t counter[16], stream[16]; memcpy(counter, tag, 16); + counter[8] &= 0x7f; counter[12] &= 0x7f; + while (len) { + aes.encryptBlock(stream, counter); const size_t n = len < 16 ? len : 16; + for (size_t i = 0; i < n; ++i) data[i] ^= stream[i]; + data += n; len -= n; + for (int i = 15; i >= 0 && ++counter[i] == 0; --i) {} + } + erase(counter, 16); erase(stream, 16); +} +bool seal(const uint8_t key[32], const uint8_t* aad, size_t aad_len, + uint8_t* data, size_t len, uint8_t tag[16]) { + if (len > MAX_PAYLOAD || aad_len > MAX_PAYLOAD) return false; + s2v(key, aad, aad_len, data, len, tag); ctr(key, data, len, tag); return true; +} +bool open(const uint8_t key[32], const uint8_t* aad, size_t aad_len, + uint8_t* data, size_t len, const uint8_t tag[16]) { + if (len > MAX_PAYLOAD || aad_len > MAX_PAYLOAD) return false; + uint8_t expected[16]; ctr(key, data, len, tag); s2v(key, aad, aad_len, data, len, expected); + const bool ok = equal(expected, tag, 16); erase(expected, 16); + if (!ok) erase(data, len); + return ok; +} +} } diff --git a/src/helpers/ManagementReport.h b/src/helpers/ManagementReport.h new file mode 100644 index 00000000..3e6dd3f3 --- /dev/null +++ b/src/helpers/ManagementReport.h @@ -0,0 +1,125 @@ +#pragma once + +#include +#include +#include +#include + +// Portable management wire format. No update authorization is granted by this +// protocol. See docs/management_reports.md for the byte layout and trust model. +namespace mesh { namespace management { +constexpr size_t HEADER = 83, TAG = 16, ENTRY = 13, PER_PAGE = 6; +constexpr size_t MAX_KEYS = 36, MAX_PAGES = 6, MAX_PAYLOAD = HEADER + TAG + PER_PAGE * ENTRY; +constexpr uint32_t DAY = 86400, FLOOD_INTERVAL = 21 * DAY; +enum Feature : uint8_t { WIFI = 1, GPS = 2, NTP = 4, USB = 8, OTA = 16 }; +enum Valid : uint16_t { FIRMWARE = 1, BOOTLOADER = 2, BASE = 4, STORE = 8, + PARTIAL_WEEK = 16, PARTIAL_PERIOD = 32, MCU_TEMPERATURE = 64 }; +enum Permission : uint8_t { ADMIN = 1, OTA_SIGNER = 2 }; + +inline uint16_t read16(const uint8_t* p) { return p[0] | (uint16_t(p[1]) << 8); } +inline uint32_t read32(const uint8_t* p) { return read16(p) | (uint32_t(read16(p + 2)) << 16); } +inline void write16(uint8_t* p, uint16_t n) { p[0] = n; p[1] = n >> 8; } +inline void write32(uint8_t* p, uint32_t n) { write16(p, n); write16(p + 2, n >> 16); } +inline void erase(void* p, size_t n) { volatile uint8_t* b = static_cast(p); while (n--) *b++ = 0; } + +// 0 missing, 1..251 = -50..200 C, 252 below range, 253 above range. +inline uint8_t temperature(float c) { + if (!isfinite(c)) return 0; + if (c < -50) return 252; + if (c > 200) return 253; + return uint8_t(lroundf(c) + 51); +} +inline int tempOrder(uint8_t t) { return t == 252 ? -1 : t == 253 ? 252 : t; } +struct Extrema { + uint16_t voltage = 0; + uint8_t low = 0, high = 0; + void add(uint16_t v, uint8_t t) { + if (v && (!voltage || v < voltage)) voltage = v; + if (t && (!low || tempOrder(t) < tempOrder(low))) low = t; + if (t && (!high || tempOrder(t) > tempOrder(high))) high = t; + } + void merge(const Extrema& e) { add(e.voltage, e.low); add(0, e.high); } + void encode(uint8_t* p) const { write16(p, voltage); p[2] = low; p[3] = high; } +}; + +// Hourly extrema, retaining at least seven days, at most seven days + one +// hour. Sampling once a minute includes all samples, not only the first one +// in each bucket. No RTC dependence and no claim of pre-boot history. +class History { + Extrema hours[169] = {}; + uint32_t hour = 0; + bool started = false; +public: + Extrema period; + void advance(uint32_t seconds) { + const uint32_t h = seconds / 3600; + if (!started || h < hour || h - hour >= 169) memset(hours, 0, sizeof(hours)); + else for (uint32_t i = hour + 1; i <= h; ++i) hours[i % 169] = Extrema(); + started = true; hour = h; + } + void sample(uint32_t seconds, uint16_t v, float c) { + advance(seconds); const uint8_t t = temperature(c); + hours[hour % 169].add(v, t); period.add(v, t); + } + Extrema week() const { Extrema result; for (const auto& h : hours) result.merge(h); return result; } +}; + +// Countdown seconds survive reboot via conservative hourly checkpoints. +// Wall-clock changes never accelerate reporting. Offline time is not credited. +struct Schedule { + uint32_t direct = 5 * DAY, flood = 21 * DAY; + static uint32_t sub(uint32_t n, uint32_t elapsed) { return elapsed < n ? n - elapsed : 0; } + void advance(uint32_t elapsed) { direct = sub(direct, elapsed); flood = sub(flood, elapsed); } + static bool validDirect(unsigned days) { return days >= 5 && days <= 90; } + static bool validFlood(unsigned days) { return days >= 21 && days <= 90; } + void reserve(bool is_flood, unsigned direct_days, unsigned flood_days, + uint32_t jitter) { + if (is_flood) { + flood = flood_days * DAY + jitter; + // A flood replaces a direct copy only when that copy is already due. + // Otherwise the independent direct cadence is left untouched. + if (!direct && direct_days) direct = direct_days * DAY + jitter; + } else { + direct = direct_days * DAY + jitter; + } + } +}; + +void passwordKey(const char* password, uint8_t key[32]); +void deriveKey(const uint8_t key[32], const char* domain, const uint8_t radio[16], uint8_t out[32]); +void fingerprint(const uint8_t key[32], const uint8_t radio[16], const uint8_t admin[32], uint8_t out[12]); +// RFC 5297 AES-SIV with one associated-data string. Tag is stored separately. +// Bound to packet-sized data; decrypt erases plaintext on authentication failure. +bool seal(const uint8_t key[32], const uint8_t* aad, size_t aad_len, + uint8_t* data, size_t len, uint8_t tag[16]); +bool open(const uint8_t key[32], const uint8_t* aad, size_t aad_len, + uint8_t* data, size_t len, const uint8_t tag[16]); +bool equal(const uint8_t* a, const uint8_t* b, size_t size); +inline size_t pageSize(const uint8_t* p) { return HEADER + p[82] * ENTRY + TAG; } +inline size_t floodSize(size_t canonical) { return 3 + ((canonical - 3 + 15) / 16) * 16; } +inline bool validPage(const uint8_t* p, size_t size, bool flood_padding = false) { + if (!p || size < HEADER + TAG || size > (flood_padding ? 179 : MAX_PAYLOAD) || memcmp(p, "MGR1", 4)) return false; + const unsigned page = p[78], pages = p[79], total = p[80], first = p[81], count = p[82]; + const unsigned expected_pages = total ? (total + PER_PAGE - 1) / PER_PAGE : 1; + if (total > MAX_KEYS || pages != expected_pages || page >= pages || first != page * PER_PAGE || first > total) return false; + const unsigned remaining = total - first; + if (count != (remaining < PER_PAGE ? remaining : PER_PAGE)) return false; + const size_t canonical = pageSize(p); + if (size != (flood_padding ? floodSize(canonical) : canonical)) return false; + for (size_t i = canonical; i < size; ++i) if (p[i]) return false; + return true; +} + +struct AclList { + uint8_t entries[MAX_KEYS][ENTRY] = {}; + uint8_t count = 0; + bool add(const uint8_t token[12], uint8_t permissions) { + for (uint8_t i = 0; i < count; ++i) { + if (!memcmp(entries[i], token, 12)) { entries[i][12] |= permissions; return true; } + } + if (count == MAX_KEYS) return false; + memcpy(entries[count], token, 12); entries[count++][12] = permissions; return true; + } + uint8_t pages() const { return count ? (count + PER_PAGE - 1) / PER_PAGE : 1; } +}; +} } diff --git a/src/helpers/ManagementReporter.cpp b/src/helpers/ManagementReporter.cpp new file mode 100644 index 00000000..12408f8f --- /dev/null +++ b/src/helpers/ManagementReporter.cpp @@ -0,0 +1,386 @@ +#include "ManagementReporter.h" +#include "CommonCLI.h" +#include "FileRead.h" +#include "PersistentStoreFormat.h" +#include +#include +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#include "AtomicFileWriter.h" +#else +#include "ContactFileTransaction.h" +#endif +#if defined(ESP32_PLATFORM) +#include +#include +#endif +#if defined(ENABLE_OTA) +#include "ota/OtaContext.h" +#include "ota/OtaDeflate.h" +#endif + +namespace mesh { +using namespace management; +static constexpr char STATE_FILE[] = "/management"; +static constexpr size_t STATE_SIZE = 120; +struct ManagementReporter::Working { + History history; + Extrema during_report; + AclList acl; + uint8_t header[HEADER] = {}; + TransportKey scope; + uint8_t route_path[MAX_PATH_SIZE] = {}; + uint8_t route_path_len = OUT_PATH_UNKNOWN; + uint32_t sample_in = 0, page_in = 0, lifetime = 0, history_seconds = 0; + uint8_t page = 0; + bool sending = false, flood = false, partial_period = true; +}; + +ManagementReporter::ManagementReporter(Mesh& mesh, MainBoard& board, SensorManager& sensors, + ClientACL& acl, NodePrefs& prefs, CommonCLICallbacks& callbacks, + CommonCLI& cli, FILESYSTEM* fs) + : mesh(mesh), board(board), sensors(sensors), acl(acl), prefs(prefs), + callbacks(callbacks), cli(cli), fs(fs) { + last_ms = millis(); healthy = load(); + if (healthy && enabled && !allocate()) healthy = false; +} +ManagementReporter::~ManagementReporter() { delete work; erase(key, sizeof(key)); } +bool ManagementReporter::allocate() { + static_assert(sizeof(Working) <= 1536, "Keep opt-in management working RAM bounded"); + if (!work) work = new (std::nothrow) Working; + return work != nullptr; +} +bool ManagementReporter::load() { +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + if (!ContactFileTransaction::recover(fs, STATE_FILE)) return false; +#endif + if (!fs->exists(STATE_FILE)) return true; // fresh install remains off + uint8_t b[STATE_SIZE] = {}; + for (unsigned retry = 0; retry < 3; ++retry) { + auto f = openFileRead(fs, STATE_FILE); + const bool read = f && f.size() == sizeof(b) && f.read(b, sizeof(b)) == sizeof(b); + if (f) f.close(); + const bool legacy = read && !memcmp(b, "MGC1", 4); + const bool current = read && !memcmp(b, "MGC2", 4); + const bool direct_valid = legacy ? Schedule::validDirect(b[5]) + : (b[5] == 0 || Schedule::validDirect(b[5])); + const bool flood_valid = legacy + ? (b[7] == OUT_PATH_UNKNOWN || Packet::isValidPathLen(b[7])) + : (b[7] == 0 || Schedule::validFlood(b[7])); + if ((!legacy && !current) || b[4] > 1 || b[6] > 1 || + !direct_valid || !flood_valid || (b[4] && (!b[6] || (!b[5] && !b[7]))) || + read32(b + 116) != storage::updateCRC32(0xffffffff, b, 116) || + read32(b + 104) > 90 * DAY + 3600 || read32(b + 108) > 90 * DAY + 3600) continue; + enabled = b[4]; direct_days = b[5]; keyed = b[6]; + flood_days = legacy ? (direct_days < 21 ? 21 : direct_days) : b[7]; + if (legacy && b[7] != OUT_PATH_UNKNOWN) cli.adoptLegacyDataTxPath(b + 8, b[7]); + memcpy(key, b + 72, 32); + schedule.direct = read32(b + 104); schedule.flood = read32(b + 108); + if (!direct_days) schedule.direct = 0; + if (!flood_days) schedule.flood = 0; + sequence = read32(b + 112); erase(b, sizeof(b)); return true; + } + erase(b, sizeof(b)); return false; // unreadable/corrupt state never transmits +} +bool ManagementReporter::save() { + uint8_t b[STATE_SIZE] = {}; + memcpy(b, "MGC2", 4); b[4] = enabled; b[5] = direct_days; b[6] = keyed; + b[7] = flood_days; memcpy(b + 72, key, 32); + write32(b + 104, schedule.direct); write32(b + 108, schedule.flood); write32(b + 112, sequence); + write32(b + 116, storage::updateCRC32(0xffffffff, b, 116)); +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + AtomicFileWriter writer(fs, STATE_FILE); +#else + ContactFileTransaction writer(fs, STATE_FILE); +#endif + const bool ok = writer && writer.write(b, sizeof(b)) == sizeof(b) && writer.commit(); + erase(b, sizeof(b)); if (ok) checkpoint = 0; return ok; +} +void ManagementReporter::cancel() { + if (work) work->sending = false; +} +uint32_t ManagementReporter::jitter() const { + return (read32(mesh.self_id.pub_key) ^ (sequence * 2654435761UL)) % 3600; +} +void ManagementReporter::snapshot() { + auto& w = *work; memset(w.header, 0, sizeof(w.header)); w.acl = AclList(); + auto* p = w.header; memcpy(p, "MGR1", 4); memcpy(p + 4, mesh.self_id.pub_key, 16); + write32(p + 20, sequence); write32(p + 24, mesh.getRTCClock()->getCurrentTime()); + const uint64_t hours = uptime / 3600; write16(p + 60, hours > 65535 ? 65535 : hours); + w.history.week().encode(p + 62); w.history.period.encode(p + 66); + const uint32_t history_hours = w.history_seconds / 3600; + p[70] = history_hours > 168 ? 168 : history_hours; + p[71] = w.flood ? flood_days : direct_days; + p[72] = !strcmp(callbacks.getRole(), "repeater") ? 1 : !strcmp(callbacks.getRole(), "room_server") ? 2 : 3; + uint16_t valid = MCU_TEMPERATURE | (history_hours < 168 ? PARTIAL_WEEK : 0) | (w.partial_period ? PARTIAL_PERIOD : 0); + p[75] = GPS | OTA; // known active-state bits; unknown is not confused with off + if (sensors.isGPSDetected()) p[73] |= GPS; + auto* gps = sensors.getLocationProvider(); + if (gps && gps->isEnabled()) p[74] |= GPS; +#if defined(ESP32_PLATFORM) + p[73] |= WIFI; p[75] |= WIFI; + if (WiFi.status() == WL_CONNECTED) p[74] |= WIFI; +#endif +#if defined(NRF52_PLATFORM) || (defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT) + p[73] |= USB; p[75] |= USB; + if (board.isUsbDataConnected()) p[74] |= USB; +#endif +#ifdef WITH_MQTT_BRIDGE + p[73] |= NTP; p[75] |= NTP; + if (callbacks.managementNtpSynced()) p[74] |= NTP; +#endif +#if defined(ENABLE_OTA) + p[73] |= OTA; + ota::SelfFwInfo info; + if (ota::ota_self_firmware(info) && info.valid) { + write32(p + 28, info.fw_version); write32(p + 36, info.target_id); + memcpy(p + 40, info.body_hash, 8); write32(p + 48, info.image_len); + valid |= BASE; if (info.fw_version) valid |= FIRMWARE; + } + uint32_t caps = 1; // compiled OTA protocol + // bits 8..23 apply codecs; transfer capabilities are separate low bits. + caps |= 2; // OTA transport decoder is installed in all ENABLE_OTA builds + caps |= 4; // 2 KiB application transfer blocks +#if defined(ESP32_PLATFORM) + const esp_partition_t* running = esp_ota_get_running_partition(); + const esp_partition_t* next = esp_ota_get_next_update_partition(nullptr); + if (running && next && running->address != next->address) { + write32(p + 52, next->size); valid |= STORE; p[74] |= OTA; + caps |= 7UL << 8; // full + detools sequential + in-place + } +#elif defined(NRF52_PLATFORM) + const auto bl = ota::ota_bootloader_app_caps(); +#if defined(OTA_SD_STORE) || defined(OTA_QSPI_STORE) + const uint16_t codecs = bl.codec_mask & 5u; // full + in-place +#else + const uint16_t codecs = bl.codec_mask & 4u; // internal app path accepts deltas only +#endif + if (bl.present && bl.apply_abi >= 2) caps |= uint32_t(codecs) << 8; + ota::OtaBootloaderIdentity identity; + if (ota::ota_installed_bootloader_identity(identity) && identity.boot_version) { + write32(p + 32, identity.boot_version); valid |= BOOTLOADER; + } + // Never allocate an OTA workspace or wake external media just for a report. + if (auto* context = ota::ota_context_if_active()) { + const uint32_t capacity = context->fetch_store.capacity(); + write32(p + 52, capacity); valid |= STORE; + if (capacity && bl.present && bl.apply_abi >= 2 && codecs) p[74] |= OTA; + } else { + p[75] &= ~OTA; // compiled support, readiness not established + } +#else + p[75] &= ~OTA; +#endif +#if defined(OTA_SEEDER_ONLY) + caps &= 0xff; p[74] &= ~OTA; +#endif + write32(p + 56, caps); +#endif + // If EndF is absent, only publish a version we can parse unambiguously. + if (!(valid & FIRMWARE)) { + unsigned major = 0, minor = 0, patch = 0, pre = 0; + const char* version = callbacks.getFirmwareVer(); if (*version == 'v') ++version; + const int n = sscanf(version, "%u.%u.%u.%u", &major, &minor, &patch, &pre); + if (n >= 3 && major <= 255 && minor <= 255 && patch <= 255 && pre <= 255) { + write32(p + 28, major << 24 | minor << 16 | patch << 8 | pre); valid |= FIRMWARE; + } + } + write16(p + 76, valid); + uint8_t token[12]; bool complete = true; + for (int i = 0; i < acl.getNumClients(); ++i) { + const auto* client = acl.getClientByIdx(i); + if (!client->isAdmin()) continue; + fingerprint(key, p + 4, client->id.pub_key, token); complete &= w.acl.add(token, ADMIN); + } +#if defined(ENABLE_OTA) + if (prefs.ota_signer_count > 4) complete = false; + for (unsigned i = 0; i < prefs.ota_signer_count && i < 4; ++i) { + fingerprint(key, p + 4, prefs.ota_signers[i], token); complete &= w.acl.add(token, OTA_SIGNER); + } +#endif + erase(token, sizeof(token)); + if (!complete) healthy = false; // never silently truncate an ACL +} +void ManagementReporter::start(bool flood) { + if (sequence == UINT32_MAX) { healthy = false; return; } + auto& w = *work; + w.route_path_len = OUT_PATH_UNKNOWN; + memset(w.route_path, 0, sizeof(w.route_path)); + if (flood) { + if (!cli.resolveDataTxScope(w.scope)) return; + } else { + const uint8_t* configured = nullptr; + if (!cli.getDataTxPath(configured, w.route_path_len)) return; + if (w.route_path_len & 63) { + Packet::copyPath(w.route_path, configured, w.route_path_len); + } + } + ++sequence; schedule.reserve(flood, direct_days, flood_days, jitter()); + if (!save()) { healthy = false; return; } // reserve BEFORE any packet leaves + w.flood = flood; + snapshot(); if (!healthy) return; + w.page = 0; w.page_in = 0; + w.during_report = Extrema(); + w.sending = true; w.lifetime = 3600; +} +void ManagementReporter::sendPage() { + auto& w = *work; + uint8_t packet[MAX_PAYLOAD], enc[32]; memcpy(packet, w.header, HEADER); + const uint8_t first = w.page * PER_PAGE; + const uint8_t count = w.acl.count - first < PER_PAGE ? w.acl.count - first : PER_PAGE; + packet[78] = w.page; packet[79] = w.acl.pages(); packet[80] = w.acl.count; + packet[81] = first; packet[82] = count; + const size_t private_len = count * ENTRY, size = HEADER + private_len + TAG; + memcpy(packet + HEADER, w.acl.entries + first, private_len); + deriveKey(key, "MeshCore-MGR1-SIV", packet + 4, enc); + seal(enc, packet, HEADER, packet + HEADER, private_len, packet + HEADER + private_len); + erase(enc, sizeof(enc)); + Packet* pkt = mesh.createRawData(packet, size); + if (!pkt) { w.page_in = 60; return; } + // Dedicated background admission avoids normal direct-message priority and + // automatic retries. Dispatcher still enforces normal channel/duty limits. + if (!mesh.sendManagementData(pkt, w.flood, w.route_path, w.route_path_len, + prefs.path_hash_mode + 1, + w.flood ? w.scope.key : nullptr)) { + w.page_in = 60; return; + } + ++w.page; w.page_in = 60; + if (w.page == w.acl.pages()) { + w.sending = false; + // Readings gathered while the frozen snapshot was being paged belong to + // the next report. Never discard that interval on completion. + w.history.period = w.during_report; w.partial_period = false; + } +} +void ManagementReporter::loop(uint64_t node_uptime_seconds) { + const uint32_t now = millis(), delta = now - last_ms; last_ms = now; + const uint64_t accumulated = uint64_t(fraction_ms) + delta; + const uint32_t elapsed = accumulated / 1000; fraction_ms = accumulated % 1000; + uptime = node_uptime_seconds == UINT64_MAX ? uptime + elapsed : node_uptime_seconds; + if (!healthy || !enabled || !keyed || !work) return; + auto& w = *work; schedule.advance(elapsed); checkpoint += elapsed; + w.history_seconds += elapsed; + w.sample_in = Schedule::sub(w.sample_in, elapsed); + w.page_in = Schedule::sub(w.page_in, elapsed); + w.lifetime = Schedule::sub(w.lifetime, elapsed); + if (w.sending && !w.lifetime) { w.sending = false; w.partial_period = true; } + if (!w.sample_in) { + const uint16_t voltage = board.getBattMilliVolts(); + const float celsius = board.getMCUTemperature(); + w.history.sample(w.history_seconds, voltage, celsius); + if (w.sending) w.during_report.add(voltage, temperature(celsius)); + w.sample_in = 60; + } + if (checkpoint >= 3600 && !save()) { healthy = false; return; } + if (mesh.isAnyTempRadioActive() || mesh.hasOutbound() || !mesh.getRemainingTxBudget()) return; + if (!w.sending) { + const uint8_t* route_path = nullptr; uint8_t route_path_len = OUT_PATH_UNKNOWN; + // Flood has its own conservative schedule. MQTT upload has no deployed + // return path, so a direct transmission never postpones it. When both are + // due, the scoped flood replaces the redundant direct transmission. + if (flood_days && !schedule.flood) start(true); + if (!w.sending && direct_days + && cli.getDataTxPath(route_path, route_path_len) && !schedule.direct) { + start(false); + } + } + if (healthy && w.sending && !w.page_in) sendPage(); +} +bool ManagementReporter::command(char* command, char* reply, size_t size) { + if (!strcmp(command, "get mgmt")) { + const uint8_t* route_path = nullptr; uint8_t route_path_len = OUT_PATH_UNKNOWN; + TransportKey scope; + const bool routed = cli.getDataTxPath(route_path, route_path_len); + const bool scoped = cli.resolveDataTxScope(scope); + char direct[12], flood[12]; + if (direct_days) snprintf(direct, sizeof(direct), "%ud", direct_days); + else strcpy(direct, "off"); + if (flood_days) snprintf(flood, sizeof(flood), "%ud", flood_days); + else strcpy(flood, "off"); + snprintf(reply, size, "> %s key=%s direct=%s/%luh flood=%s/%luh path=%s region=%s%s", + enabled ? "on" : "off", keyed ? "set" : "unset", direct, + (unsigned long)(schedule.direct / 3600), flood, + (unsigned long)(schedule.flood / 3600), + routed ? "set" : "missing", scoped ? "set" : "missing", + healthy ? "" : " FAULT(no TX)"); + return true; + } + if (strncmp(command, "set mgmt.", 9)) return false; + char* value = strchr(command + 9, ' '); + if (!value) { + snprintf(reply, size, "ERR: mgmt.enabled/direct/flood/interval/password value"); + return true; + } + ++value; + if (!healthy) { snprintf(reply, size, "ERR: management state fault; repair storage/reboot first"); return true; } + // Failed persistence stops reporting; no uncommitted state is transmitted. + cancel(); + if (!strncmp(command, "set mgmt.password ", 18)) { + const size_t len = strlen(value); + if (len < 12 || len > 96) { snprintf(reply, size, "ERR: password must be 12..96 bytes"); erase(value, len); return true; } + passwordKey(value, key); erase(value, len); keyed = true; + } else if (!strncmp(command, "set mgmt.enabled ", 17)) { + if (strcmp(value, "on") && strcmp(value, "off")) { snprintf(reply, size, "ERR: use on/off"); return true; } + const bool on = !strcmp(value, "on"); + const uint8_t* route_path = nullptr; uint8_t route_path_len = OUT_PATH_UNKNOWN; + TransportKey scope; + const bool direct_ready = !direct_days + || cli.getDataTxPath(route_path, route_path_len); + const bool flood_ready = !flood_days || cli.resolveDataTxScope(scope); + if (on && (!keyed || (!direct_days && !flood_days) || !direct_ready + || !flood_ready || !allocate())) { + snprintf(reply, size, "ERR: password, enabled route(s), data.tx path/region and free RAM required"); + return true; + } + if (on && !enabled && sequence == 0) { + if (direct_days && schedule.direct == direct_days * DAY) schedule.direct += jitter(); + if (flood_days && schedule.flood == flood_days * DAY) schedule.flood += jitter(); + } + enabled = on; + } else if (!strncmp(command, "set mgmt.direct ", 16)) { + if (!strcmp(value, "off")) { + if (enabled && !flood_days) { + snprintf(reply, size, "ERR: disable management or leave flood enabled"); return true; + } + direct_days = 0; schedule.direct = 0; + } else { + char* end; const unsigned long n = strtoul(value, &end, 10); + const uint8_t* route_path = nullptr; uint8_t route_path_len = OUT_PATH_UNKNOWN; + if (!*value || *end || !Schedule::validDirect(n) + || !cli.getDataTxPath(route_path, route_path_len)) { + snprintf(reply, size, "ERR: direct needs data.tx path and 5..90 days"); return true; + } + direct_days = n; schedule.direct = direct_days * DAY; + } + } else if (!strncmp(command, "set mgmt.flood ", 15)) { + if (!strcmp(value, "off")) { + if (enabled && !direct_days) { + snprintf(reply, size, "ERR: disable management or leave direct enabled"); return true; + } + flood_days = 0; schedule.flood = 0; + } else { + char* end; const unsigned long n = strtoul(value, &end, 10); + TransportKey scope; + if (!*value || *end || !Schedule::validFlood(n) + || !cli.resolveDataTxScope(scope)) { + snprintf(reply, size, "ERR: flood needs data.tx region and 21..90 days"); return true; + } + flood_days = n; schedule.flood = flood_days * DAY; + } + } else if (!strncmp(command, "set mgmt.interval ", 18)) { + char* end; const unsigned long n = strtoul(value, &end, 10); + const uint8_t* route_path = nullptr; uint8_t route_path_len = OUT_PATH_UNKNOWN; + TransportKey scope; + if (!*value || *end || !Schedule::validDirect(n) + || !cli.getDataTxPath(route_path, route_path_len) + || !cli.resolveDataTxScope(scope)) { + snprintf(reply, size, "ERR: interval needs path, region and 5..90 days"); return true; + } + // Compatibility shorthand: configure both schedules together. + direct_days = n; flood_days = n < 21 ? 21 : n; + schedule.direct = direct_days * DAY; schedule.flood = flood_days * DAY; + } else { snprintf(reply, size, "ERR: unknown management setting"); return true; } + if (!save()) { healthy = false; snprintf(reply, size, "ERR: save failed; reporting stopped"); return true; } + if (!enabled) { delete work; work = nullptr; } + snprintf(reply, size, "OK"); return true; +} +} diff --git a/src/helpers/ManagementReporter.h b/src/helpers/ManagementReporter.h new file mode 100644 index 00000000..1e118fad --- /dev/null +++ b/src/helpers/ManagementReporter.h @@ -0,0 +1,48 @@ +#pragma once + +#include "ManagementReport.h" +#include "IdentityStore.h" +#include + +class NodePrefs; +class CommonCLI; +class CommonCLICallbacks; +class ClientACL; +class SensorManager; + +namespace mesh { +class ManagementReporter { + struct Working; + Mesh& mesh; + MainBoard& board; + SensorManager& sensors; + ClientACL& acl; + NodePrefs& prefs; + CommonCLICallbacks& callbacks; + CommonCLI& cli; + FILESYSTEM* fs; + Working* work = nullptr; + uint8_t key[32] = {}; + uint8_t direct_days = 5, flood_days = 21; + bool enabled = false, keyed = false, healthy = true; + management::Schedule schedule; + uint32_t sequence = 0, last_ms = 0, fraction_ms = 0, checkpoint = 0; + uint64_t uptime = 0; + bool save(); + bool load(); + bool allocate(); + void cancel(); + void snapshot(); + void start(bool flood); + void sendPage(); + uint32_t jitter() const; +public: + ManagementReporter(Mesh& mesh, MainBoard& board, SensorManager& sensors, + ClientACL& acl, NodePrefs& prefs, + CommonCLICallbacks& callbacks, CommonCLI& cli, + FILESYSTEM* fs); + ~ManagementReporter(); + void loop(uint64_t node_uptime_seconds = UINT64_MAX); + bool command(char* command, char* reply, size_t size); +}; +} diff --git a/src/helpers/SensorManager.cpp b/src/helpers/SensorManager.cpp index 311977dc..6bbb7c6f 100644 --- a/src/helpers/SensorManager.cpp +++ b/src/helpers/SensorManager.cpp @@ -56,6 +56,24 @@ void SensorManager::maybeStopGpsForTelemetry(unsigned long now) { } } +void SensorManager::cancelGpsTelemetryDemand(unsigned long now) { + // An explicit user-off request must win over work started by an earlier + // telemetry query. Otherwise the acquisition/hold state can keep a GPS + // powered for up to two hours after the user turned it off. + gps_acquiring = false; + gps_acquire_has_fix = false; + gps_hold_until = 0; + gps_acquire_started_at = 0; + gps_stable_started_at = 0; + gps_weighted_lat = 0; + gps_weighted_lon = 0; + gps_weighted_altitude = 0; + gps_weight_sum = 0; + gps_weight_count = 0; + // Do not let the background cache refresh immediately undo the command. + gps_next_cache_update_at = now + GPS_TELEMETRY_CACHE_INTERVAL_SEC * 1000UL; +} + void SensorManager::beginGpsTelemetryAcquisition(unsigned long now) { if (!gps_transport_available || !telemetryGpsDetected() || gps_acquiring) return; @@ -162,6 +180,7 @@ void SensorManager::setGpsTelemetryUserEnabled(bool enabled) { if (gps_transport_available && telemetryGpsDetected() && !telemetryGpsActive()) telemetryGpsStart(); } else { + cancelGpsTelemetryDemand(now); maybeStopGpsForTelemetry(now); } } diff --git a/src/helpers/SensorManager.h b/src/helpers/SensorManager.h index 865739a5..33be9e4b 100644 --- a/src/helpers/SensorManager.h +++ b/src/helpers/SensorManager.h @@ -43,6 +43,7 @@ class SensorManager { void finishGpsTelemetryAcquisition(unsigned long now, bool use_weighted_average); void updateGpsTelemetryCache(float lat, float lon, float altitude, unsigned long now); void maybeStopGpsForTelemetry(unsigned long now); + void cancelGpsTelemetryDemand(unsigned long now); protected: virtual bool telemetryGpsDetected() const { return false; } diff --git a/test/fixtures/management/CommonCLI.h b/test/fixtures/management/CommonCLI.h new file mode 100644 index 00000000..e47558d8 --- /dev/null +++ b/test/fixtures/management/CommonCLI.h @@ -0,0 +1,38 @@ +#pragma once +#include +#include +class NodePrefs { public: uint8_t path_hash_mode = 0; }; +class CommonCLI { + uint8_t path[64] = {}; +public: + uint8_t path_len = 0; + bool scope_available = true; + bool getDataTxPath(const uint8_t*& out, uint8_t& len) const { + out = path; len = path_len; + return path_len != 0xff && mesh::Packet::isValidPathLen(path_len); + } + bool resolveDataTxScope(TransportKey& scope, char* = nullptr, + size_t = 0, bool* ambiguous = nullptr) const { + if (ambiguous) *ambiguous = false; + scope = TransportKey(); return scope_available; + } + bool adoptLegacyDataTxPath(const uint8_t*, uint8_t) { return true; } +}; +class CommonCLICallbacks { +public: + const char* getFirmwareVer() { return "v1.17.1.5"; } + const char* getRole() { return "repeater"; } +}; +struct LocationProvider { bool isEnabled() { return false; } }; +class SensorManager { +public: + bool isGPSDetected() { return false; } + LocationProvider* getLocationProvider() { return nullptr; } +}; +struct ClientInfo { mesh::LocalIdentity id; bool admin = true; bool isAdmin() const { return admin; } }; +class ClientACL { +public: + std::vector clients; + int getNumClients() { return clients.size(); } + ClientInfo* getClientByIdx(int i) { return &clients[i]; } +}; diff --git a/test/fixtures/management/Mesh.h b/test/fixtures/management/Mesh.h new file mode 100644 index 00000000..cc888552 --- /dev/null +++ b/test/fixtures/management/Mesh.h @@ -0,0 +1,49 @@ +#pragma once +#include +#include +#include +#include +inline uint32_t test_millis = 0; +inline uint32_t millis() { return test_millis; } +#define MAX_PATH_SIZE 64 +#define OUT_PATH_UNKNOWN 0xff +struct TransportKey { + uint8_t key[16] = {1}; + bool isNull() const { for (auto b : key) if (b) return false; return true; } +}; +namespace mesh { +struct LocalIdentity { uint8_t pub_key[32] = {}; }; +struct Packet { + uint8_t payload[184] = {}, payload_len = 0; + bool flood = false; + static bool isValidPathLen(uint8_t n) { return (n >> 6) < 3 && ((n & 63) * ((n >> 6) + 1)) <= 64; } + static uint8_t copyPath(uint8_t* out, const uint8_t* in, uint8_t n) { + const size_t bytes = (n & 63) * ((n >> 6) + 1); + if (bytes) memcpy(out, in, bytes); + return n; + } +}; +struct RTCClock { uint32_t time = 1789513200; uint32_t getCurrentTime() { return time; } }; +struct MainBoard { + uint16_t voltage = 3740; + float temperature = 24; + uint16_t getBattMilliVolts() { return voltage; } + float getMCUTemperature() { return temperature; } +}; +struct Mesh { + LocalIdentity self_id; + RTCClock rtc; + bool temp = false, outbound = false, fail_queue = false; + unsigned budget = 60000; + std::vector packets; + RTCClock* getRTCClock() { return &rtc; } + bool isAnyTempRadioActive() { return temp; } + bool hasOutbound() { return outbound; } + unsigned getRemainingTxBudget() { return budget; } + Packet* createRawData(const uint8_t* data, size_t n) { auto* p = new Packet; memcpy(p->payload, data, n); p->payload_len = n; return p; } + bool sendManagementData(Packet* p, bool flood, const uint8_t*, uint8_t, + uint8_t, const uint8_t* = nullptr) { + p->flood = flood; if (!fail_queue) packets.push_back(*p); delete p; return !fail_queue; + } +}; +} diff --git a/test/fixtures/management/protocol_test.cpp b/test/fixtures/management/protocol_test.cpp new file mode 100644 index 00000000..a59d65a9 --- /dev/null +++ b/test/fixtures/management/protocol_test.cpp @@ -0,0 +1,75 @@ +#include +#include +#include +#include +#include +using namespace mesh::management; +static std::vector hex(const char* text) { + std::vector b; + while (*text) { unsigned n; assert(sscanf(text, "%2x", &n) == 1); b.push_back(n); text += 2; } + return b; +} +static void print(const uint8_t* p, size_t n) { while (n--) printf("%02x", *p++); puts(""); } +int main() { + // RFC 5297 Appendix A.1 (real AES, never the repository's no-op AES mock). + auto k = hex("fffefdfcfbfaf9f8f7f6f5f4f3f2f1f0f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"); + auto ad = hex("101112131415161718191a1b1c1d1e1f2021222324252627"); + auto plain = hex("112233445566778899aabbccddee"); auto p = plain; + uint8_t tag[16]; assert(seal(k.data(), ad.data(), ad.size(), p.data(), p.size(), tag)); + assert(equal(tag, hex("85632d07c6e8f37f950acd320a2ecc93").data(), 16)); + assert(p == hex("40c02b9690c4dc04daef7f6afe5c")); + assert(open(k.data(), ad.data(), ad.size(), p.data(), p.size(), tag)); assert(p == plain); + for (size_t n : {size_t(0), size_t(1), size_t(15), size_t(16), size_t(17), size_t(78), size_t(177)}) { + std::vector b(n, 0x39), original = b; + assert(seal(k.data(), ad.data(), ad.size(), b.data(), n, tag)); + assert(open(k.data(), ad.data(), ad.size(), b.data(), n, tag)); assert(b == original); + assert(seal(k.data(), ad.data(), ad.size(), b.data(), n, tag)); tag[4] ^= 1; + assert(!open(k.data(), ad.data(), ad.size(), b.data(), n, tag)); + for (uint8_t c : b) assert(c == 0); + } + assert(!seal(k.data(), ad.data(), ad.size(), p.data(), 178, tag)); + History history; + history.sample(0, 3900, -4); history.sample(59, 3100, 98); + assert(history.week().voltage == 3100 && history.week().high == temperature(98)); + history.sample(3600, 3800, 28); + history.advance(169 * 3600); + assert(history.week().voltage == 3800 && history.period.voltage == 3100); + history.advance(400 * 3600); assert(history.week().voltage == 0); + assert(temperature(NAN) == 0 && temperature(-51) == 252 && temperature(201) == 253); + Schedule schedule; + assert(Schedule::validDirect(5) && Schedule::validDirect(90)); + assert(!Schedule::validDirect(4) && !Schedule::validDirect(91)); + assert(Schedule::validFlood(21) && Schedule::validFlood(90)); + assert(!Schedule::validFlood(20) && !Schedule::validFlood(91)); + schedule.advance(5 * DAY); assert(schedule.flood == 16 * DAY); + schedule.reserve(false, 5, 21, 0); assert(schedule.flood == 16 * DAY); + schedule.advance(16 * DAY); assert(!schedule.direct && !schedule.flood); + schedule.reserve(true, 5, 21, 0); assert(schedule.flood == 21 * DAY); + assert(schedule.direct == 5 * DAY); + schedule.direct = 4 * DAY; schedule.flood = 0; + schedule.reserve(true, 5, 21, 0); assert(schedule.direct == 4 * DAY); + schedule.reserve(true, 5, 90, 0); assert(schedule.flood == 90 * DAY); + Schedule restored = schedule; restored.advance(90 * DAY); assert(!restored.flood); + uint8_t root[32], radio[16] = {}, admin[32] = {}, token[12], other[12]; + passwordKey("management test password", root); + fingerprint(root, radio, admin, token); radio[0] = 1; + fingerprint(root, radio, admin, other); assert(!equal(token, other, 12)); + AclList acl; assert(acl.add(token, ADMIN)); assert(acl.add(token, OTA_SIGNER)); + assert(acl.count == 1 && acl.entries[0][12] == 3); + for (uint8_t i = 1; i < MAX_KEYS; ++i) { token[0] = i; assert(acl.add(token, ADMIN)); } + token[0] = 240; assert(!acl.add(token, ADMIN)); assert(acl.pages() == MAX_PAGES); + // Emit cross-language packets with all header fields and maximum ACL pages. + for (uint8_t page = 0; page < acl.pages(); ++page) { + uint8_t raw[MAX_PAYLOAD] = {}, enc[32]; memcpy(raw, "MGR1", 4); memcpy(raw + 4, radio, 16); + write32(raw + 20, 42); write32(raw + 28, 0x01110105); write16(raw + 76, FIRMWARE); + raw[71] = 5; raw[78] = page; raw[79] = acl.pages(); raw[80] = acl.count; + raw[81] = page * PER_PAGE; raw[82] = PER_PAGE; + memcpy(raw + HEADER, acl.entries[page * PER_PAGE], PER_PAGE * ENTRY); + deriveKey(root, "MeshCore-MGR1-SIV", radio, enc); + assert(seal(enc, raw, HEADER, raw + HEADER, PER_PAGE * ENTRY, raw + MAX_PAYLOAD - TAG)); + assert(validPage(raw, sizeof(raw))); print(raw, sizeof(raw)); + raw[81]++; assert(!validPage(raw, sizeof(raw))); raw[81]--; + assert(!validPage(raw, sizeof(raw) - 1)); raw[80] = 37; assert(!validPage(raw, sizeof(raw))); + } + for (size_t n = 0; n < HEADER + TAG; ++n) assert(!validPage(plain.data(), n)); +} diff --git a/test/fixtures/management/runtime_test.cpp b/test/fixtures/management/runtime_test.cpp new file mode 100644 index 00000000..5b997f8a --- /dev/null +++ b/test/fixtures/management/runtime_test.cpp @@ -0,0 +1,130 @@ +#include "ManagementReporter.h" +#include "CommonCLI.h" +#include +#include +#include +#include +using namespace mesh; +using namespace mesh::management; +struct Fixture { + MemoryFS fs; + Mesh mesh; + MainBoard board; + SensorManager sensors; + ClientACL acl; + NodePrefs prefs; + CommonCLI cli; + CommonCLICallbacks callbacks; + std::unique_ptr reporter; + Fixture() { test_millis = 0; reboot(); } + void reboot() { reporter.reset(); test_millis = 0; reporter.reset(new ManagementReporter(mesh, board, sensors, acl, prefs, callbacks, cli, &fs)); } + std::string cmd(const char* text, bool ok = true) { + char b[200], reply[160] = {}; strcpy(b, text); + assert(reporter->command(b, reply, sizeof(reply))); + if (!strncmp(text, "set ", 4) && ((!strncmp(reply, "OK", 2)) != ok)) { + fprintf(stderr, "%s: %s\n", text, reply); assert(false); + } + if (!strncmp(text, "set mgmt.password ", 18) && strlen(text + 18) >= 12) assert(b[18] == 0); + return reply; + } + void advance(uint32_t seconds) { + while (seconds) { const uint32_t step = seconds < 60 ? seconds : 60; test_millis += step * 1000; + reporter->loop(); seconds -= step; + } + } + void configure(bool direct = true) { + cmd("set mgmt.enabled on", false); + cmd("set mgmt.password short", false); + cmd("set mgmt.password management test password"); + if (direct) cmd("set mgmt.interval 5"); + cmd("set mgmt.enabled on"); + // Simulate a route becoming unavailable after enable so fallback-only + // tests exercise the scoped 21-day safety report. + if (!direct) cli.path_len = 0xff; + const auto& file = fs.files["/management"]; + assert(std::string(file.begin(), file.end()).find("management test password") == std::string::npos); + } +}; +int main() { + { + Fixture f; + assert(f.cmd("get mgmt").find("direct=5d") != std::string::npos); + assert(f.cmd("get mgmt").find("flood=21d") != std::string::npos); + f.advance(100 * DAY); assert(f.mesh.packets.empty()); + f.configure(); f.cmd("set mgmt.interval 91", false); + f.advance(5 * DAY - 60); assert(f.mesh.packets.empty()); + // Changes to RTC in either direction have no effect on flood eligibility. + f.mesh.rtc.time = 0; f.advance(60); assert(f.mesh.packets.size() == 1 && !f.mesh.packets[0].flood); + f.mesh.rtc.time = 0xffffffff; f.reboot(); + f.advance(5 * DAY + 3600); assert(f.mesh.packets.size() == 2); + for (const auto& p : f.mesh.packets) assert(!p.flood); + } + { + Fixture f; f.configure(); f.advance(21 * DAY + 601); + assert(f.mesh.packets.size() == 5 && !f.mesh.packets[0].flood && f.mesh.packets.back().flood); + const uint32_t seq = read32(f.mesh.packets.back().payload + 20); f.reboot(); f.advance(600); + assert(f.mesh.packets.size() == 5); + f.cmd("set mgmt.enabled off"); f.cmd("set mgmt.enabled on"); f.advance(600); + assert(f.mesh.packets.size() == 5 && read32(f.mesh.packets.back().payload + 20) == seq); + } + { + Fixture f; f.configure(); f.cmd("set mgmt.flood off"); + f.advance(21 * DAY + 3600); assert(f.mesh.packets.size() == 4); + for (const auto& p : f.mesh.packets) assert(!p.flood); + } + { + Fixture f; f.configure(); f.cmd("set mgmt.direct off"); + f.advance(21 * DAY + 60); assert(f.mesh.packets.size() == 1); + assert(f.mesh.packets[0].flood); + } + { + Fixture f; f.configure(); f.cmd("set mgmt.direct 10"); + f.cmd("set mgmt.flood 30"); f.advance(30 * DAY + 60); + assert(f.mesh.packets.size() == 3 && f.mesh.packets.back().flood); + assert(!f.mesh.packets[0].flood && !f.mesh.packets[1].flood); + } + { + Fixture f; f.configure(false); f.advance(21 * DAY - 60); f.fs.fail_write = true; f.advance(120); + assert(f.mesh.packets.empty()); assert(f.cmd("get mgmt").find("FAULT") != std::string::npos); + // Last hourly checkpoint was one hour before the failed reservation. + f.fs.fail_write = false; f.reboot(); f.advance(3600); assert(f.mesh.packets.size() == 1); + } + { + Fixture f; f.configure(false); f.fs.files["/management"][72] ^= 1; f.reboot(); f.advance(100 * DAY); + assert(f.mesh.packets.empty()); assert(f.cmd("get mgmt").find("FAULT") != std::string::npos); + } + { + Fixture f; f.configure(false); f.fs.fail_read_open = true; f.reboot(); f.advance(100 * DAY); + assert(f.mesh.packets.empty()); + } + { + Fixture f; + for (unsigned i = 0; i < 36; ++i) { ClientInfo c; c.id.pub_key[0] = i; f.acl.clients.push_back(c); } + f.configure(false); f.advance(21 * DAY + 600); assert(f.mesh.packets.size() == 6); + for (unsigned i = 0; i < 6; ++i) { + const auto& p = f.mesh.packets[i]; assert(validPage(p.payload, p.payload_len)); + assert(p.payload[78] == i && p.payload[80] == 36 && p.flood); + } + } + { + Fixture f; + for (unsigned i = 0; i < 7; ++i) { ClientInfo c; c.id.pub_key[0] = i; f.acl.clients.push_back(c); } + f.configure(false); f.advance(21 * DAY); assert(f.mesh.packets.size() == 1); + f.board.voltage = 2900; f.board.temperature = 60; f.advance(60); + assert(f.mesh.packets.size() == 2); + // The low reading was not in the first snapshot and must survive into the next one. + f.board.voltage = 3740; f.board.temperature = 24; f.advance(21 * DAY + 3600); + assert(f.mesh.packets.size() == 4); + assert(read16(f.mesh.packets.back().payload + 66) == 2900); + assert(f.mesh.packets.back().payload[69] == temperature(60)); + } + { + Fixture f; f.configure(false); f.mesh.temp = true; f.advance(22 * DAY); assert(f.mesh.packets.empty()); + f.mesh.temp = false; f.mesh.budget = 0; f.advance(60); assert(f.mesh.packets.empty()); + f.mesh.budget = 1000; f.advance(60); assert(f.mesh.packets.size() == 1); + } + { + Fixture f; f.configure(false); f.cmd("set mgmt.flood 90"); f.advance(89 * DAY); assert(f.mesh.packets.empty()); + f.advance(DAY); assert(f.mesh.packets.size() == 1); + } +} diff --git a/test/test_gps_transport_ownership/test_gps_transport_ownership.cpp b/test/test_gps_transport_ownership/test_gps_transport_ownership.cpp index a1090b35..4c8b9aed 100644 --- a/test/test_gps_transport_ownership/test_gps_transport_ownership.cpp +++ b/test/test_gps_transport_ownership/test_gps_transport_ownership.cpp @@ -15,6 +15,10 @@ public: void setTransportAvailable(bool available) { setGpsTelemetryTransportAvailable(available); } + void setLocationAccessAvailable(bool available) { + setTelemetryLocationAccessAvailable(available); + } + void service(unsigned long now) { loopGpsTelemetry(now); } bool receiverRequired(unsigned long now) const { return gpsTelemetryReceiverRequired(now); } @@ -75,6 +79,30 @@ TEST(GpsTransportOwnership, UserPreferenceSurvivesBlockAndRestartsOnRelease) { EXPECT_EQ(2, sensors.starts); } +TEST(GpsTransportOwnership, UserOffCancelsTelemetryAcquisitionAndHold) { + resetArduinoMock(); + TestGpsSensorManager sensors; + CayenneLPP telemetry(64); + + sensors.setLocationAccessAvailable(true); + EXPECT_FALSE(sensors.queryLocation(telemetry)); + ASSERT_TRUE(sensors.active); + ASSERT_TRUE(sensors.receiverRequired(millis())); + + // This is the path used by `gps off`/`set gps off`. It must immediately + // override a telemetry acquisition and the request's two-hour hold. + sensors.setUserEnabled(false); + EXPECT_FALSE(sensors.active); + EXPECT_FALSE(sensors.receiverRequired(millis())); + EXPECT_EQ(1, sensors.stops); + + // Location-access policy may still be enabled, but the scheduled refresh + // must not immediately turn the receiver back on in the next loop. + sensors.service(millis()); + EXPECT_FALSE(sensors.active); + EXPECT_EQ(1, sensors.starts); +} + TEST(GpsTransportOwnership, LastGoodCacheSurvivesTemporaryUartOwnership) { resetArduinoMock(); TestGpsSensorManager sensors; diff --git a/test/test_management_report.py b/test/test_management_report.py new file mode 100644 index 00000000..37d55897 --- /dev/null +++ b/test/test_management_report.py @@ -0,0 +1,118 @@ +"""Production crypto vs RFC vector + independent Python decoder, no AES mocks.""" +from pathlib import Path +import importlib.util +import os +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("management", ROOT / "tools/management/report.py") +report = importlib.util.module_from_spec(spec) +spec.loader.exec_module(report) + + +class ManagementTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + candidates = [Path(os.environ.get("MESHCORE_CRYPTO_DIR", "/nonexistent"))] + candidates += sorted((ROOT / ".pio/libdeps").glob("*/Crypto")) + cls.crypto = next((p for p in candidates if (p / "AES128.cpp").is_file()), None) + if cls.crypto is None: + raise RuntimeError("Install rweather/Crypto 0.4.0 or set MESHCORE_CRYPTO_DIR (no mock fallback)") + cls.work = tempfile.TemporaryDirectory() + cls.exe = Path(cls.work.name) / "management-test" + sources = ["AES128.cpp", "AESCommon.cpp", "BlockCipher.cpp", "Crypto.cpp", "SHA256.cpp", "Hash.cpp"] + host_define = [] if os.name == "nt" else ["-DHOST_BUILD"] + sanitizers = [] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"] + cmd = [shutil.which("g++") or "g++", "-std=c++17", "-O1", "-g", "-Wall", "-Wextra"] + host_define + [ + *sanitizers, "-I", str(cls.crypto), "-I", str(ROOT / "src"), + str(ROOT / "src/helpers/ManagementReport.cpp"), + str(ROOT / "test/fixtures/management/protocol_test.cpp")] + cmd += [str(cls.crypto / p) for p in sources] + ["-o", str(cls.exe)] + subprocess.run(cmd, check=True, capture_output=True, text=True) + result = subprocess.run([str(cls.exe)], check=True, capture_output=True, text=True) + cls.pages = [bytes.fromhex(line) for line in result.stdout.splitlines()] + # Compile the unchanged production runtime and transaction writer with + # a memory filesystem/radio in place of the hardware-facing headers. + fixture = ROOT / "test/fixtures/management" + for name in ("ManagementReporter.cpp", "ManagementReporter.h", "FileRead.h", "ContactFileTransaction.h", "PersistentStoreFormat.h"): + shutil.copyfile(ROOT / "src/helpers" / name, Path(cls.work.name) / name) + for name in ("CommonCLI.h", "Mesh.h"): + shutil.copyfile(fixture / name, Path(cls.work.name) / name) + shutil.copyfile(ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h", Path(cls.work.name) / "IdentityStore.h") + runtime = Path(cls.work.name) / "runtime-test" + runtime_cmd = [shutil.which("g++") or "g++", "-std=c++17", "-O1", "-g"] + host_define + ["-DRP2040_PLATFORM", + *sanitizers, "-I", cls.work.name, + "-I", str(cls.crypto), "-I", str(ROOT / "src/helpers"), "-I", str(ROOT / "src"), + str(Path(cls.work.name) / "ManagementReporter.cpp"), str(ROOT / "src/helpers/ManagementReport.cpp"), + str(fixture / "runtime_test.cpp")] + runtime_cmd += [str(cls.crypto / p) for p in sources] + ["-o", str(runtime)] + subprocess.run(runtime_cmd, check=True, capture_output=True, text=True) + cls.runtime = runtime + + @classmethod + def tearDownClass(cls): + cls.work.cleanup() + + def test_interoperable_full_report(self): + decoded = report.decode_report(list(reversed(self.pages)), "management test password") + self.assertEqual(decoded["sequence"], 42) + self.assertEqual(len(decoded["acl"]), 36) + self.assertEqual(decoded["firmware_version"], "1.17.1.5") + self.assertNotIn("manifest_id", decoded) + + def test_runtime_storage_scheduling_rollover_and_failures(self): + subprocess.run([str(self.runtime)], check=True) + + def test_tampering_and_wrong_password(self): + for position in (4, 20, 28, 74, 76, 83, -1): + changed = bytearray(self.pages[0]); changed[position] ^= 1 + with self.subTest(position=position), self.assertRaises(ValueError): + report.decode_report([bytes(changed)] + self.pages[1:], "management test password") + with self.assertRaises(ValueError): + report.decode_report(self.pages, "incorrect management password") + + def test_page_omission_duplicate_mixed(self): + for pages in ([], self.pages[:-1], self.pages[1:] + [self.pages[1]], self.pages + [self.pages[0]]): + with self.assertRaises(ValueError): + report.decode_report(pages, "management test password") + + def test_fingerprints_and_password_length(self): + a = report.fingerprint("management test password", bytes(16), bytes(32)) + b = report.fingerprint("management test password", bytes([1]) + bytes(15), bytes(32)) + self.assertEqual(len(a), 12); self.assertNotEqual(a, b) + for password in ("short", "x" * 97): + with self.assertRaises(ValueError): report.password_key(password) + + def test_companions_excluded(self): + for role in ("simple_repeater/MyMesh", "simple_room_server/MyMesh", "simple_sensor/SensorMesh"): + self.assertIn("_cli.beginManagement(*this, _fs)", (ROOT / "examples" / (role + ".cpp")).read_text()) + for path in (ROOT / "examples/companion_radio").glob("*.*"): + if path.suffix in (".cpp", ".h"): self.assertNotIn("beginManagement", path.read_text()) + + def test_mqtt_paths_scopes_and_duplicate_uplinks(self): + messages = [] + for route in range(4): + for page in self.pages: + wire = bytes([0x3c | route]) + (bytes(4) if route in (0, 3) else b"") + wire += bytes([0x42]) + bytes.fromhex("1234abcd") + page + message = dict(type="PACKET", direction="rx", raw=wire.hex()) + self.assertEqual(report.mqtt_payload(message), page) + messages.extend([message, message]) + decoded = report.mqtt_reports(messages, "management test password") + self.assertEqual(len(decoded), 1) + self.assertEqual(len(decoded[0]["acl"]), 36) + self.assertIsNone(report.mqtt_payload(dict(type="PACKET", direction="tx", raw=wire.hex()))) + self.assertIsNone(report.mqtt_payload(dict(type="PACKET", direction="rx", raw="3dc0"))) + self.assertEqual(report.mqtt_reports(messages[:2], "management test password"), []) + for page in self.pages: + padded = page + bytes(3 + ((len(page) - 3 + 15) // 16) * 16 - len(page)) + message = dict(type="PACKET", direction="rx", raw=(b"\x19\x00" + padded).hex()) + self.assertEqual(report.mqtt_payload(message), page) + message["raw"] = (b"\x19\x00" + padded[:-1] + b"\x01").hex() + self.assertIsNone(report.mqtt_payload(message)) + + +if __name__ == "__main__": unittest.main() diff --git a/test/test_trace_retry/test_trace_retry.cpp b/test/test_trace_retry/test_trace_retry.cpp index 93da274a..bbba0bfc 100644 --- a/test/test_trace_retry/test_trace_retry.cpp +++ b/test/test_trace_retry/test_trace_retry.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include class TraceTestClock : public mesh::MillisecondClock { @@ -166,6 +167,43 @@ static mesh::Packet makeFloodPacket(uint8_t payload_type) { return packet; } +TEST(ManagementRouting, GroupDataForBothRoutesKeepsPublicHeaderAndLegacyLength) { + TraceTestRadio radio; TraceTestClock clock; TraceTestRNG rng; TraceTestRTC rtc; + TraceTestTables tables; StaticPoolPacketManager pool(8); + TraceTestMesh mesh(radio, clock, rng, rtc, pool, tables); + uint8_t raw[mesh::management::HEADER + mesh::management::TAG] = {}; + memcpy(raw, "MGR1", 4); raw[79] = 1; + const uint8_t path[] = {0x12, 0xab}; + uint8_t scope_key[16]; memset(scope_key, 0x42, sizeof(scope_key)); + for (bool flood : {false, true}) { + auto* p = mesh.createRawData(raw, sizeof(raw)); ASSERT_NE(nullptr, p); + ASSERT_TRUE(mesh.sendManagementData(p, flood, path, 2, 2, + flood ? scope_key : nullptr)); + EXPECT_EQ(PAYLOAD_TYPE_GRP_DATA, p->getPayloadType()); + EXPECT_EQ(flood, p->isRouteFlood()); + EXPECT_EQ(0, memcmp(p->payload, raw, sizeof(raw))); + EXPECT_EQ(0u, (p->payload_len - 3u) % 16u); + EXPECT_TRUE(mesh::management::validPage(p->payload, p->payload_len, true)); + EXPECT_EQ(flood ? 0 : 2, p->getPathHashCount()); + if (!flood) EXPECT_EQ(0, memcmp(p->path, path, 2)); + } +} + +TEST(ManagementRouting, FloodForwardingDoesNotNeedAKeyAndStillHonorsFilters) { + TraceTestRadio radio; TraceTestClock clock; TraceTestRNG rng; TraceTestRTC rtc; + TraceTestTables tables; StaticPoolPacketManager pool(8); + TraceTestMesh mesh(radio, clock, rng, rtc, pool, tables); + mesh.forwardFloods = true; + mesh::Packet p; + p.header = (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT) | ROUTE_TYPE_FLOOD; + p.setPathHashSizeAndCount(1, 0); memcpy(p.payload, "MGR1", 4); p.payload[79] = 1; + p.payload_len = mesh::management::floodSize(mesh::management::HEADER + mesh::management::TAG); + EXPECT_NE(ACTION_RELEASE, mesh.receivePacket(&p)); + EXPECT_FALSE(mesh.groupPacketObserved); // never delivered as decrypted channel data + mesh.rejectFloods = true; + EXPECT_EQ(ACTION_RELEASE, mesh.receivePacket(&p)); +} + class RetryCodingRateRadio : public TraceTestRadio { public: uint8_t cr = 5; diff --git a/tools/management/report.py b/tools/management/report.py new file mode 100644 index 00000000..f1bea854 --- /dev/null +++ b/tools/management/report.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Decode and authenticate complete MGR1 management reports. + +Input is a JSON list of raw payload hex strings, one entry for each page (not +MeshCore packet headers/paths). A relay/collector can obtain these from RX logs +or the companion raw-data notification. +""" +import argparse +import getpass +import hashlib +import hmac +import json +import struct +from pathlib import Path + +HEADER, ENTRY, PER_PAGE, TAG, MAX_KEYS = 83, 13, 6, 16, 36 + + +def password_key(password): + encoded = password.encode("utf-8") + if not 12 <= len(encoded) <= 96: + raise ValueError("password must contain 12..96 UTF-8 bytes") + return hashlib.sha256(b"#" + encoded).digest() + + +def derive(key, domain, radio): + return hmac.digest(key, domain.encode("ascii") + radio, "sha256") + + +def fingerprint(password, radio, administrator): + if len(radio) != 16 or len(administrator) != 32: + raise ValueError("radio ID must be 16 bytes; administrator key must be 32 bytes") + key = derive(password_key(password), "MeshCore-MGR1-ACL", radio) + return hmac.digest(key, radio + administrator, "sha256")[:12] + + +def _temperature(value): + if value == 0: + return None + if value == 252: + return "below -50 C" + if value == 253: + return "above 200 C" + if value > 253: + raise ValueError("reserved temperature value") + return value - 51 + + +def _extrema(raw): + voltage = int.from_bytes(raw[:2], "little") + return dict(min_voltage_mv=voltage or None, min_temperature_c=_temperature(raw[2]), + max_temperature_c=_temperature(raw[3])) + + +def decode_page(payload, password): + from Crypto.Cipher import AES # PyCryptodome; independent AES-SIV implementation + if not HEADER + TAG <= len(payload) <= 177 or payload[:4] != b"MGR1": + raise ValueError("invalid management payload") + page, pages, total, first, count = payload[78:83] + expected = max(1, (total + PER_PAGE - 1) // PER_PAGE) + if (total > MAX_KEYS or pages != expected or page >= pages or + first != page * PER_PAGE or first > total or + count != min(PER_PAGE, total - first) or len(payload) != HEADER + count * ENTRY + TAG): + raise ValueError("invalid management page bounds") + key = derive(password_key(password), "MeshCore-MGR1-SIV", payload[4:20]) + cipher = AES.new(key, AES.MODE_SIV) + cipher.update(payload[:HEADER]) + private = cipher.decrypt_and_verify(payload[HEADER:-TAG], payload[-TAG:]) + entries = [] + for i in range(count): + token = private[i * ENTRY:i * ENTRY + 12] + flags = private[i * ENTRY + 12] + if not flags or flags & ~3: + raise ValueError("invalid ACL role flags") + entries.append(dict(fingerprint=token.hex(), admin=bool(flags & 1), ota_signer=bool(flags & 2))) + return entries + + +def mqtt_payload(message): + """Extract an MGR1 payload from the observer's MQTT PACKET/raw JSON. + + Path length is encoded, not simply a byte count. Region transport codes + precede it. Never trust the redundant MQTT payload_len/type fields. + """ + if not isinstance(message, dict) or message.get("direction", "rx") != "rx": + return None + raw = message.get("raw") if message.get("type") == "PACKET" else message.get("data") + if not isinstance(raw, str): + return None + try: + packet = bytes.fromhex(raw) + except ValueError: + return None + if not 2 <= len(packet) <= 255 or packet[0] >> 6 or (packet[0] >> 2) & 15 not in (6, 15): + return None + position = 5 if packet[0] & 3 in (0, 3) else 1 + if position >= len(packet): + return None + path_len = packet[position] + width, count = (path_len >> 6) + 1, path_len & 63 + if width > 3 or width * count > 64: + return None + start = position + 1 + width * count + payload = packet[start:] + if (packet[0] >> 2) & 15 == 6: + if len(payload) < HEADER + TAG or payload[:4] != b"MGR1": + return None + canonical = HEADER + payload[82] * ENTRY + TAG + padded = 3 + ((canonical - 3 + 15) // 16) * 16 + if len(payload) != padded or any(payload[canonical:]): + return None + payload = payload[:canonical] + return payload if payload[:4] == b"MGR1" else None + + +def mqtt_reports(messages, password): + """Group a saved MQTT capture, deduplicating copies heard by many uplinks. + + A conflicting page fails closed. Only fully authenticated complete reports + are returned. + """ + snapshots = {} + for message in messages: + payload = mqtt_payload(message) + if payload is None: + continue + decode_page(payload, password) + identity = payload[4:24] + pages = snapshots.setdefault(identity, {}) + previous = pages.get(payload[78]) + if previous is not None and previous != payload: + raise ValueError("conflicting MQTT copies for one management page") + pages[payload[78]] = payload + results = [] + for pages in snapshots.values(): + first = next(iter(pages.values())) + if len(pages) == first[79]: + results.append(decode_report(list(pages.values()), password)) + return results + + +def decode_report(payloads, password): + if not payloads or len(payloads) > 6: + raise ValueError("one through six pages required") + # Check every page before indexing metadata, then reject omissions, + # duplicates, mixed snapshots and unauthenticated public fields. + decoded = [(p, decode_page(p, password)) for p in payloads] + decoded.sort(key=lambda item: item[0][78]) + first = decoded[0][0] + if len(decoded) != first[79] or [p[78] for p, _ in decoded] != list(range(first[79])): + raise ValueError("incomplete or duplicate report pages") + if any(p[:78] != first[:78] or p[79:81] != first[79:81] for p, _ in decoded): + raise ValueError("mixed report snapshots") + fields = struct.unpack_from("> n) & 255) for n in (24, 16, 8, 0)) + result = dict(radio_id=radio.hex(), sequence=sequence, timestamp=timestamp, + firmware_version=version(firmware) if valid & 1 else None, + bootloader_version=version(bootloader) if valid & 2 else None, + target_id=f"{target:08x}" if valid & 4 else None, + base_hash=first[40:48].hex() if valid & 4 else None, + image_length=int.from_bytes(first[48:52], "little") if valid & 4 else None, + staging_capacity=int.from_bytes(first[52:56], "little") if valid & 8 else None, + ota_capabilities=int.from_bytes(first[56:60], "little"), + uptime_hours=int.from_bytes(first[60:62], "little"), + weekly=_extrema(first[62:66]), since_report=_extrema(first[66:70]), + history_hours=first[70], interval_days=first[71], role=first[72], + capability_bits=first[73], active_bits=first[74], known_bits=first[75], + partial_week=bool(valid & 16), partial_period=bool(valid & 32), + temperature_source="MCU" if valid & 64 else "unknown", + acl=[entry for _, entries in decoded for entry in entries]) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("pages", type=Path, help="JSON array of raw payload hex strings") + parser.add_argument("--match-admin", help="full public key to match against private ACL fingerprints") + parser.add_argument("--mqtt", action="store_true", help="input is JSON array or JSONL of saved MQTT uplinks") + args = parser.parse_args() + password = getpass.getpass("Management password: ") + try: + text = args.pages.read_text() + if args.mqtt: + messages = json.loads(text) if text.lstrip().startswith("[") else [json.loads(line) for line in text.splitlines() if line.strip()] + print(json.dumps(mqtt_reports(messages, password), indent=2)) + return + report = decode_report([bytes.fromhex(p) for p in json.loads(text)], password) + if args.match_admin: + match = fingerprint(password, bytes.fromhex(report["radio_id"]), bytes.fromhex(args.match_admin)).hex() + report["matching_acl"] = [entry for entry in report["acl"] if entry["fingerprint"] == match] + except (ValueError, TypeError, KeyError) as exc: + parser.error(str(exc)) + print(json.dumps(report, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/management/requirements.txt b/tools/management/requirements.txt new file mode 100644 index 00000000..77c42ff1 --- /dev/null +++ b/tools/management/requirements.txt @@ -0,0 +1 @@ +pycryptodome==3.23.0