Add secure host CLI bridge and harden OTA workflows

Expose the Full Companion terminal over TCP and add an authenticated, replay-resistant host command service with documented CPU temperature, reboot, and allowlisted program examples.\n\nImprove LoRa OTA diagnostics and persistent CLI handling, reduce clock correction drift to ten minutes, and make mOTA stream flush behavior an explicit transport policy with regression coverage.
This commit is contained in:
mikecarper committed 2026-08-23 15:29:30 -07:00
1 parent 142d166252
commit 9ed9e9d746
46 files changed
+4364 -359

No files matched your search

+25 -4
View File
@@ -196,6 +196,20 @@ saved credentials take priority. With no credentials, its WebConfig portal
starts in setup-AP mode. With credentials, the WebUI is enabled by default on
the station IP.
Full Companion can also be provisioned from its USB terminal or TCP port 5002:
```text
get wifi.ssid
get wifi.status
set wifi.ssid SlowFi
set wifi.pwd your-password
```
Credential writes are persisted immediately. After the reply has drained, the
Companion restarts its WiFi station with the saved pair. A port-5002 client is
expected to disconnect and can reconnect at the new LAN IP. USB masks the
password as it is entered; `get wifi.pwd` is intentionally unavailable.
If the configured network remains unavailable for two minutes, the companion
opens its setup AP so the credentials can be repaired. It continues retrying
the saved network. WiFi modem sleep has its own persisted `wifi.powersave`
@@ -232,12 +246,19 @@ RXPS on at level 8 with a 16-symbol preamble, and WiFi modem power saving at
When `ENABLE_OTA` is included, a WiFi companion also listens on:
- TCP 5001 for the OTA folder seeder used by `motatool serve --tcp`;
- TCP 5002 for the OTA text console.
- TCP 5002 for a text management console.
These ports do not replace the companion protocol on TCP 5000.
On a `companion_radio_full` build, port 5002 additionally accepts bounded
`tempradio`, `normalradio`, and `get/set wifi.powersave` commands, while LoRa
staging and installation on the Companion itself are disabled.
On a `companion_radio_full` build, port 5002 is the same role-specific text
terminal available over USB, including chat, remote administration, radio and
power settings, WiFi/WebConfig management, `tempradio`, and the source-only
`ota` commands. Other
OTA-enabled Companion builds keep the bounded `ota ...` console. LoRa staging
and installation on the Full Companion itself remain disabled.
Port 5002 is plaintext and has no independent login gate. Use it only on a
trusted LAN or temporary setup network, especially when entering a remote-node
admin password with the terminal's `login` command.
FULL ESP32 builds share the port 5001 folder seeder. It starts whenever that
role has a usable WiFi station or setup access point and stops when WiFi stops.
+3 -2
View File
@@ -92,8 +92,9 @@ does not exist on that target:
family require an ESP32 WebConfig build. FULL standalone repeater and
room-server builds support the corresponding WiFi setters and status
commands. ESP32 WiFi Companion exposes `wifi.powersave` in its WebConfig
form and binary protocol; Full Companion also exposes the text getter and
setter on its USB terminal and TCP port 5002.
form and binary protocol; Full Companion exposes its complete role-specific
text terminal over USB and TCP port 5002, including WiFi credentials,
connection status, WebConfig, CLI-tab, and power-save controls.
- MQTT commands require an MQTT observer target.
- `discover.scopes` requires an MQTT observer with compiled neighbor support;
it does not independently require PSRAM or the FULL parser.
+4
View File
@@ -53,6 +53,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| Operational | [`erase`](cli_commands.md#erasefactory-reset) | Local serial | Serial | Serial | Serial |
| Information | [`ver`](cli_commands.md#get-the-version) | All text CLI roles | Yes | Yes | Yes |
| Information | [`board`](cli_commands.md#show-the-hardware-name) | All text CLI roles | Yes | Yes | Yes |
| Diagnostics | [`get storage.layout`](cli_commands.md#show-the-storage-layout) | Internal layout and configured QSPI or SD storage | Yes | Yes | Yes |
| Diagnostics | [`memory`](#memory) | ESP32 only | No | No | No |
| Diagnostics | [`sensor`](#sensor-hardware-summary) | Hardware wiring summary | Yes | Yes | Yes |
| Diagnostics | [`powerlog`](#powerlog) | Reset, shutdown, and boot-voltage summary | Yes | Yes | Yes |
@@ -87,6 +88,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| System | [`get/set owner.info`](cli_commands.md#view-or-change-this-nodes-owner-info) | All text CLI roles | Yes | Yes | Yes |
| System | [`get/set adc.multiplier`](cli_commands.md#fine-tune-the-battery-reading) | Board ADC override support | Feature | Feature | Feature |
| System | [`send text.flood <message>`](cli_commands.md#send-a-repeater-flood-text) | Repeater | Yes | Yes | Yes |
| System | [`host <text>`; `get host`](cli_commands.md#ask-a-usb-connected-host-service-over-lora-repeater-only) | Repeater with host bridge; `host` requires a remote administrator and a USB/MQTT service | Yes | Yes | Yes |
| System | [`get/set battery.alert`; `get battery.alert.region`](cli_commands.md#view-or-change-battery-alert-state) | Repeater | Yes | Yes | Yes |
| System | [`get/set battery.alert.low`; `get/set battery.alert.critical`](cli_commands.md#view-or-change-battery-alert-thresholds) | Repeater | Yes | Yes | Yes |
| System | [`get/set rx.watchdog`](cli_commands.md#enable-or-disable-the-rx-inactivity-watchdog-repeater-only) | Repeater | Yes | Yes | Yes |
@@ -213,6 +215,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| Operational | [`erase`](cli_commands.md#erasefactory-reset) | Local serial | Serial | Serial | Serial | Serial | Serial |
| Information | [`ver`](cli_commands.md#get-the-version) | All text CLI roles | Yes | Yes | Yes | Yes | Yes |
| Information | [`board`](cli_commands.md#show-the-hardware-name) | All text CLI roles | Yes | Yes | Yes | Yes | Yes |
| Diagnostics | [`get storage.layout`](cli_commands.md#show-the-storage-layout) | Detected flash size and live partition table | Yes | Yes | Yes | Yes | Yes |
| Diagnostics | [`memory`](#memory) | ESP32 heap and PSRAM summary | Yes | Yes | Yes | Yes | Yes |
| Diagnostics | [`sensor`](#sensor-hardware-summary) | Hardware wiring summary | Yes | Yes | Yes | Yes | Yes |
| Diagnostics | [`powerlog`](#powerlog) | Reset-reason summary | Yes | Yes | Yes | Yes | Yes |
@@ -247,6 +250,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| System | [`get/set owner.info`](cli_commands.md#view-or-change-this-nodes-owner-info) | Full parser | Yes | Yes | Yes | Yes | Yes |
| System | [`get/set adc.multiplier`](cli_commands.md#fine-tune-the-battery-reading) | Board ADC override support | Feature | Feature | Feature | Feature | Feature |
| System | [`send text.flood <message>`](cli_commands.md#send-a-repeater-flood-text) | Repeater | Yes | Yes | Yes | Yes | Yes |
| System | [`host <text>`; `get host`](cli_commands.md#ask-a-usb-connected-host-service-over-lora-repeater-only) | Repeater with host bridge; `host` requires a remote administrator and a USB/MQTT service | Yes | Yes | Yes | Yes | Yes |
| System | [`get/set battery.alert`; `get battery.alert.region`](cli_commands.md#view-or-change-battery-alert-state) | Repeater | Yes | Yes | Yes | Yes | Yes |
| System | [`get/set battery.alert.low`; `get/set battery.alert.critical`](cli_commands.md#view-or-change-battery-alert-thresholds) | Repeater | Yes | Yes | Yes | Yes | Yes |
| System | [`get/set rx.watchdog`](cli_commands.md#enable-or-disable-the-rx-inactivity-watchdog-repeater-only) | Repeater | Yes | Yes | Yes | Yes | Yes |
+62 -4
View File
@@ -107,6 +107,38 @@ arguments such as node names, passwords, and keys is left unchanged.
---
### Ask a USB-connected host service over LoRa (Repeater Only)
**Usage:**
- `host <text>` - authenticated remote administrator only
- `get host` - show bridge state and byte limits
`host` hands one remote LoRa CLI request to a service on the repeater's USB
host and returns that service's reply over LoRa. It does not execute the text
inside the firmware. The service has 4 seconds to prove the exact request is
still pending, then 6 seconds to execute and reply. A second remote command
receives busy while either phase is pending.
Request text is limited to 155 UTF-8 bytes, or 152 bytes when a companion uses
its legacy three-byte correlation prefix. The complete LoRa reply is limited to
162 bytes. USB records use Base64URL text, a random 64-bit nonce, and the
repeater identity's Ed25519 signature. Before executing anything, the service
must return a random one-time challenge and receive the repeater's signed live
proof. This avoids any dependency on repeater/Pi clock agreement. Every
`host.reply` USB command must carry the matching request ID and nonce; it is not
accepted from LoRa or the Ethernet CLI.
The included Raspberry Pi endpoint supports exact commands for `help`,
`cpu-temp`, `hostname`, `uptime`, `load`, `memory`, and `disk-free`. It also
provides `reboot` as an opt-in action example and `run <alias> [arguments]` for
locally allowlisted executables with typed arguments. Reboot is disabled by
default, arbitrary executables are never accepted, and no action uses a shell.
See [LoRa CLI host service](host_cli_service.md) for the complete
`meshcoretomqtt` setup and security model.
---
### Start or stop an Over-The-Air (OTA) firmware update
**Usage:**
- `start ota`
@@ -214,8 +246,12 @@ take effect on the next connection. `get wifi.pwd` is intentionally unavailable
so the standalone password is never returned by the CLI.
ESP32 WiFi Companion WebConfig exposes the same `wifi.powersave` values in its
WiFi card. Full Companion also accepts `get/set wifi.powersave` from its USB
terminal and TCP port 5002. Binary Companion clients can use command bytes
WiFi card. Full Companion exposes the same role-specific text terminal over
USB and TCP port 5002, including the standalone `wifi.ssid`, `wifi.status`,
`wifi.powersave`, `wifi.cli`, and WebConfig command families. Credential
writes reply before restarting the WiFi station, so a TCP client should expect
to reconnect at the new address; USB password input is masked. Binary
Companion clients can use command bytes
`0x46` and `0x47` over USB, BLE, or TCP port 5000 without the terminal-start
token. WiFi-only Companions accept all three modes; Full Companion rejects
`none` because its simultaneous BLE transport requires modem sleep. Companion
@@ -539,6 +575,28 @@ It also does not change the node-storage capture controlled by `log start` and
---
### Show the storage layout
**Usage:** `get storage.layout`
Reports a compact, read-only summary of the storage compiled into the running
firmware:
- ESP32 reports the detected internal flash size and the live partition table.
Partition addresses after `@` are hexadecimal, sizes after `+` are KiB, and
`*` marks the running application. `...` means later entries did not fit in
the CLI reply.
- nRF52 reports the physical internal flash size, linked application range,
InternalFS range, and any configured external store. Raw QSPI OTA storage
includes its detected size and JEDEC ID; SD storage includes total, used, and
free space.
- RP2040 and STM32 report their fixed internal flash and filesystem geometry.
The command does not write, format, or resize storage. A raw-QSPI or SD build
may briefly probe, wake, or mount its configured external storage to read its
capacity. Remote use follows the existing administrator-command permissions.
---
## Configuration
### Radio
@@ -953,7 +1011,7 @@ send text.flood checking ridge link
- `clock.sync.mesh`: `on` for all repeater, sensor, and room-server builds
- `clock.sync.mesh.edge`: `on`
- `clock.sync.internet`: `off`
- `clock.sync.drift`: `3600` seconds
- `clock.sync.drift`: `600` seconds (10 minutes)
- `clock.sync.samples`: `9`
When either source is enabled, the node makes its first clock-bootstrap
@@ -1092,7 +1150,7 @@ replays until corrected time passes the previously observed value.
**Example:**
```text
set clock.sync.drift 1800
set clock.sync.drift 600
set clock.sync.samples 3
set clock.sync.mesh on
set clock.sync.mesh.edge on
+37 -9
View File
@@ -51,7 +51,7 @@ Heltec `_femon` Companion firmware can switch the external FEM receive gain at
runtime, so the Companion bulk-build commands omit the redundant legacy
`_femoff` targets. Those targets remain available through an explicit
`build-firmware` command for compatibility. In WebConfig, use the **FEM RX
boost** switch. From the USB terminal, use:
boost** switch. From the text terminal (USB, or TCP 5002 on ESP32), use:
```text
get radio.rxgain
@@ -71,7 +71,7 @@ are applied immediately and retained after reboot. FEM TX gain is reported as
unsupported on boards without software-selectable PA gain.
Device power saving is separate from LoRa RXPS. It can be changed in WebConfig
with the **Device power saving** switch or from the USB terminal:
with the **Device power saving** switch or from the text terminal:
```text
powersaving
@@ -80,15 +80,28 @@ powersaving off
```
On ESP32, WiFi modem power saving is a third independent setting. Select it in
the WebConfig **WiFi** card, or use the Full Companion USB terminal or TCP port
5002:
the WebConfig **WiFi** card, or use the Full Companion text terminal:
```text
get wifi.ssid
get wifi.status
get wifi.powersave
get wifi.cli
get webui
set wifi.ssid MyNetwork
set wifi.pwd my-password
set wifi.powersave min
set wifi.powersave max
set wifi.cli on
start webconfig
stop webconfig
```
SSID and password writes return their reply first, then restart the Companion
WiFi station with the saved credentials. A TCP terminal therefore disconnects
shortly after either write; reconnect to the IP reported by the new network.
The password is write-only and is masked while it is entered over USB.
The normal binary Companion connection can also read or write this setting over
USB, BLE, or TCP port 5000 without entering terminal mode. The saved values are
`0` for `min`, `1` for `none`, and `2` for `max`; see the
@@ -96,7 +109,7 @@ USB, BLE, or TCP port 5000 without entering terminal mode. The saved values are
`none` because simultaneous WiFi and BLE require modem sleep. Fresh Cascade
builds select `min`, and an existing saved selection takes precedence.
On radios with RX duty-cycle support, WebConfig and the USB terminal also
On radios with RX duty-cycle support, WebConfig and the text terminal also
expose the persisted RXPS setting:
```text
@@ -172,7 +185,7 @@ itself.
| ESP32 | TCP 5000 | Binary Companion over WiFi |
| ESP32 | HTTP 80 | Companion WebConfig and first-boot WiFi setup |
| ESP32 | TCP 5001 | Host `.mota` folder from `motatool serve --tcp` |
| ESP32 | TCP 5002 | Local `ota`, `tempradio`, `normalradio`, and `wifi.powersave` console |
| ESP32 | TCP 5002 | Full Companion text terminal; same role commands as the USB terminal |
| nRF52 | USB mOTA mode | Host `.mota` folder from `motatool serve --serial` |
Delivery-required replies are returned only to the interface which supplied the
@@ -201,7 +214,7 @@ ESP32 ports 5000, 5001, 5002, and WebConfig have no independent login layer.
Expose them only on a trusted LAN or temporary setup network. See
[WiFi setup](./WiFi.md) for credential setup and reconnect behavior.
## USB Binary and terminal modes
## USB Binary and text terminal modes
USB starts in Binary mode for MeshCore apps and `meshcli`:
@@ -228,9 +241,24 @@ The terminal supports Companion chat commands, including `channels`,
`login <admin-password>` and `cmd <remote-command>`, and routed
`trace [recipient-name-or-prefix]`, plus local `ota`, `tempradio`, and
`normalradio` controls. ESP32 builds also provide local
`get/set wifi.powersave`. Logging artifacts additionally provide session-only
WiFi credential, status, WebConfig, CLI-tab, and power-save controls. Logging
artifacts additionally provide session-only
`get/set usb.logging`; turning it off suppresses live USB diagnostics without
disabling Companion frames or terminal replies. For example:
disabling Companion frames or terminal replies.
ESP32 Full Companion exposes this same text terminal on TCP port 5002. Connect
with `nc DEVICE_IP 5002`; no USB control token is needed. USB terminal mode and
the TCP terminal share recipient, login, command, trace, and display state, so
only one may own the terminal at a time. Entering USB terminal mode closes an
active TCP terminal session. Disconnecting TCP clears pending terminal-only
state without cancelling Binary Companion delivery or radio retries.
Port 5002 is plaintext and has no device-local login gate. A remote-admin
password entered with `login` is sent across the LAN connection as typed even
though the terminal does not echo it. Use port 5002 only on a trusted LAN or a
temporary setup network.
For example:
```text
channels
+1 -1
View File
@@ -90,7 +90,7 @@ set flood.retry.ignore none
| `clock.sync.mesh` | Defaults on for all repeater, sensor, and room-server builds; a saved setting overrides that default. It estimates UTC as soon as the configured number of fresh signed-advert or valid Public-channel sources is collected, with a 30-minute bootstrap/retry timer when evidence is still insufficient, then repeats lazily seven days after each successful estimate. New evidence retriggers evaluation after a no-consensus result. `clock.sync.mesh now` queues an immediate LoRa-only attempt without bypassing quorum or source suppression. Only timestamps from firmware build time through build time plus ten years are recorded. Successful CLI, GPS, or WiFi/NTP clock updates suppress LoRa time collection until reboot; after reboot LoRa is the fallback if NTP cannot sync. Status reports the reason a clock was not set; its `.table` and `.1` through `.16` forms inspect collected samples. | `get clock.sync.mesh`, `set clock.sync.mesh <on|off>`, `clock.sync.mesh now`, `get clock.sync.status[.table|.1-.16]` | `set clock.sync.mesh on` |
| `clock.sync.mesh.edge` | Defaults on so edge infrastructure nodes can collect clock evidence when all packets arrive through one relay path. Verified evidence is observed before the forwarding decision, so disabled forwarding and forwarding filters do not prevent collection. Signed adverts are deduplicated by public key and Public-channel timestamps by case-insensitive display name; all may share one receive path. Public display names are unauthenticated and can be spoofed. Changing this setting clears current clock samples. | `get clock.sync.mesh.edge`, `set clock.sync.mesh.edge <on|off>` | `set clock.sync.mesh.edge on` |
| `clock.sync.internet` | Adds a read-only internet/NTP estimate at the same initial and seven-day checks on WiFi MQTT repeater-observer builds. Other builds retain the setting but report internet unavailable. | `get clock.sync.internet`, `set clock.sync.internet <on|off>` | `set clock.sync.internet on` |
| `clock.sync.drift` | Absolute correction threshold in seconds. The clock is moved forward or backward only when the estimate differs by more than this value. | `get clock.sync.drift`, `set clock.sync.drift <30-86400>` | `set clock.sync.drift 3600` |
| `clock.sync.drift` | Absolute correction threshold in seconds; the default is 600 seconds (10 minutes). The clock is moved forward or backward only when the estimate differs by more than this value. | `get clock.sync.drift`, `set clock.sync.drift <30-86400>` | `set clock.sync.drift 600` |
| `clock.sync.samples` | Minimum fresh evidence count before mesh time can be used: distinct receive paths in normal mode or distinct advert keys/Public-channel display names in edge mode. A strict majority of all fresh samples is also required. Range `3-16`; default `9`. | `get clock.sync.samples`, `set clock.sync.samples <3-16>` | `set clock.sync.samples 9` |
| `outpath` | Overrides the primary direct route used for replies to the current remote client. | `get outpath`, `set outpath <hops>`, `set outpath direct`, `set outpath clear`, `set outpath flood` | `set outpath A1B2C3,D4E5F6` |
| `altpath` | Adds a secondary direct route for repeater replies to the current remote client. | `get altpath`, `set altpath <hops>`, `set altpath direct`, `set altpath clear`, `set altpath flood` | `set altpath 71CE82,BA09F0` |
+182
View File
@@ -0,0 +1,182 @@
# LoRa CLI Host Service
The repeater `host <text>` command lets an authenticated LoRa administrator
request a small, explicitly allowed operation from a USB-connected computer.
The included Raspberry Pi endpoint supports these exact requests:
```text
cmd host help
cmd host cpu-temp
cmd host hostname
cmd host uptime
cmd host load
cmd host memory
cmd host disk-free
cmd host reboot
cmd host run <alias> [arguments]
```
The first six actions after `help` are read-only. `reboot` is an opt-in action
example and is disabled unless the endpoint is started with `--allow-reboot`.
Text such as `reboot now`, `cpu-temp; reboot`, and embedded newlines is not a
command: the endpoint accepts only an exact allowlist match.
The bridge is included in normal repeater firmware. The specialized Wio-E5
RS232 bridge image omits it because the RS232 application already fills its
fixed 240 KiB application partition; use the normal Wio-E5 repeater image when
the USB/MQTT host service is needed.
## Run alongside meshcoretomqtt
`meshcoretomqtt` must remain the only process that opens the repeater USB serial
port. The endpoint communicates with it through the same MQTT broker, which can
be local to the Raspberry Pi:
```text
LoRa -> repeater -> USB -> meshcoretomqtt -> MQTT -> host endpoint
LoRa <- repeater <- USB <- meshcoretomqtt <- MQTT <- host endpoint
```
Launch `meshcoretomqtt` with its existing arguments plus `--debug`; its current
debug-topic parser needs that flag to publish the repeater request record. Do
not open the serial TTY from the host endpoint too.
This feature does not compare the repeater wall clock with the Pi clock.
Minutes of drift, an unset repeater clock, and later clock corrections are safe:
request freshness comes from a live one-time challenge. The normal
`meshcoretomqtt` `sync_time` setting may remain enabled for its other uses, but
host-command authorization does not depend on it.
Generate a dedicated service key using the Python environment installed by
`meshcoretomqtt`:
```sh
sudo /opt/mctomqtt/venv/bin/python3 host_cli_service.py \
--generate-key /etc/mctomqtt/host-cli-key.json
sudo chown mctomqtt:mctomqtt /etc/mctomqtt/host-cli-key.json
sudo chmod 600 /etc/mctomqtt/host-cli-key.json
```
Add the printed public key to the existing `meshcoretomqtt` configuration:
```toml
[remote_serial]
enabled = true
allowed_companions = [
"SERVICE_PUBLIC_KEY_PRINTED_ABOVE"
]
nonce_ttl = 120
command_timeout = 10
```
Restart `meshcoretomqtt`, then run the endpoint. Replace `USA` with the exact
three-character IATA namespace configured in the broker and supply the
repeater's complete 64-character public key:
```sh
sudo -u mctomqtt /opt/mctomqtt/venv/bin/python3 host_cli_service.py \
--broker 127.0.0.1 \
--iata USA \
--repeater-key REPEATER_PUBLIC_KEY \
--service-key /etc/mctomqtt/host-cli-key.json
```
Add `--username NAME --password-file FILE` for broker authentication. Add
`--tls`, and optionally `--ca-cert FILE`, for TLS. Custom installations can use
`--request-topic` and `--command-topic` to match their broker namespace. The
endpoint source and its systemd/configuration details are in
[`examples/host_cli_service`](https://github.com/mikecarper/MeshCore/tree/keymindCascade/examples/host_cli_service).
## Allowlisted programs and arguments
Add `--programs-file FILE` to expose locally selected programs as
`host run <alias> [arguments]`. The example JSON file fixes each executable,
fixed leading arguments, maximum 1-5 second runtime, and the exact remote
argument schema. For example:
```text
cmd host run fan on 15
```
The example maps that request to the fixed process argument vector:
```text
/usr/local/bin/mesh-fan-control --source lora on 15
```
The supported argument rules are an explicit non-option `choice`, a bounded
nonnegative `integer`, or a short `token` using a restricted ASCII character
set. Alias lookup and every argument validation happen before process creation.
Execution uses an absolute configured path, no stdin, `shell=False`, a minimal
environment, and `/` as its working directory. Extra arguments, leading-option
injection, shell syntax, control characters, and invalid quoting are rejected.
The allowlist file and executable must not be group- or world-writable.
Copy and edit
[`programs.example.json`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/examples/host_cli_service/programs.example.json),
then give the service account only the operating-system permissions needed by
those trusted programs.
## Opt-in reboot example
To enable only the exact `host reboot` action, give the service account narrow
permission in `/etc/sudoers.d/meshcore-host-cli`:
```text
mctomqtt ALL=(root) NOPASSWD: /usr/bin/systemctl reboot
```
Set that file to mode `440`, test it locally, and add `--allow-reboot` to the
endpoint command. Its default response is `OK - host reboot scheduled in 5s`.
The delay can be set from 3 through 60 seconds with `--reboot-delay`.
The implementation always invokes the fixed argument vector
`/usr/bin/sudo -n /usr/bin/systemctl reboot`; no LoRa text is placed in a shell,
path, or process argument. Do not grant the service account a wildcard sudo
rule.
## Trust and injection controls
The MQTT broker transports records but does not establish their authenticity.
The endpoint checks the configured repeater identity and verifies its Ed25519
signature over the complete request ID, random nonce, and Base64URL request. It
also validates framing, UTF-8, and byte limits. It does not execute that first
record.
Instead, the endpoint stores the request in memory, creates a random 64-bit
challenge, and sends `@claim=<random>` through `meshcoretomqtt`'s signed serial
channel. The repeater accepts it only from physical USB while the exact ID and
nonce are pending, then signs a `CLAIMED` proof containing the challenge. Only
a matching live proof lets the endpoint perform allowlist matching and execute
the action. It consumes the proof before execution, so MQTT redelivery cannot
execute an action twice.
A captured request only causes a new challenge that an idle repeater refuses.
A captured proof does not match a new challenge, and restarting the endpoint
forgets pending challenges. Repeater/Pi clock drift therefore cannot turn an
old `reboot` or `run` record into a valid action. The reply and claim commands
still use short-lived JWTs; `meshcoretomqtt` checks their signer allowlist,
target, expiration, signature, and separate replay nonce. If the endpoint and
`meshcoretomqtt` are on different computers, those two host clocks must be
compatible for JWT validation. They naturally share a clock when both run on
the same Pi.
Newlines and other reply control characters are converted to spaces and are
also independently rejected by the firmware parser.
Protect the service private key and use broker ACLs that allow it to subscribe
only to the selected repeater debug topic and publish only to that repeater
serial-command topic. A private key listed in `allowed_companions` is trusted to
sign serial requests.
## Limits
- The request is at most 155 UTF-8 bytes after `host `, or 152 bytes with the
legacy three-byte companion correlation prefix.
- The complete LoRa reply is at most 162 UTF-8 bytes.
- Only one host request can be pending per repeater.
- The live claim must complete within 4 seconds; after it is accepted, the
program and reply have 6 seconds.
- `host.reply` is accepted only through physical USB, not LoRa or Ethernet.
Use `cmd get host` to report the bridge state and limits.
+1
View File
@@ -7,6 +7,7 @@ Below are a few quick start guides.
- [Frequently Asked Questions](./faq.md)
- [Firmware Picker](./firmware_picker.md)
- [CLI Commands](./cli_commands.md)
- [LoRa CLI Host Service](./host_cli_service.md)
- [Filter Policy Playground](./filter_tool.md)
- [Telemetry Decoder](./telemetry_decoder.md)
- [CLI Availability by Firmware Build](./cli_build_matrix.md)
+8 -2
View File
@@ -343,8 +343,11 @@ An ESP32 `companion_radio_full` uses WiFi for both dedicated source links:
```
Port 5002 defaults automatically when it is omitted from
`--source-cli-tcp`. The source-only Companion never stages or installs the
image itself; it streams the host folder to other nodes over LoRa. See the
`--source-cli-tcp`. Current Full Companion firmware exposes its complete
role-specific text terminal there; the runner uses only `ota`, `tempradio`,
and `normalradio`, and remains compatible with older bounded port-5002
consoles. The source-only Companion never stages or installs the image itself;
it streams the host folder to other nodes over LoRa. See the
[full Companion guide](./companion_radio_full.md) for manual operation and
interface details.
@@ -412,6 +415,9 @@ Useful controls:
restores both values before the relay leaves TempRadio.
- `--work-dir PATH` chooses a new, non-existent work directory.
- `--meshcli PATH` and `--motatool PATH` select binaries not on `PATH`.
- `--debug` prints redacted child commands, timeouts, process status, stdout,
and stderr. Admin passwords are never printed, but node names, addresses,
paths, and command replies can still be sensitive; share the log carefully.
For offline package preparation only:
+3 -2
View File
@@ -134,8 +134,9 @@ motatool serve --dir ./motas --tcp <source-host>:5001 -v
Port `5001` is separate from the companion application port (`5000`) and the
HTTP configuration/browser-OTA port (`80`, depending on the role). An ESP32
`companion_radio_full` also has a local OTA/TempRadio console on port `5002`;
see the [full Companion guide](./companion_radio_full.md). On a FULL repeater or room
`companion_radio_full` also has its complete role-specific text terminal on
port `5002`; OTA automation uses its `ota` and TempRadio subset. See the
[full Companion guide](./companion_radio_full.md). On a FULL repeater or room
server, `start webconfig` can bring up the saved WiFi connection. Other FULL
roles with browser OTA support can raise `MeshCore-OTA` with `start ota` and
use `192.168.4.1:5001`. The TCP seeder auto-attaches; do not also run
+31
View File
@@ -11,6 +11,37 @@ repeater's public key. Match that 16-character hex value against the public-key
prefix recorded for your repeaters. It comes from the telemetry payload itself,
so it is available even when only the payload was copied.
## Send raw telemetry packets
From an administrator CLI session, first configure a direct route to the MQTT
observer that will receive and upload the raw packets. Use `direct` when the
observer is a zero-hop neighbor:
```text
set telemetry.tx direct
send telemetry.tx now
```
For a routed observer, provide its comma-separated hop hashes instead:
```text
set telemetry.tx 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:
```text
set telemetry.tx schedule off
send telemetry.tx now
```
The `send telemetry.tx now` command queues one `TTB1` temperature packet and
one `TVB1` voltage packet containing all currently available samples, up to 165
per packet. There is no temperature-only CLI command. The command requires at
least one collected sample and works even when the automatic schedule is off.
## Decode a packet
<div class="telemetry-tool" data-telemetry-decoder>
+4
View File
@@ -17,6 +17,10 @@ enum class CompanionWiFiPowerSaveResult : uint8_t {
bool toggleCompanionWiFi();
bool isCompanionWiFiEnabled();
// Reload saved credentials after a text-terminal update. The reconnect is
// deferred so the command reply can leave USB/TCP before WiFi is restarted.
void scheduleCompanionWiFiCredentialReload();
// Companion WiFi power save is stored in the shared mesh-wifi NVS namespace.
// The returned mode is always the effective mode: WiFi+BLE builds map a stale
// or default "none" value to "min" because modem sleep is required for radio
File diff suppressed because it is too large. Load diff
+10
View File
@@ -164,6 +164,11 @@ public:
void exitTerminalMode();
bool isTerminalMode() const { return _terminal_mode; }
void handleTerminalCommand(char* command);
#if defined(COMPANION_RADIO_FULL)
bool enterNetworkTerminalMode(Stream& output);
void exitNetworkTerminalMode(Stream& output);
bool isNetworkTerminalMode(const Stream& output) const;
#endif
#endif
#if defined(COMPANION_RADIO_FULL)
@@ -302,6 +307,10 @@ private:
void syncWiFiPowerSaving();
#endif
#ifdef ENABLE_USB_INTERFACE
Stream& terminalOutput();
bool hasTerminalOutput() const { return _terminal_output != NULL; }
void resetTerminalSession();
void printTerminalBanner(bool show_binary_stop);
ContactInfo* getTerminalRecipient();
void printTerminalPath(const ContactInfo& recipient);
void handleTerminalPath(ContactInfo& recipient, const char* path_spec);
@@ -374,6 +383,7 @@ private:
bool _cli_rescue;
#ifdef ENABLE_USB_INTERFACE
bool _terminal_mode;
Stream* _terminal_output;
mesh::TerminalDisplayFilter _terminal_display;
bool _terminal_recipient_set;
uint8_t _terminal_recipient_key[PUB_KEY_SIZE];
+114 -25
View File
@@ -284,11 +284,10 @@ static void clearUsbTerminalLine() {
}
static void printUsbTerminalInputEcho() {
const char* password = nullptr;
const char* password =
mesh::cli::terminalPasswordInput(usb_terminal_line);
size_t visible_len = usb_terminal_line_len;
if (mesh::cli::parseTerminalArgumentCommand(
usb_terminal_line, "login", password)
== mesh::cli::TerminalArgumentCommandMatch::Valid) {
if (password != nullptr) {
visible_len = static_cast<size_t>(password - usb_terminal_line);
}
@@ -515,6 +514,8 @@ void halt() {
static bool companion_wifi_active = false;
static bool companion_wifi_disable_in_progress = false;
static bool companion_wifi_services_stopped = false;
static bool companion_wifi_credential_reload_pending = false;
static unsigned long companion_wifi_credential_reload_at = 0;
static bool companion_wifi_power_save_loaded = false;
static uint8_t companion_wifi_power_save = mesh::wifi::kDefaultPowerSave;
@@ -612,6 +613,15 @@ void halt() {
}
}
void scheduleCompanionWiFiCredentialReload() {
loadCompanionWiFiCredentials();
companion_wifi_credential_reload_pending = true;
companion_wifi_credential_reload_at = millis() + 1500UL;
if (companion_wifi_credential_reload_at == 0) {
companion_wifi_credential_reload_at = 1;
}
}
bool isCompanionWiFiEnabled() {
return companion_wifi_requested;
}
@@ -693,11 +703,10 @@ void halt() {
}
#endif
/* WIFI OTA CONSOLE - a tiny text CLI for OTA over WiFi. Connect with e.g. `nc <ip> 5002` and type
`ota status` / `ota ls` / `ota announce` / ... - one client at a time, on a DEDICATED port separate from
the companion (5000) and the seeder (5001). Full companions also accept `tempradio ...`,
`normalradio`, and WiFi power-save commands here so a host can manage the source without
occupying the binary port. */
/* WIFI TEXT CONSOLE - one client at a time on a dedicated port, separate from
Binary Companion (5000) and the mOTA seeder (5001). A Full Companion exposes
the same role CLI here as its USB terminal. Other OTA-enabled Companion
builds retain the bounded `ota ...` management console. */
#if defined(ESP32) && defined(WIFI_SSID) && defined(ENABLE_OTA)
#include <helpers/ota/OtaCli.h> // mesh::ota::handle_ota_command(line, reply, board)
#include <helpers/esp32/WiFiOtaSeeder.h>
@@ -706,50 +715,103 @@ void halt() {
#endif
static WiFiServer ota_console_server(OTA_CONSOLE_TCP_PORT);
static WiFiClient ota_console_client;
static char ota_console_line[128];
static uint8_t ota_console_len = 0;
static char ota_console_line[MAX_TRANS_UNIT * 2 + 32];
static size_t ota_console_len = 0;
static bool ota_console_discard_line = false;
static void ota_console_clear_line() {
memset(ota_console_line, 0, sizeof(ota_console_line));
ota_console_len = 0;
}
static void ota_console_start() {
ota_console_server.begin();
#if defined(COMPANION_RADIO_FULL) && defined(ENABLE_USB_INTERFACE)
WIFI_DEBUG_PRINTLN("Full Companion terminal listening on :%d (nc <ip> %d)",
OTA_CONSOLE_TCP_PORT, OTA_CONSOLE_TCP_PORT);
#else
WIFI_DEBUG_PRINTLN("OTA console listening on :%d (nc <ip> %d, type `ota ...`)",
OTA_CONSOLE_TCP_PORT, OTA_CONSOLE_TCP_PORT);
#endif
}
static void ota_console_stop() {
#if defined(COMPANION_RADIO_FULL) && defined(ENABLE_USB_INTERFACE)
the_mesh.exitNetworkTerminalMode(ota_console_client);
#endif
if (ota_console_client) ota_console_client.stop();
ota_console_server.end();
ota_console_len = 0;
ota_console_clear_line();
ota_console_discard_line = false;
}
static void ota_console_loop() {
if (!ota_console_client || !ota_console_client.connected()) {
WiFiClient c = ota_console_server.available();
if (c) { ota_console_client = c; ota_console_len = 0;
ota_console_client.print("OTA console - type `ota ...`");
#if defined(COMPANION_RADIO_FULL)
ota_console_client.print(" or `tempradio freq,bw,sf,cr,minutes`");
ota_console_client.print("; `get/set wifi.powersave`");
#if defined(COMPANION_RADIO_FULL) && defined(ENABLE_USB_INTERFACE)
the_mesh.exitNetworkTerminalMode(ota_console_client);
#endif
ota_console_client.print("\r\n> "); }
WiFiClient c = ota_console_server.available();
if (c) {
ota_console_client = c;
ota_console_clear_line();
ota_console_discard_line = false;
#if defined(COMPANION_RADIO_FULL) && defined(ENABLE_USB_INTERFACE)
if (!the_mesh.enterNetworkTerminalMode(ota_console_client)) {
ota_console_client.print(
"ERROR: USB currently owns the Full Companion terminal\r\n");
ota_console_client.stop();
}
#else
ota_console_client.print("OTA console - type `ota ...`\r\n> ");
#endif
}
return;
}
#if defined(COMPANION_RADIO_FULL) && defined(ENABLE_USB_INTERFACE)
if (!the_mesh.isNetworkTerminalMode(ota_console_client)) {
ota_console_client.print(
"\r\nERROR: terminal ownership moved to USB; closing\r\n");
ota_console_client.stop();
ota_console_clear_line();
ota_console_discard_line = false;
return;
}
#endif
while (ota_console_client.available()) {
char ch = (char)ota_console_client.read();
if (ota_console_discard_line) {
if (ch == '\r' || ch == '\n') {
ota_console_discard_line = false;
ota_console_client.print(" ERROR: command too long\r\n> ");
}
continue;
}
if (ch == '\r' || ch == '\n') {
if (ota_console_len == 0) continue; // ignore blanks / the CRLF pair
ota_console_line[ota_console_len] = 0;
char reply[160]; reply[0] = 0;
#if defined(COMPANION_RADIO_FULL)
if (!the_mesh.handleFullOtaCommand(ota_console_line, reply, sizeof(reply)))
strcpy(reply, "supported: ota ... | tempradio ... | normalradio | get/set wifi.powersave");
#if defined(COMPANION_RADIO_FULL) && defined(ENABLE_USB_INTERFACE)
if (strcmp(ota_console_line, "disconnect") == 0) {
ota_console_client.print(" OK - disconnecting\r\n");
the_mesh.exitNetworkTerminalMode(ota_console_client);
ota_console_client.stop();
ota_console_clear_line();
ota_console_discard_line = false;
return;
}
the_mesh.handleTerminalCommand(ota_console_line);
ota_console_client.print("> ");
#else
char reply[160]; reply[0] = 0;
if (!mesh::ota::handle_ota_command(ota_console_line, reply, board))
strcpy(reply, "only `ota ...` commands are supported on this console");
#endif
ota_console_client.print(" -> "); ota_console_client.print(reply); ota_console_client.print("\r\n> ");
ota_console_len = 0;
#endif
ota_console_clear_line();
} else if (ota_console_len < sizeof(ota_console_line) - 1) {
ota_console_line[ota_console_len++] = ch;
} else {
ota_console_clear_line();
ota_console_discard_line = true;
}
}
}
@@ -763,6 +825,32 @@ void halt() {
wifi_setup_recovery_mode = false;
}
static void serviceCompanionWiFiCredentialReload() {
if (!companion_wifi_credential_reload_pending
|| static_cast<int32_t>(millis() - companion_wifi_credential_reload_at) < 0) {
return;
}
#ifdef WITH_WEBCONFIG
if (the_mesh.isWebConfigActiveOrStopping()) return;
#endif
companion_wifi_credential_reload_pending = false;
companion_wifi_credential_reload_at = 0;
loadCompanionWiFiCredentials();
resetCompanionWiFiRecoveryState();
if (!companion_wifi_requested || !companion_wifi_active
|| !companion_wifi_has_credentials) {
return;
}
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.disconnect(false, false);
WiFi.begin(configured_wifi_ssid, configured_wifi_password);
wifi_reconnect_tracker.noteAttempt(millis());
WIFI_DEBUG_PRINTLN("WiFi credentials reloaded; reconnecting to saved SSID");
}
static void startCompanionWiFi() {
if (companion_wifi_active) return;
@@ -1226,6 +1314,7 @@ void loop() {
#ifdef WITH_WEBCONFIG
the_mesh.serviceWebConfig();
#endif
serviceCompanionWiFiCredentialReload();
serviceCompanionWiFiState();
if (companion_wifi_requested && companion_wifi_active) {
#ifdef ENABLE_OTA
+230
View File
@@ -0,0 +1,230 @@
# LoRa CLI host service example
This example lets an authenticated remote administrator ask a computer attached
to a repeater to perform a small, explicitly allowed operation. The included
service implements this fixed allowlist:
```text
host help
host cpu-temp
host hostname
host uptime
host load
host memory
host disk-free
host reboot
host run <alias> [arguments]
```
The status commands read bounded operating-system APIs or virtual files. The
service never invokes a shell and does not accept arbitrary commands,
file paths, or executable names. Locally configured program aliases can accept
strictly validated arguments. Host reboot is disabled by default.
Normal repeater firmware includes the host bridge. The specialized Wio-E5
RS232 bridge image omits it to stay within that board's fixed 240 KiB
application partition; use the normal Wio-E5 repeater image for this service.
## Using it with meshcoretomqtt
`meshcoretomqtt` remains the only process that opens the repeater's USB serial
port. The data path is:
```text
LoRa admin -> repeater -> USB -> meshcoretomqtt -> MQTT broker
MQTT broker -> host endpoint -> signed remote-serial MQTT -> meshcoretomqtt
meshcoretomqtt -> USB -> repeater -> LoRa reply
```
Two processes must not read the same serial TTY. The MQTT endpoint avoids that
race and can run on the same Pi as the broker and `meshcoretomqtt`, or on a
different host.
The current `meshcoretomqtt` release publishes repeater `DEBUG` records only
when it is launched with `--debug`. Add `--debug` to its existing systemd
`ExecStart` command and restart it. Keep any existing `--config` arguments.
This publishes other firmware debug records too, so use broker ACLs and do not
expose the debug topic publicly.
The host-command authorization does not compare the repeater clock with the Pi
clock. Several minutes of drift, an unset repeater wall clock, or a later clock
correction cannot make an old request executable. Freshness comes from the
live one-time challenge described below. The normal `meshcoretomqtt`
`sync_time` setting can remain enabled for its other uses, but this feature does
not depend on it.
Generate a dedicated service signing key with the Python environment already
installed by `meshcoretomqtt`:
```sh
sudo /opt/mctomqtt/venv/bin/python3 host_cli_service.py \
--generate-key /etc/mctomqtt/host-cli-key.json
sudo chown mctomqtt:mctomqtt /etc/mctomqtt/host-cli-key.json
sudo chmod 600 /etc/mctomqtt/host-cli-key.json
```
The command prints the public key. Add only that public key to the existing
`meshcoretomqtt` configuration:
```toml
[remote_serial]
enabled = true
allowed_companions = [
"SERVICE_PUBLIC_KEY_PRINTED_ABOVE"
]
nonce_ttl = 120
command_timeout = 10
```
The name `allowed_companions` comes from `meshcoretomqtt`; a dedicated service
identity works because it uses the same Ed25519 token format. Restart
`mctomqtt` after changing the file.
Run the endpoint against the same broker and topic namespace. Replace `USA`
with the exact three-character IATA value used by that broker and replace the
repeater key with the repeater's complete 64-hex-character public key:
```sh
sudo -u mctomqtt /opt/mctomqtt/venv/bin/python3 host_cli_service.py \
--broker 127.0.0.1 \
--iata USA \
--repeater-key REPEATER_PUBLIC_KEY \
--service-key /etc/mctomqtt/host-cli-key.json
```
For a password-protected broker, add `--username NAME --password-file FILE`.
For TLS, add `--tls`, and optionally `--ca-cert FILE`. If the debug or serial
command topic is customized in `meshcoretomqtt`, pass the matching
`--request-topic` or `--command-topic` value.
To enable the exact `host reboot` request, give only `systemctl reboot` to the
service account in `/etc/sudoers.d/meshcore-host-cli`:
```text
mctomqtt ALL=(root) NOPASSWD: /usr/bin/systemctl reboot
```
Set that file to mode `440`, then add `--allow-reboot` to the endpoint command.
The endpoint publishes `OK - host reboot scheduled in 5s`, waits for the MQTT
publish, and then runs the fixed argv
`/usr/bin/sudo -n /usr/bin/systemctl reboot` after five seconds. Change the
delay from 3 through 60 seconds with `--reboot-delay`; no LoRa text is ever
inserted into the process invocation. Do not add a broad sudo wildcard.
## Allowlisted programs with arguments
Pass `--programs-file FILE` to expose selected local programs as
`host run <alias> [arguments]`. Start with `programs.example.json`, replace its
example executable paths with installed programs, and keep the file owned by a
trusted local account. The service rejects a group- or world-writable allowlist
or executable.
Each entry fixes the executable and any leading arguments in `argv`. Its remote
arguments have an exact count and one of three validators:
- `choice` accepts only one explicitly listed value;
- `integer` accepts a canonical nonnegative decimal value inside `min` and
`max`;
- `token` accepts a bounded ASCII token that starts with a letter or digit and
contains only letters, digits, `_`, `.`, `:`, `@`, `+`, `,`, or `-`.
For the example `fan` entry, the remote command is:
```text
cmd host run fan on 15
```
It always executes this argument vector, without a shell:
```text
/usr/local/bin/mesh-fan-control --source lora on 15
```
An alias cannot select another executable. Extra arguments, option-shaped
tokens such as `--help`, invalid choices, out-of-range integers, shell syntax,
control characters, and malformed quoting are rejected before a process is
started. Program timeouts are limited to 1-5 seconds so the reply fits inside
the repeater's 10-second host-service deadline. The selected local program is
still trusted code; give its service account only the operating-system
permissions that program needs.
From a companion's authenticated remote CLI, select and log into the repeater,
then run:
```text
cmd host help
cmd host cpu-temp
```
Use `cmd get host` to view the bridge limits and whether a request is waiting.
## Injection and replay protection
The broker is transport, not the trust boundary. The endpoint accepts a request
only when all of these checks pass:
- the LoRa caller was authenticated by the repeater and has administrator
permission;
- the MQTT `origin_id` is the configured repeater identity;
- the complete protocol record has a valid Ed25519 signature from that
repeater;
- the request contains the pending 32-bit ID and a fresh random 64-bit nonce;
- Base64URL framing, UTF-8, and byte limits are valid;
- a new random 64-bit service challenge is returned through the signed serial
channel and the repeater signs proof that the exact request is still pending;
- only after that live proof does the decoded request reach the fixed built-in
or locally configured program allowlist.
For `run`, the request must additionally match a locally configured alias and
every argument validator. Execution uses a fixed absolute executable,
`shell=False`, a fixed minimal environment, no stdin, and `/` as its working
directory.
The initial signed request never executes an action. The endpoint first stores
it in memory and sends `@claim=<random>` with the same ID and nonce. The
firmware accepts that claim only through physical USB while that exact LoRa
request is pending, then emits a signed `CLAIMED` proof containing the random
value. The endpoint removes the proof before running the action, making action
execution at most once even if MQTT redelivers a record.
A captured request can at most make the endpoint issue a new challenge; a
repeater with no matching live request will not sign it. A captured `CLAIMED`
record cannot match the endpoint's new random challenge, and a service restart
forgets all outstanding challenges. These replay properties do not use wall
clock time. The final response still travels in a short-lived JWT whose
signature, signer allowlist, target, expiration, and independent nonce are
checked by `meshcoretomqtt`. When both processes run on the same Pi, that JWT
check naturally uses the same host clock; if they run on different computers,
those two computers need compatible clocks. Repeater-to-Pi clock drift remains
irrelevant.
Request text is Base64URL encoded before it enters the line-oriented USB stream.
Reply controls such as carriage return and newline are converted to spaces, and
the firmware independently rejects control characters and malformed UTF-8.
This prevents text from becoming a second serial command.
Protect `host-cli-key.json`: any private key placed in
`allowed_companions` is trusted by `meshcoretomqtt` to sign serial requests.
Use a dedicated broker account whose ACL can only subscribe to this repeater's
debug topic and publish to this repeater's `serial/commands` topic.
## Limits
- LoRa `host` command: 155 UTF-8 bytes after `host `, or 152 bytes when the
companion uses its legacy three-byte correlation prefix.
- LoRa reply: 162 UTF-8 bytes total, including that correlation prefix.
- One request can be pending per repeater.
- Live-claim deadline: 4 seconds, followed by up to 6 seconds for execution and
the reply.
- `host.reply` is accepted only from the physical USB serial command path, not
from LoRa or the Ethernet CLI.
Run the dependency-free logic tests with:
```sh
cd examples/host_cli_service
python3 -m unittest -v
```
The live service needs Python 3.11 or later, `paho-mqtt`, and
`ed25519-orlp`; the standard `/opt/mctomqtt/venv` already contains them.
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python3
"""Compatibility entry point for the expanded MeshCore host CLI service."""
from host_cli_service import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,41 @@
{
"programs": {
"fan": {
"argv": [
"/usr/local/bin/mesh-fan-control",
"--source",
"lora"
],
"timeout_seconds": 3,
"arguments": [
{
"name": "state",
"type": "choice",
"choices": [
"on",
"off"
]
},
{
"name": "minutes",
"type": "integer",
"min": 1,
"max": 60
}
]
},
"label-status": {
"argv": [
"/usr/local/bin/mesh-label-status"
],
"timeout_seconds": 3,
"arguments": [
{
"name": "label",
"type": "token",
"max_bytes": 24
}
]
}
}
}
@@ -0,0 +1,585 @@
from __future__ import annotations
import base64
import json
from pathlib import Path
import tempfile
import unittest
from unittest import mock
import host_cli_service as service
PUBLIC_KEY = "11" * 32
PRIVATE_KEY = "22" * 64
SIGNATURE = bytes([0xA5]) * 64
def request_payload(
text: str = "cpu-temp",
request_id: str = "89ABCDEF",
) -> tuple[str, bytes]:
encoded = base64.urlsafe_b64encode(text.encode()).rstrip(b"=").decode()
signed = f"HOSTCLI/1 REQUEST {request_id} 0123456789ABCDEF {encoded}"
message = "DEBUG " + signed + " " + SIGNATURE.hex().upper()
payload = json.dumps(
{"type": "DEBUG", "origin_id": PUBLIC_KEY, "message": message}
).encode()
return signed, payload
def claim_payload(
challenge: str = "FEDCBA9876543210",
request_id: str = "89ABCDEF",
) -> tuple[str, bytes]:
signed = (
f"HOSTCLI/1 CLAIMED {request_id} 0123456789ABCDEF {challenge}"
)
message = "DEBUG " + signed + " " + SIGNATURE.hex().upper()
payload = json.dumps(
{"type": "DEBUG", "origin_id": PUBLIC_KEY, "message": message}
).encode()
return signed, payload
class HostRequestTests(unittest.TestCase):
def test_accepts_only_a_valid_repeater_signature(self) -> None:
expected, payload = request_payload()
def verifier(signature: bytes, message: bytes, public: bytes) -> bool:
return (
signature == SIGNATURE
and message == expected.encode()
and public == bytes.fromhex(PUBLIC_KEY)
)
request = service.parse_and_verify_request(
payload, PUBLIC_KEY, verifier=verifier,
)
self.assertEqual(
service.HostRequest(
"89ABCDEF", "0123456789ABCDEF", "cpu-temp"
),
request,
)
def test_rejects_forged_content_and_wrong_origin(self) -> None:
_expected, payload = request_payload("cpu-temp")
forged = payload.replace(b"Y3B1LXRlbXA", b"ZXJhc2U")
with self.assertRaisesRegex(ValueError, "signature"):
service.parse_and_verify_request(
forged, PUBLIC_KEY, verifier=lambda *_args: False,
)
document = json.loads(payload)
document["origin_id"] = "33" * 32
with self.assertRaisesRegex(ValueError, "origin"):
service.parse_and_verify_request(
json.dumps(document), PUBLIC_KEY,
verifier=lambda *_args: True,
)
def test_ignores_unrelated_debug_records(self) -> None:
payload = json.dumps(
{"type": "DEBUG", "origin_id": PUBLIC_KEY,
"message": "DEBUG ordinary log"}
)
self.assertIsNone(
service.parse_and_verify_request(
payload, PUBLIC_KEY, verifier=lambda *_args: True,
)
)
def test_accepts_only_a_valid_signed_claim(self) -> None:
expected, payload = claim_payload()
def verifier(signature: bytes, message: bytes, public: bytes) -> bool:
return (
signature == SIGNATURE
and message == expected.encode()
and public == bytes.fromhex(PUBLIC_KEY)
)
self.assertEqual(
service.HostClaim(
"89ABCDEF", "0123456789ABCDEF", "FEDCBA9876543210"
),
service.parse_and_verify_claim(
payload, PUBLIC_KEY, verifier=verifier
),
)
with self.assertRaisesRegex(ValueError, "signature"):
service.parse_and_verify_claim(
payload, PUBLIC_KEY, verifier=lambda *_args: False
)
def test_rejects_a_zero_claim_challenge(self) -> None:
_signed, payload = claim_payload("0000000000000000")
with self.assertRaisesRegex(ValueError, "must not be zero"):
service.parse_and_verify_claim(
payload, PUBLIC_KEY, verifier=lambda *_args: True
)
class RequestHandlerTests(unittest.TestCase):
def test_reads_pi_cpu_temperature(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "temp"
path.write_text("47250\n", encoding="ascii")
self.assertEqual(
service.HostResult("CPU 47.2 C"),
service.handle_request("cpu-temp", path),
)
def test_never_executes_or_accepts_injected_commands(self) -> None:
response = service.handle_request(
"cpu-temp\r\nerase", Path("/does/not/matter")
)
self.assertEqual(
service.HostResult("Err - unsupported host request"), response
)
request = service.HostRequest(
"12345678", "FEDCBA9876543210", "cpu-temp"
)
command = service.make_serial_reply(request, "OK\r\nerase")
self.assertEqual(
"host.reply 12345678 FEDCBA9876543210 OK erase", command
)
self.assertNotIn("\r", command)
self.assertNotIn("\n", command)
self.assertEqual(
"OK 31mnot-a-terminal-control",
service.bounded_line_text("OK\N{CONTROL SEQUENCE INTRODUCER}31m"
"not-a-terminal-control"),
)
def test_exact_fixed_commands_and_opt_in_reboot(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
uptime = root / "uptime"
load = root / "loadavg"
memory = root / "meminfo"
uptime.write_text("183845.2 0.0\n", encoding="ascii")
load.write_text("0.12 0.34 0.56 1/1 1\n", encoding="ascii")
memory.write_text(
"MemTotal: 1024000 kB\nMemAvailable: 512000 kB\n",
encoding="ascii",
)
kwargs = {
"uptime_path": uptime,
"load_path": load,
"memory_path": memory,
"disk_path": root,
}
self.assertEqual(
"Uptime 2d 3h 4m",
service.handle_request("uptime", root, **kwargs).text,
)
self.assertEqual(
"Load 0.12 0.34 0.56",
service.handle_request("load", root, **kwargs).text,
)
self.assertEqual(
"Memory 500/1000 MiB available",
service.handle_request("memory", root, **kwargs).text,
)
self.assertTrue(
service.handle_request("disk-free", root, **kwargs).text.startswith(
"Disk "
)
)
disabled = service.handle_request("reboot", Path("/unused"))
self.assertEqual("Err - host reboot is disabled", disabled.text)
self.assertFalse(disabled.reboot_requested)
enabled = service.handle_request(
"reboot", Path("/unused"), allow_reboot=True, reboot_delay=7
)
self.assertEqual("OK - host reboot scheduled in 7s", enabled.text)
self.assertTrue(enabled.reboot_requested)
self.assertEqual(
service.HostResult("Err - unsupported host request"),
service.handle_request("reboot now", Path("/unused"),
allow_reboot=True),
)
def test_reply_is_utf8_bounded(self) -> None:
text = "x" * 161 + "\N{EURO SIGN}"
bounded = service.bounded_line_text(text)
self.assertLessEqual(len(bounded.encode()), service.MAX_REPLY_BYTES)
self.assertEqual("x" * 161, bounded)
def test_runs_only_a_fixed_program_with_validated_arguments(self) -> None:
programs = {
"fan": service.ProgramDefinition(
alias="fan",
argv=("/usr/local/bin/mesh-fan-control", "--source", "lora"),
arguments=(
service.ProgramArgumentRule(
"state", "choice", choices=("on", "off")
),
service.ProgramArgumentRule(
"minutes", "integer", minimum=1, maximum=60
),
),
timeout_seconds=3,
)
}
completed = subprocess_result = mock.Mock(
returncode=0, stdout="fan scheduled\n"
)
with mock.patch.object(
service.subprocess, "run", return_value=completed
) as run:
result = service.handle_request(
"run fan on 15", Path("/unused"), programs=programs
)
self.assertEqual("fan scheduled", result.text)
self.assertIs(completed, subprocess_result)
argv = run.call_args.args[0]
self.assertEqual(
[
"/usr/local/bin/mesh-fan-control",
"--source",
"lora",
"on",
"15",
],
argv,
)
self.assertFalse(run.call_args.kwargs["shell"])
self.assertEqual("/", run.call_args.kwargs["cwd"])
def test_rejects_program_alias_and_argument_injection(self) -> None:
programs = {
"fan": service.ProgramDefinition(
alias="fan",
argv=("/usr/local/bin/mesh-fan-control",),
arguments=(
service.ProgramArgumentRule(
"state", "choice", choices=("on", "off")
),
),
)
}
rejected = (
"run other on",
"run fan --help",
"run fan on;reboot",
"run fan on\nreboot",
"run fan on extra",
"run fan -1",
)
with mock.patch.object(service.subprocess, "run") as run:
for request in rejected:
with self.subTest(request=request):
self.assertTrue(
service.handle_request(
request, Path("/unused"), programs=programs
).text.startswith("Err -")
)
run.assert_not_called()
def test_loads_a_bounded_program_allowlist(self) -> None:
executable = Path("/bin/echo").resolve()
with tempfile.TemporaryDirectory() as directory:
config = Path(directory) / "programs.json"
config.write_text(
json.dumps(
{
"programs": {
"sample": {
"argv": [str(executable), "fixed"],
"timeout_seconds": 2,
"arguments": [
{
"name": "mode",
"type": "choice",
"choices": ["short", "full"],
},
{
"name": "count",
"type": "integer",
"min": 1,
"max": 5,
},
{
"name": "label",
"type": "token",
"max_bytes": 12,
},
],
}
}
}
),
encoding="utf-8",
)
config.chmod(0o644)
programs = service.load_programs_file(config)
self.assertEqual((str(executable), "fixed"), programs["sample"].argv)
self.assertEqual(3, len(programs["sample"].arguments))
def test_rejects_writable_program_allowlist(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = Path(directory) / "programs.json"
config.write_text('{"programs": {}}', encoding="ascii")
config.chmod(0o666)
with self.assertRaisesRegex(ValueError, "group/world writable"):
service.load_programs_file(config)
class TokenTests(unittest.TestCase):
def test_signed_remote_serial_token_has_required_claims(self) -> None:
key = service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY)
def signer(message: bytes, public: bytes, private: bytes) -> bytes:
self.assertEqual(bytes.fromhex(PUBLIC_KEY), public)
self.assertEqual(bytes.fromhex(PRIVATE_KEY), private)
self.assertEqual(1, message.count(b"."))
return SIGNATURE
token = service.create_auth_token(
key,
{
"command": (
"host.reply 12345678 FEDCBA9876543210 CPU 47.2 C"
),
"target": "44" * 32,
"nonce": "nonce-value",
},
now=1000,
signer=signer,
)
header_part, payload_part, signature_part = token.split(".")
del header_part
padding = "=" * ((4 - len(payload_part) % 4) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload_part + padding))
self.assertEqual(PUBLIC_KEY, claims["publicKey"])
self.assertEqual(1000, claims["iat"])
self.assertEqual(1030, claims["exp"])
self.assertEqual("44" * 32, claims["target"])
self.assertEqual(SIGNATURE.hex().upper(), signature_part)
class EndpointTests(unittest.TestCase):
def test_request_needs_matching_live_claim_before_reboot(self) -> None:
events: list[object] = []
class PublishResult:
rc = 0
def wait_for_publish(self, timeout: float) -> None:
events.append(("wait", timeout))
class Client:
def publish(self, topic: str, token: str, qos: int) -> PublishResult:
events.append(("publish", topic, token, qos))
return PublishResult()
endpoint = service.HostCliEndpoint(
client=Client(),
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
allow_reboot=True,
reboot_delay=7,
reboot_scheduler=lambda delay: events.append(("reboot", delay)),
challenge_generator=lambda: "A1B2C3D4E5F60718",
monotonic=lambda: 100.0,
)
request = service.HostRequest(
"12345678", "FEDCBA9876543210", "reboot"
)
claim = service.HostClaim(
request.request_id,
request.request_nonce,
"A1B2C3D4E5F60718",
)
def make_token(
_key: service.ServiceKey, claims: dict[str, object]
) -> str:
events.append(("token", claims["command"]))
return "signed"
reboot_result = service.HostResult(
"OK - host reboot scheduled in 7s", reboot_requested=True
)
with mock.patch.object(
service, "parse_and_verify_request", return_value=request
), mock.patch.object(
service, "create_auth_token", side_effect=make_token
), mock.patch.object(
service, "handle_request", return_value=reboot_result
) as handler:
self.assertTrue(endpoint.handle_mqtt_message(b"request"))
handler.assert_not_called()
with mock.patch.object(
service, "parse_and_verify_request", return_value=None
), mock.patch.object(
service, "parse_and_verify_claim", return_value=claim
):
self.assertTrue(endpoint.handle_mqtt_message(b"claim"))
self.assertEqual(
(
"token",
"host.reply 12345678 FEDCBA9876543210 "
"@claim=A1B2C3D4E5F60718",
),
events[0],
)
self.assertEqual("publish", events[1][0])
self.assertEqual(
(
"token",
"host.reply 12345678 FEDCBA9876543210 "
"OK - host reboot scheduled in 7s",
),
events[2],
)
self.assertEqual("publish", events[3][0])
self.assertEqual(("wait", 2.0), events[4])
self.assertEqual(("reboot", 7), events[5])
def test_mismatched_expired_and_replayed_claims_do_not_execute(self) -> None:
now = [10.0]
class Client:
def publish(self, _topic: str, _token: str, qos: int) -> mock.Mock:
self.qos = qos
return mock.Mock(rc=0)
endpoint = service.HostCliEndpoint(
client=Client(),
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
challenge_generator=lambda: "1111222233334444",
monotonic=lambda: now[0],
)
request = service.HostRequest(
"12345678", "FEDCBA9876543210", "cpu-temp"
)
matching = service.HostClaim(
request.request_id, request.request_nonce, "1111222233334444"
)
wrong = service.HostClaim(
request.request_id, request.request_nonce, "9999888877776666"
)
with mock.patch.object(
service, "create_auth_token", return_value="signed"
), mock.patch.object(
service, "handle_request", return_value=service.HostResult("OK")
) as handler:
endpoint._handle_request(request)
endpoint._handle_claim(wrong)
handler.assert_not_called()
endpoint._handle_claim(matching)
handler.assert_called_once()
endpoint._handle_claim(matching)
handler.assert_called_once()
restarted = service.HostCliEndpoint(
client=Client(),
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
)
restarted._handle_claim(matching)
handler.assert_called_once()
later = service.HostCliEndpoint(
client=Client(),
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
challenge_generator=lambda: "5555666677778888",
challenge_timeout_seconds=8.0,
monotonic=lambda: now[0],
)
later._handle_request(request)
now[0] = 19.0
later._handle_claim(
service.HostClaim(
request.request_id,
request.request_nonce,
"5555666677778888",
)
)
handler.assert_called_once()
def test_run_program_is_not_started_by_request_record_alone(self) -> None:
client = mock.Mock()
client.publish.return_value = mock.Mock(rc=0)
programs = {
"sample": service.ProgramDefinition(
alias="sample",
argv=("/usr/bin/printf", "%s"),
arguments=(
service.ProgramArgumentRule(
"value", "choice", choices=("safe",)
),
),
)
}
endpoint = service.HostCliEndpoint(
client=client,
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
programs=programs,
challenge_generator=lambda: "ABCDEF0123456789",
)
request = service.HostRequest(
"12345678", "FEDCBA9876543210", "run sample safe"
)
with mock.patch.object(
service, "create_auth_token", return_value="signed"
), mock.patch.object(service.subprocess, "run") as run:
endpoint._handle_request(request)
run.assert_not_called()
run.return_value = mock.Mock(returncode=0, stdout="done\n")
endpoint._handle_claim(
service.HostClaim(
request.request_id,
request.request_nonce,
"ABCDEF0123456789",
)
)
run.assert_called_once()
def test_failed_challenge_publish_clears_pending_state(self) -> None:
client = mock.Mock()
client.publish.return_value = mock.Mock(rc=7)
endpoint = service.HostCliEndpoint(
client=client,
repeater_public_key="44" * 32,
service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY),
command_topic="serial/commands",
temperature_path=Path("/unused"),
challenge_generator=lambda: "ABCDEF0123456789",
)
request = service.HostRequest(
"12345678", "FEDCBA9876543210", "cpu-temp"
)
with mock.patch.object(
service, "create_auth_token", return_value="signed"
), self.assertRaisesRegex(RuntimeError, "publish failed"):
endpoint._handle_request(request)
self.assertIsNone(endpoint.pending)
self.assertEqual({}, endpoint.seen)
if __name__ == "__main__":
unittest.main()
+241 -16
View File
@@ -101,6 +101,12 @@ extern "C" caddr_t _sbrk(int increment);
#define CLI_REPLY_DELAY_MILLIS 600
#if MESH_ENABLE_HOST_CLI
static_assert(mesh::HostCliBridge::REMOTE_REPLY_MAX
== mesh::RemoteCliReplyCache::MAX_REPLY_TEXT,
"host CLI and remote CLI reply limits must stay aligned");
#endif
// Max time to flush the outbound queue (START alert + CLI reply) before the OTA
// teardown blocks the loop until reboot. Best-effort: exits early once the queue
// drains (immediate on a healthy node), caps the wait on a jammed/duty-limited
@@ -2796,28 +2802,46 @@ void MyMesh::onUserGpioTimerCompleted(uint8_t pin, uint8_t state,
}
#endif
void __attribute__((noinline)) MyMesh::processDeferredCliCommand() {
if (!deferred_cli_command.pending) return;
void MyMesh::clearDeferredCliCommand() {
deferred_cli_command.clear();
deferred_cli_reply_scoped = false;
memset(deferred_cli_reply_scope.key, 0, sizeof(deferred_cli_reply_scope.key));
#if MESH_ENABLE_HOST_CLI
host_cli_waiting = false;
host_cli_claimed = false;
host_cli_claim_emit = false;
host_cli_deadline = 0;
host_cli_claim_emit_at = 0;
host_cli_nonce = 0;
host_cli_claim_challenge = 0;
#endif
}
#if MESH_ENABLE_HOST_CLI
bool MyMesh::completeHostCliRequest(const char* service_reply) {
if (!deferred_cli_command.pending || !host_cli_waiting
|| service_reply == NULL) {
return false;
}
const int client_index = deferred_cli_command.client_index;
if (client_index < 0 || client_index >= acl.getNumClients()) {
MESH_DEBUG_PRINTLN("processDeferredCliCommand: invalid client idx: %d", client_index);
deferred_cli_command.clear();
deferred_cli_reply_scoped = false;
memset(deferred_cli_reply_scope.key, 0, sizeof(deferred_cli_reply_scope.key));
return;
MESH_DEBUG_PRINTLN("completeHostCliRequest: invalid client idx: %d",
client_index);
clearDeferredCliCommand();
return false;
}
ClientInfo* client = acl.getClientByIdx(client_index);
char* reply = (char*)&reply_data[5];
reply[0] = 0;
mesh::HostCliBridge::formatRemoteReply(
reply, mesh::HostCliBridge::REMOTE_REPLY_MAX + 1U,
deferred_cli_command.command, service_reply);
const uint32_t command_fingerprint =
mesh::RemoteCliReplyCache::fingerprint(
deferred_cli_command.command,
strlen(deferred_cli_command.command));
handleCommand(deferred_cli_command.sender_timestamp, client,
deferred_cli_command.command, reply, client_index,
deferred_cli_command.path_hash_size);
remote_cli_reply_cache.remember(client->id.pub_key,
deferred_cli_command.request_id,
command_fingerprint, reply);
@@ -2825,9 +2849,173 @@ void __attribute__((noinline)) MyMesh::processDeferredCliCommand() {
deferred_cli_command.path_hash_size,
deferred_cli_command.sender_timestamp, reply,
deferred_cli_reply_scoped ? &deferred_cli_reply_scope : NULL);
deferred_cli_command.clear();
deferred_cli_reply_scoped = false;
memset(deferred_cli_reply_scope.key, 0, sizeof(deferred_cli_reply_scope.key));
clearDeferredCliCommand();
return true;
}
bool MyMesh::handleHostCliSerialReply(const char* command, char* reply) {
mesh::HostCliBridge::ReplyView parsed;
const mesh::HostCliBridge::ParseResult result =
mesh::HostCliBridge::parseReply(command, parsed);
if (result == mesh::HostCliBridge::NOT_HOST_COMMAND) return false;
if (reply == NULL) return true;
if (result != mesh::HostCliBridge::VALID_HOST_COMMAND) {
strcpy(reply, "Err - invalid host.reply format");
} else if (!deferred_cli_command.pending || !host_cli_waiting) {
strcpy(reply, "Err - no host request is pending");
} else if (parsed.request_id != deferred_cli_command.request_id
|| parsed.request_nonce != host_cli_nonce) {
strcpy(reply, "Err - host request token mismatch");
} else if (millisHasNowPassed(host_cli_deadline)) {
completeHostCliRequest("Err - host service timeout");
strcpy(reply, "Err - host request expired");
} else {
uint64_t claim_challenge = 0;
const bool is_claim = mesh::HostCliBridge::parseServiceClaim(
parsed.text, parsed.text_len, claim_challenge);
if (is_claim && (host_cli_claimed || host_cli_claim_emit)) {
strcpy(reply, "Err - host request already claimed");
} else if (is_claim) {
host_cli_claim_challenge = claim_challenge;
host_cli_claim_emit = true;
host_cli_claim_emit_at = futureMillis(
mesh::HostCliBridge::SERVICE_CLAIM_EMIT_DELAY_MILLIS);
host_cli_deadline = futureMillis(
mesh::HostCliBridge::SERVICE_REPLY_TIMEOUT_MILLIS);
strcpy(reply, "OK - host claim accepted");
} else if (!host_cli_claimed) {
strcpy(reply, "Err - host request not claimed");
} else if (!completeHostCliRequest(parsed.text)) {
strcpy(reply, "Err - host requester is unavailable");
} else {
strcpy(reply, "OK - host reply accepted");
}
}
return true;
}
#endif
void __attribute__((noinline)) MyMesh::processDeferredCliCommand() {
if (!deferred_cli_command.pending) return;
const int client_index = deferred_cli_command.client_index;
if (client_index < 0 || client_index >= acl.getNumClients()) {
MESH_DEBUG_PRINTLN("processDeferredCliCommand: invalid client idx: %d", client_index);
clearDeferredCliCommand();
return;
}
#if MESH_ENABLE_HOST_CLI
if (host_cli_waiting) {
if (millisHasNowPassed(host_cli_deadline)) {
completeHostCliRequest("Err - host service timeout");
return;
}
if (host_cli_claim_emit
&& millisHasNowPassed(host_cli_claim_emit_at)) {
char record[mesh::HostCliBridge::CLAIMED_USB_RECORD_MAX + 1U];
memcpy(record, "DEBUG ", 6);
char* signed_content = record + 6;
if (!mesh::HostCliBridge::formatUsbClaim(
signed_content,
mesh::HostCliBridge::CLAIMED_SIGNED_CONTENT_MAX + 1U,
deferred_cli_command.request_id, host_cli_nonce,
host_cli_claim_challenge)) {
completeHostCliRequest("Err - could not confirm host service");
return;
}
const size_t signed_content_len = strlen(signed_content);
uint8_t signature[SIGNATURE_SIZE];
self_id.sign(signature, (const uint8_t*)signed_content,
signed_content_len);
record[6U + signed_content_len] = ' ';
mesh::Utils::toHex(record + 6U + signed_content_len + 1U,
signature, sizeof(signature));
Serial.println(record);
host_cli_claim_emit = false;
host_cli_claim_emit_at = 0;
host_cli_claimed = true;
}
return;
}
#endif
ClientInfo* client = acl.getClientByIdx(client_index);
char* reply = (char*)&reply_data[5];
reply[0] = 0;
#if MESH_ENABLE_HOST_CLI
mesh::HostCliBridge::RequestView host_request;
const mesh::HostCliBridge::ParseResult host_result =
mesh::HostCliBridge::parseRequest(deferred_cli_command.command,
host_request);
if (host_result != mesh::HostCliBridge::NOT_HOST_COMMAND) {
if (!client->isAdmin()) {
mesh::HostCliBridge::formatRemoteReply(
reply, mesh::HostCliBridge::REMOTE_REPLY_MAX + 1U,
deferred_cli_command.command, "Err - not permitted");
} else if (host_result != mesh::HostCliBridge::VALID_HOST_COMMAND) {
mesh::HostCliBridge::formatRemoteReply(
reply, mesh::HostCliBridge::REMOTE_REPLY_MAX + 1U,
deferred_cli_command.command,
"Err - use: host <text up to 155 bytes>");
} else {
uint64_t request_nonce = 0;
getRNG()->random((uint8_t*)&request_nonce, sizeof(request_nonce));
if (request_nonce == 0) request_nonce = 1;
char record[mesh::HostCliBridge::USB_RECORD_MAX + 1U];
memcpy(record, "DEBUG ", 6);
char* signed_content = record + 6;
if (mesh::HostCliBridge::formatUsbRequest(
signed_content,
mesh::HostCliBridge::USB_SIGNED_CONTENT_MAX + 1U,
deferred_cli_command.request_id, request_nonce,
host_request.text, host_request.text_len)) {
const size_t signed_content_len = strlen(signed_content);
uint8_t signature[SIGNATURE_SIZE];
self_id.sign(signature, (const uint8_t*)signed_content,
signed_content_len);
record[6U + signed_content_len] = ' ';
mesh::Utils::toHex(record + 6U + signed_content_len + 1U,
signature, sizeof(signature));
Serial.println(record);
host_cli_waiting = true;
host_cli_claimed = false;
host_cli_claim_emit = false;
host_cli_claim_emit_at = 0;
host_cli_nonce = request_nonce;
host_cli_claim_challenge = 0;
host_cli_deadline = futureMillis(
mesh::HostCliBridge::SERVICE_CLAIM_TIMEOUT_MILLIS);
return;
}
mesh::HostCliBridge::formatRemoteReply(
reply, mesh::HostCliBridge::REMOTE_REPLY_MAX + 1U,
deferred_cli_command.command,
"Err - could not encode host request");
}
} else {
#endif
handleCommand(deferred_cli_command.sender_timestamp, client,
deferred_cli_command.command, reply, client_index,
deferred_cli_command.path_hash_size);
#if MESH_ENABLE_HOST_CLI
}
#endif
const uint32_t command_fingerprint =
mesh::RemoteCliReplyCache::fingerprint(
deferred_cli_command.command,
strlen(deferred_cli_command.command));
remote_cli_reply_cache.remember(client->id.pub_key,
deferred_cli_command.request_id,
command_fingerprint, reply);
sendRemoteCliReply(client, deferred_cli_command.secret,
deferred_cli_command.path_hash_size,
deferred_cli_command.sender_timestamp, reply,
deferred_cli_reply_scoped ? &deferred_cli_reply_scope : NULL);
clearDeferredCliCommand();
}
bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t *secret, uint8_t *path,
@@ -2948,6 +3136,15 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
next_local_advert = next_flood_advert = 0;
deferred_cli_reply_scoped = false;
memset(deferred_cli_reply_scope.key, 0, sizeof(deferred_cli_reply_scope.key));
#if MESH_ENABLE_HOST_CLI
host_cli_waiting = false;
host_cli_claimed = false;
host_cli_claim_emit = false;
host_cli_deadline = 0;
host_cli_claim_emit_at = 0;
host_cli_nonce = 0;
host_cli_claim_challenge = 0;
#endif
pending_self_advert_delay = 0;
pending_self_advert = false;
pending_self_advert_flood = false;
@@ -3012,7 +3209,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
clock_sync_last_fresh_count = 0;
clock_sync_last_required_count = CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT;
clock_sync_required_samples = CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT;
clock_sync_drift_seconds = CLOCK_SYNC_DRIFT_DEFAULT_SECONDS;
clock_sync_drift_seconds = mesh::CLOCK_SYNC_DRIFT_DEFAULT_SECONDS;
clock_sync_last_estimate = 0;
clock_sync_last_abs_drift = 0;
clock_sync_internet_requested_millis = 0;
@@ -8893,7 +9090,7 @@ void MyMesh::loadClockSyncPrefs() {
clock_sync_mesh_enabled = CLOCK_SYNC_MESH_DEFAULT_ENABLED != 0;
clock_sync_mesh_edge_enabled = CLOCK_SYNC_MESH_EDGE_DEFAULT_ENABLED != 0;
clock_sync_internet_enabled = false;
clock_sync_drift_seconds = CLOCK_SYNC_DRIFT_DEFAULT_SECONDS;
clock_sync_drift_seconds = mesh::CLOCK_SYNC_DRIFT_DEFAULT_SECONDS;
clock_sync_required_samples = CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT;
if (_fs != NULL && _fs->exists(CLOCK_SYNC_PREFS_FILE)) {
@@ -10183,6 +10380,34 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
const mesh::cli::NoArgCommandMatch discover_neighbors_match =
mesh::cli::matchNoArgCommand(command, "discover.neighbors");
#if MESH_ENABLE_HOST_CLI
if (strcmp(command, "get host") == 0) {
if (host_cli_waiting && deferred_cli_command.pending) {
snprintf(reply, 160,
"> waiting,%s,id=%08lX,request_max=%u,reply_max=%u,"
"claim=%lus,reply=%lus",
(host_cli_claimed || host_cli_claim_emit) ? "reply" : "claim",
(unsigned long)deferred_cli_command.request_id,
(unsigned)mesh::HostCliBridge::REQUEST_TEXT_MAX,
(unsigned)mesh::HostCliBridge::REMOTE_REPLY_MAX,
(unsigned long)(
mesh::HostCliBridge::SERVICE_CLAIM_TIMEOUT_MILLIS / 1000UL),
(unsigned long)(
mesh::HostCliBridge::SERVICE_REPLY_TIMEOUT_MILLIS / 1000UL));
} else {
snprintf(reply, 160,
"> ready,idle,request_max=%u,reply_max=%u,claim=%lus,reply=%lus",
(unsigned)mesh::HostCliBridge::REQUEST_TEXT_MAX,
(unsigned)mesh::HostCliBridge::REMOTE_REPLY_MAX,
(unsigned long)(
mesh::HostCliBridge::SERVICE_CLAIM_TIMEOUT_MILLIS / 1000UL),
(unsigned long)(
mesh::HostCliBridge::SERVICE_REPLY_TIMEOUT_MILLIS / 1000UL));
}
return;
}
#endif
#if MESH_ENABLE_TELEMETRY_HISTORY
const char* telemetry_args = NULL;
mesh::TelemetryHistory::Series telemetry_series =
+22 -1
View File
@@ -83,6 +83,12 @@
#include <helpers/ClientACL.h>
#include <helpers/CommonCLI.h>
#include <helpers/DeferredCliCommand.h>
#ifndef MESH_ENABLE_HOST_CLI
#define MESH_ENABLE_HOST_CLI 1
#endif
#if MESH_ENABLE_HOST_CLI
#include <helpers/HostCliBridge.h>
#endif
#include <helpers/RemoteCliReplyCache.h>
#include <helpers/RemoteCliRequest.h>
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
@@ -246,7 +252,6 @@ struct NeighbourInfo {
#define CLOCK_SYNC_CONSENSUS_WINDOW_SECONDS 600UL
#define CLOCK_SYNC_DRIFT_MIN_SECONDS 30UL
#define CLOCK_SYNC_DRIFT_MAX_SECONDS 86400UL
#define CLOCK_SYNC_DRIFT_DEFAULT_SECONDS 3600UL
#define CLOCK_SYNC_MESH_SUPPRESS_NONE 0
#define CLOCK_SYNC_MESH_SUPPRESS_CLI 1
#define CLOCK_SYNC_MESH_SUPPRESS_GPS 2
@@ -282,6 +287,15 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
mesh::RemoteCliReplyCache remote_cli_reply_cache;
TransportKey deferred_cli_reply_scope;
bool deferred_cli_reply_scoped;
#if MESH_ENABLE_HOST_CLI
bool host_cli_waiting;
bool host_cli_claimed;
bool host_cli_claim_emit;
unsigned long host_cli_deadline;
unsigned long host_cli_claim_emit_at;
uint64_t host_cli_nonce;
uint64_t host_cli_claim_challenge;
#endif
uint32_t pending_self_advert_delay;
bool pending_self_advert;
bool pending_self_advert_flood;
@@ -597,6 +611,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len);
mesh::Packet* createSelfAdvert();
void processDeferredCliCommand();
void clearDeferredCliCommand();
#if MESH_ENABLE_HOST_CLI
bool completeHostCliRequest(const char* service_reply);
#endif
void sendRemoteCliReply(ClientInfo* client, const uint8_t* secret,
uint8_t path_hash_size, uint32_t sender_timestamp,
const char* reply, const TransportKey* fallback_scope);
@@ -936,6 +954,9 @@ public:
void handleCommand(uint32_t sender_timestamp, char* command, char* reply) {
handleCommand(sender_timestamp, NULL, command, reply);
}
#if MESH_ENABLE_HOST_CLI
bool handleHostCliSerialReply(const char* command, char* reply);
#endif
void loop();
uint32_t getPowerSaveSleepSeconds(uint32_t max_secs) const;
+53 -10
View File
@@ -47,7 +47,14 @@ void halt() {
while (1) ;
}
static char command[160];
#if MESH_ENABLE_HOST_CLI
static constexpr size_t LOCAL_SERIAL_COMMAND_MAX =
mesh::HostCliBridge::SERIAL_REPLY_COMMAND_MAX;
#else
static constexpr size_t LOCAL_SERIAL_COMMAND_MAX = 159U;
#endif
static char command[LOCAL_SERIAL_COMMAND_MAX + 2U];
static bool command_overflow = false;
#ifdef ETHERNET_ENABLED
static char ethernet_command[160];
#endif
@@ -179,30 +186,66 @@ void setup() {
static void __attribute__((noinline)) serviceCommandInterfaces() {
// Handle Serial CLI
int len = strlen(command);
while (Serial.available() && len < sizeof(command)-1) {
bool line_complete = false;
bool overlong_line_complete = false;
while (Serial.available()) {
char c = Serial.read();
if (c != '\n') {
if (c == '\n') continue;
Serial.print(c);
if (command_overflow) {
if (c == '\r') {
command_overflow = false;
overlong_line_complete = true;
break;
}
continue;
}
if (c == '\r') {
line_complete = true;
break;
}
if ((size_t)len < LOCAL_SERIAL_COMMAND_MAX) {
command[len++] = c;
command[len] = 0;
Serial.print(c);
} else {
// Discard the entire record through its delimiter. Never parse a
// truncated prefix as one command and its tail as a second command.
command[0] = 0;
len = 0;
command_overflow = true;
}
if (c == '\r') break;
}
if (len == sizeof(command)-1) { // command buffer full
command[sizeof(command)-1] = '\r';
}
if (len > 0 && command[len - 1] == '\r') { // received complete line
if (overlong_line_complete) {
Serial.print('\n');
Serial.println(" -> Err - command too long");
command[0] = 0;
return;
}
if (line_complete) {
Serial.print('\n');
command[len - 1] = 0; // replace newline with C string null terminator
char reply[160];
reply[0] = 0;
#ifdef ETHERNET_ENABLED
if (!ethernet_handle_command(command, reply)) {
#if MESH_ENABLE_HOST_CLI
if (!the_mesh.handleHostCliSerialReply(command, reply)) {
the_mesh.handleCommand(0, command, reply);
}
#else
the_mesh.handleCommand(0, command, reply);
#endif
}
#else
#if MESH_ENABLE_HOST_CLI
if (!the_mesh.handleHostCliSerialReply(command, reply)) {
the_mesh.handleCommand(0, command, reply); // NOTE: there is no sender_timestamp via serial!
}
#else
the_mesh.handleCommand(0, command, reply); // NOTE: there is no sender_timestamp via serial!
#endif
#endif
if (reply[0]) {
Serial.print(" -> "); Serial.println(reply);
+1
View File
@@ -303,6 +303,7 @@ build_src_filter =
+<../src/helpers/ota/MotaContainer.cpp>
+<../src/helpers/ota/FirmwareInfo.cpp>
+<../src/helpers/ota/MotaSourceSerial.cpp>
+<../src/helpers/ota/FolderMotaStore.cpp>
+<../src/helpers/ota/OtaProtocol.cpp>
+<../src/helpers/ota/OtaManager.cpp>
+<../src/helpers/ota/detools/detools.c>
+23 -5
View File
@@ -116,13 +116,31 @@ inline TerminalArgumentCommandMatch parseTerminalArgumentCommand(
return TerminalArgumentCommandMatch::Valid;
}
// Return true once terminal input has reached a login password. The caller
// can still retain the real bytes for command handling while echoing '*'.
inline bool shouldMaskTerminalInput(const char* line) {
// Return the first password byte once terminal input has reached a secret
// value. The caller can retain the real bytes for command handling while
// masking both the live echo and any backspace redraw.
inline const char* terminalPasswordInput(const char* line) {
line = skipRecentRepeaterSpaces(line);
const char* password = nullptr;
return parseTerminalArgumentCommand(line, "login", password)
== TerminalArgumentCommandMatch::Valid;
if (parseTerminalArgumentCommand(line, "login", password)
== TerminalArgumentCommandMatch::Valid) {
return password;
}
const char* setting = nullptr;
if (parseTerminalArgumentCommand(line, "set", setting)
!= TerminalArgumentCommandMatch::Valid) {
return nullptr;
}
if (parseTerminalArgumentCommand(setting, "wifi.pwd", password)
== TerminalArgumentCommandMatch::Valid) {
return password;
}
return nullptr;
}
inline bool shouldMaskTerminalInput(const char* line) {
return terminalPasswordInput(line) != nullptr;
}
// Remove one complete UTF-8 code point from a terminal input buffer. Invalid
+2
View File
@@ -6,6 +6,8 @@ namespace mesh {
static constexpr uint8_t CLOCK_SYNC_SAMPLE_SOURCE_SIGNED_ADVERT = 1;
static constexpr uint8_t CLOCK_SYNC_SAMPLE_SOURCE_PUBLIC_CHANNEL = 2;
// Used only when /clock_sync has no valid saved threshold; persisted operator settings win.
static constexpr uint32_t CLOCK_SYNC_DRIFT_DEFAULT_SECONDS = 10UL * 60UL;
inline bool clockSyncRequiresUniquePath(bool edge_mode) {
return !edge_mode;
+136
View File
@@ -7,6 +7,7 @@
#include "AlertReporter.h" // for alertReporterBannedChannelMatch()
#if defined(NRF52_PLATFORM)
#include "AtomicFileWriter.h"
#include "ota/OtaFlashLayout_nrf52.h"
#endif
#include <RTClib.h>
#include <ctype.h>
@@ -50,11 +51,16 @@ static void resetToUf2Bootloader() {
#ifdef ESP_PLATFORM
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <esp_ota_ops.h>
#include <esp_partition.h>
#include <esp_wifi.h>
#include <esp_heap_caps.h>
#else
#include <malloc.h> // mallinfo() for the `memory` command on nRF52/RP2040
#endif
#if defined(STM32_PLATFORM)
#include <InternalFileSystem.h>
#endif
#ifdef WITH_MQTT_BRIDGE
#include "bridges/MQTTBridge.h"
#include "CommonPrefsRecovery.h"
@@ -2717,6 +2723,135 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
}
}
bool CommonCLI::handleStorageLayoutGetCmd(const char* config, char* reply) {
static const char key[] = "storage.layout";
const size_t key_len = sizeof(key) - 1;
if (strncmp(config, key, key_len) != 0 ||
(config[key_len] != 0 && config[key_len] != ' ')) {
return false;
}
if (config[key_len] != 0) {
strcpy(reply, "Error: use get storage.layout");
return true;
}
#if defined(ESP32_PLATFORM)
snprintf(reply, 160, "> int:esp32=%luK ext:none; ",
(unsigned long)(ESP.getFlashChipSize() / 1024UL));
const esp_partition_t* running = esp_ota_get_running_partition();
esp_partition_iterator_t iterator = esp_partition_find(
ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL);
bool first = true;
bool truncated = false;
while (iterator != NULL) {
const esp_partition_t* partition = esp_partition_get(iterator);
char entry[48];
snprintf(entry, sizeof(entry), "%s%s%s@0x%lX+%luK",
first ? "" : ",", partition->label,
running != NULL && partition->address == running->address ? "*" : "",
(unsigned long)partition->address,
(unsigned long)(partition->size / 1024UL));
const size_t used = strlen(reply);
const size_t entry_len = strlen(entry);
if (used + entry_len + 1 >= 160) {
truncated = true;
break;
}
memcpy(reply + used, entry, entry_len + 1);
first = false;
iterator = esp_partition_next(iterator);
}
if (iterator != NULL) esp_partition_iterator_release(iterator);
if (first) {
snprintf(reply + strlen(reply), 160 - strlen(reply), "partitions=none");
} else if (truncated) {
const size_t used = strlen(reply);
if (used + 4 < 160) memcpy(reply + used, ",...", 5);
}
#elif defined(NRF52_PLATFORM)
const uint32_t flash_bytes =
(uint32_t)NRF_FICR->CODEPAGESIZE * (uint32_t)NRF_FICR->CODESIZE;
const uint32_t app_start = mesh::ota::mota_nrf52_app_base();
const uint32_t app_end =
(uint32_t)(uintptr_t)mesh::ota::__flash_arduino_end;
#if defined(NRF52840_XXAA)
const uint32_t internal_fs_start = 0x000ED000UL;
#else
const uint32_t internal_fs_start = 0x0006D000UL;
#endif
const uint32_t internal_fs_size = 7UL * 4096UL;
snprintf(reply, 160,
"> int:nrf52=%luK app=0x%lX-0x%lX ifs=0x%lX+%luK",
(unsigned long)(flash_bytes / 1024UL),
(unsigned long)app_start, (unsigned long)app_end,
(unsigned long)internal_fs_start,
(unsigned long)(internal_fs_size / 1024UL));
#if defined(ENABLE_OTA) && defined(OTA_QSPI_STORE)
mesh::ota::OtaStoreQspiNrf52& store = mesh::ota::ota_ctx().fetch_store;
const uint32_t capacity = store.capacity();
if (capacity != 0) {
snprintf(reply + strlen(reply), 160 - strlen(reply),
"; ext:qspi=%luK owner=ota-raw id=%06lX",
(unsigned long)(capacity / 1024UL),
(unsigned long)store.jedec_id());
} else {
snprintf(reply + strlen(reply), 160 - strlen(reply),
"; ext:qspi=error(%s)", store.last_error());
}
#elif defined(ENABLE_OTA) && defined(OTA_SD_STORE)
uint64_t used_bytes = 0;
uint64_t free_bytes = 0;
mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().fetch_store;
if (store.getSpace(*_board, used_bytes, free_bytes)) {
char total[24];
char used[24];
char free[24];
formatSdCardBytes(total, sizeof(total), used_bytes + free_bytes);
formatSdCardBytes(used, sizeof(used), used_bytes);
formatSdCardBytes(free, sizeof(free), free_bytes);
snprintf(reply + strlen(reply), 160 - strlen(reply),
"; ext:sd=%s used=%s free=%s owner=ota",
total, used, free);
} else {
snprintf(reply + strlen(reply), 160 - strlen(reply),
"; ext:sd=error(%s)", store.last_error());
}
#elif defined(QSPIFLASH)
snprintf(reply + strlen(reply), 160 - strlen(reply),
"; ext:qspi=configured owner=littlefs");
#else
snprintf(reply + strlen(reply), 160 - strlen(reply), "; ext:none");
#endif
#elif defined(RP2040_PLATFORM)
#if defined(PICO_FLASH_SIZE_BYTES) && defined(FS_START) && defined(FS_END)
snprintf(reply, 160,
"> int:rp2040=%luK ifs=%luK layout=fixed; ext:none",
(unsigned long)(PICO_FLASH_SIZE_BYTES / 1024UL),
(unsigned long)(((uintptr_t)FS_END - (uintptr_t)FS_START) / 1024UL));
#elif defined(PICO_FLASH_SIZE_BYTES)
snprintf(reply, 160, "> int:rp2040=%luK layout=fixed; ext:none",
(unsigned long)(PICO_FLASH_SIZE_BYTES / 1024UL));
#else
strcpy(reply, "> int:rp2040 layout=fixed; ext:none");
#endif
#elif defined(STM32_PLATFORM)
snprintf(reply, 160,
"> int:stm32=%luK ifs=0x%lX+%luK layout=fixed; ext:none",
(unsigned long)((FLASH_END_ADDR - FLASH_BASE + 1UL) / 1024UL),
(unsigned long)LFS_FLASH_ADDR_BASE,
(unsigned long)(LFS_FLASH_TOTAL_SIZE / 1024UL));
#else
strcpy(reply, "Error: storage layout unsupported on this platform");
#endif
return true;
}
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
bool CommonCLI::handleSdCardSetCmd(const char* config, char* reply) {
if (strncmp(config, "sdcard", 6) != 0 ||
@@ -3891,6 +4026,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
#endif
// Observer/MQTT/WiFi/timezone/alert/SNMP commands live in CommonCLI_Observer.cpp.
if (handleObserverGetCmd(sender_timestamp, config, reply)) return;
if (handleStorageLayoutGetCmd(config, reply)) return;
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
if (handleSdCardGetCmd(config, reply)) return;
#endif
+1
View File
@@ -678,6 +678,7 @@ class CommonCLI {
void handleGetCmd(uint32_t sender_timestamp, char* command, char* reply);
void handleSetCmd(uint32_t sender_timestamp, char* command, char* reply);
void handleDelCmd(char* command, char* reply);
bool handleStorageLayoutGetCmd(const char* config, char* reply);
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
bool handleSdCardGetCmd(const char* config, char* reply);
bool handleSdCardSetCmd(const char* config, char* reply);
+294
View File
@@ -0,0 +1,294 @@
#pragma once
#include <MeshCore.h>
#include <helpers/UTF8Helpers.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
namespace mesh {
// Line-oriented protocol used to hand an authenticated LoRa CLI request to a
// service on the repeater's USB host. The payload is Base64URL encoded so text
// cannot inject a second serial command or debug record.
class HostCliBridge {
public:
static constexpr uint32_t SERVICE_CLAIM_TIMEOUT_MILLIS = 4000UL;
static constexpr uint32_t SERVICE_CLAIM_EMIT_DELAY_MILLIS = 250UL;
static constexpr uint32_t SERVICE_REPLY_TIMEOUT_MILLIS = 6000UL;
static constexpr uint32_t SERVICE_TIMEOUT_MILLIS =
SERVICE_CLAIM_TIMEOUT_MILLIS + SERVICE_REPLY_TIMEOUT_MILLIS;
static constexpr size_t REMOTE_COMMAND_MAX = 10U * CIPHER_BLOCK_SIZE;
static constexpr size_t REMOTE_REPLY_MAX =
MAX_PACKET_PAYLOAD - CIPHER_MAC_SIZE - (CIPHER_BLOCK_SIZE - 1U) - 5U;
static constexpr size_t REQUEST_VERB_BYTES = 5U; // "host "
static constexpr size_t CORRELATION_BYTES = 3U; // legacy "XX|"
static constexpr size_t REQUEST_TEXT_MAX =
REMOTE_COMMAND_MAX - REQUEST_VERB_BYTES;
static constexpr size_t REQUEST_TEXT_WITH_CORRELATION_MAX =
REQUEST_TEXT_MAX - CORRELATION_BYTES;
static constexpr size_t REQUEST_ENCODED_MAX =
(REQUEST_TEXT_MAX * 4U + 2U) / 3U;
static constexpr size_t USB_SIGNED_CONTENT_MAX =
sizeof("HOSTCLI/1 REQUEST FFFFFFFF FFFFFFFFFFFFFFFF ") - 1U
+ REQUEST_ENCODED_MAX;
static constexpr size_t USB_RECORD_MAX =
sizeof("DEBUG ") - 1U + USB_SIGNED_CONTENT_MAX + 1U
+ SIGNATURE_SIZE * 2U;
static constexpr size_t SERIAL_REPLY_COMMAND_MAX =
sizeof("host.reply FFFFFFFF FFFFFFFFFFFFFFFF ") - 1U
+ REMOTE_REPLY_MAX;
static constexpr size_t SERVICE_CLAIM_PREFIX_SIZE =
sizeof("@claim=") - 1U;
static constexpr size_t SERVICE_CLAIM_TEXT_SIZE =
SERVICE_CLAIM_PREFIX_SIZE + 16U;
static constexpr size_t CLAIMED_SIGNED_CONTENT_MAX =
sizeof("HOSTCLI/1 CLAIMED FFFFFFFF FFFFFFFFFFFFFFFF "
"FFFFFFFFFFFFFFFF") - 1U;
static constexpr size_t CLAIMED_USB_RECORD_MAX =
sizeof("DEBUG ") - 1U + CLAIMED_SIGNED_CONTENT_MAX + 1U
+ SIGNATURE_SIZE * 2U;
enum ParseResult : uint8_t {
NOT_HOST_COMMAND = 0,
INVALID_HOST_COMMAND,
VALID_HOST_COMMAND,
};
struct RequestView {
const char* text;
size_t text_len;
const char* correlation;
size_t correlation_len;
RequestView()
: text(NULL), text_len(0), correlation(NULL), correlation_len(0) {}
};
struct ReplyView {
uint32_t request_id;
uint64_t request_nonce;
const char* text;
size_t text_len;
ReplyView() : request_id(0), request_nonce(0), text(NULL), text_len(0) {}
};
static ParseResult parseRequest(const char* command, RequestView& view) {
view = RequestView();
if (command == NULL) return NOT_HOST_COMMAND;
const size_t command_len = strlen(command);
const char* cursor = command;
if (command_len >= CORRELATION_BYTES && command[2] == '|') {
view.correlation = command;
view.correlation_len = CORRELATION_BYTES;
cursor += CORRELATION_BYTES;
}
if (strncmp(cursor, "host", 4) != 0) return NOT_HOST_COMMAND;
if (cursor[4] != 0 && cursor[4] != ' ' && cursor[4] != '\t') {
return NOT_HOST_COMMAND;
}
if (command_len > REMOTE_COMMAND_MAX) return INVALID_HOST_COMMAND;
cursor += 4;
while (*cursor == ' ' || *cursor == '\t') cursor++;
const char* text_end = command + command_len;
while (text_end > cursor
&& (text_end[-1] == ' ' || text_end[-1] == '\t'
|| text_end[-1] == '\r' || text_end[-1] == '\n')) {
text_end--;
}
if (text_end == cursor) return INVALID_HOST_COMMAND;
const size_t text_len = (size_t)(text_end - cursor);
const size_t max_text = view.correlation_len == 0
? REQUEST_TEXT_MAX : REQUEST_TEXT_WITH_CORRELATION_MAX;
if (text_len > max_text) return INVALID_HOST_COMMAND;
view.text = cursor;
view.text_len = text_len;
return VALID_HOST_COMMAND;
}
static ParseResult parseReply(const char* command, ReplyView& view) {
view = ReplyView();
if (command == NULL) return NOT_HOST_COMMAND;
const char* end = command + strlen(command);
if (strncmp(command, "host.reply", 10) != 0) return NOT_HOST_COMMAND;
if (command[10] != 0 && command[10] != ' ' && command[10] != '\t') {
return NOT_HOST_COMMAND;
}
const char* cursor = command + 10;
while (*cursor == ' ' || *cursor == '\t') cursor++;
uint32_t request_id = 0;
if ((size_t)(end - cursor) < 8U) return INVALID_HOST_COMMAND;
for (size_t i = 0; i < 8; i++) {
const int value = hexValue(cursor[i]);
if (value < 0) return INVALID_HOST_COMMAND;
request_id = (request_id << 4) | (uint32_t)value;
}
cursor += 8;
if (*cursor != ' ' && *cursor != '\t') return INVALID_HOST_COMMAND;
while (*cursor == ' ' || *cursor == '\t') cursor++;
uint64_t request_nonce = 0;
if ((size_t)(end - cursor) < 16U) return INVALID_HOST_COMMAND;
for (size_t i = 0; i < 16; i++) {
const int value = hexValue(cursor[i]);
if (value < 0) return INVALID_HOST_COMMAND;
request_nonce = (request_nonce << 4) | (uint64_t)value;
}
cursor += 16;
if (*cursor != ' ' && *cursor != '\t') return INVALID_HOST_COMMAND;
while (*cursor == ' ' || *cursor == '\t') cursor++;
if (*cursor == 0) return INVALID_HOST_COMMAND;
const size_t text_len = strlen(cursor);
if (text_len > REMOTE_REPLY_MAX
|| validUtf8PrefixLength(cursor, text_len) != text_len) {
return INVALID_HOST_COMMAND;
}
for (size_t i = 0; i < text_len; i++) {
const uint8_t byte = (uint8_t)cursor[i];
if (byte < 0x20 || byte == 0x7F) return INVALID_HOST_COMMAND;
}
view.request_id = request_id;
view.request_nonce = request_nonce;
view.text = cursor;
view.text_len = text_len;
return VALID_HOST_COMMAND;
}
static bool formatUsbRequest(char* output, size_t output_size,
uint32_t request_id, uint64_t request_nonce,
const char* text, size_t text_len) {
if (output == NULL || output_size == 0 || text == NULL
|| text_len == 0 || text_len > REQUEST_TEXT_MAX) {
return false;
}
const int prefix_len = snprintf(output, output_size,
"HOSTCLI/1 REQUEST %08lX %08lX%08lX ",
(unsigned long)request_id,
(unsigned long)(uint32_t)(request_nonce >> 32),
(unsigned long)(uint32_t)request_nonce);
if (prefix_len < 0 || (size_t)prefix_len >= output_size) return false;
return base64UrlEncode((const uint8_t*)text, text_len,
output + prefix_len, output_size - prefix_len);
}
static bool parseServiceClaim(const char* text, size_t text_len,
uint64_t& challenge) {
challenge = 0;
if (text == NULL || text_len != SERVICE_CLAIM_TEXT_SIZE
|| memcmp(text, "@claim=", SERVICE_CLAIM_PREFIX_SIZE) != 0) {
return false;
}
for (size_t i = 0; i < 16U; i++) {
const int value = hexValue(text[SERVICE_CLAIM_PREFIX_SIZE + i]);
if (value < 0) {
challenge = 0;
return false;
}
challenge = (challenge << 4) | (uint64_t)value;
}
return challenge != 0;
}
static bool formatUsbClaim(char* output, size_t output_size,
uint32_t request_id, uint64_t request_nonce,
uint64_t challenge) {
if (output == NULL || output_size == 0 || challenge == 0) return false;
const int length = snprintf(
output, output_size,
"HOSTCLI/1 CLAIMED %08lX %08lX%08lX %08lX%08lX",
(unsigned long)request_id,
(unsigned long)(uint32_t)(request_nonce >> 32),
(unsigned long)(uint32_t)request_nonce,
(unsigned long)(uint32_t)(challenge >> 32),
(unsigned long)(uint32_t)challenge);
return length > 0 && (size_t)length < output_size;
}
static size_t formatRemoteReply(char* output, size_t output_size,
const char* original_command,
const char* service_reply) {
if (output == NULL || output_size == 0 || original_command == NULL
|| service_reply == NULL) {
return 0;
}
size_t output_len = 0;
const size_t hard_limit = output_size - 1U < REMOTE_REPLY_MAX
? output_size - 1U : REMOTE_REPLY_MAX;
if (strlen(original_command) >= CORRELATION_BYTES
&& original_command[2] == '|'
&& hard_limit >= CORRELATION_BYTES) {
memcpy(output, original_command, CORRELATION_BYTES);
output_len = CORRELATION_BYTES;
}
const size_t body_len = validUtf8PrefixLength(
service_reply, hard_limit - output_len);
memcpy(output + output_len, service_reply, body_len);
output_len += body_len;
output[output_len] = 0;
return output_len;
}
private:
static int hexValue(char value) {
if (value >= '0' && value <= '9') return value - '0';
if (value >= 'a' && value <= 'f') return value - 'a' + 10;
if (value >= 'A' && value <= 'F') return value - 'A' + 10;
return -1;
}
static bool base64UrlEncode(const uint8_t* input, size_t input_len,
char* output, size_t output_size) {
static const char alphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
const size_t encoded_len = (input_len * 4U + 2U) / 3U;
if (input == NULL || output == NULL || output_size <= encoded_len) {
return false;
}
size_t input_pos = 0;
size_t output_pos = 0;
while (input_pos + 3U <= input_len) {
const uint32_t value = ((uint32_t)input[input_pos] << 16)
| ((uint32_t)input[input_pos + 1U] << 8)
| (uint32_t)input[input_pos + 2U];
output[output_pos++] = alphabet[(value >> 18) & 0x3F];
output[output_pos++] = alphabet[(value >> 12) & 0x3F];
output[output_pos++] = alphabet[(value >> 6) & 0x3F];
output[output_pos++] = alphabet[value & 0x3F];
input_pos += 3U;
}
const size_t remaining = input_len - input_pos;
if (remaining == 1U) {
const uint32_t value = (uint32_t)input[input_pos] << 16;
output[output_pos++] = alphabet[(value >> 18) & 0x3F];
output[output_pos++] = alphabet[(value >> 12) & 0x3F];
} else if (remaining == 2U) {
const uint32_t value = ((uint32_t)input[input_pos] << 16)
| ((uint32_t)input[input_pos + 1U] << 8);
output[output_pos++] = alphabet[(value >> 18) & 0x3F];
output[output_pos++] = alphabet[(value >> 12) & 0x3F];
output[output_pos++] = alphabet[(value >> 6) & 0x3F];
}
output[output_pos] = 0;
return output_pos == encoded_len;
}
};
} // namespace mesh
+2 -3
View File
@@ -33,7 +33,6 @@ constexpr uint32_t SAMPLE_MAX_AGE_MILLIS = 2UL * 60UL * 60UL * 1000UL;
constexpr uint32_t CONSENSUS_WINDOW_SECONDS = 600UL;
constexpr uint32_t DRIFT_MIN_SECONDS = 30UL;
constexpr uint32_t DRIFT_MAX_SECONDS = 86400UL;
constexpr uint32_t DRIFT_DEFAULT_SECONDS = 3600UL;
constexpr uint16_t VALID_YEARS = 10;
// Public-channel AES key, zero-padded to the shared-secret buffer width used
@@ -215,7 +214,7 @@ void MeshClockSync::resetDefaults() {
_last_fresh_count = 0;
_last_required_count = REQUIRED_SAMPLES_DEFAULT;
_required_samples = REQUIRED_SAMPLES_DEFAULT;
_drift_seconds = DRIFT_DEFAULT_SECONDS;
_drift_seconds = mesh::CLOCK_SYNC_DRIFT_DEFAULT_SECONDS;
_last_estimate = 0;
_last_abs_drift = 0;
_next_attempt_uptime = STARTUP_DELAY_MILLIS;
@@ -231,7 +230,7 @@ void MeshClockSync::loadPrefs() {
_mesh_enabled = CLOCK_SYNC_MESH_DEFAULT_ENABLED != 0;
_mesh_edge_enabled = CLOCK_SYNC_MESH_EDGE_DEFAULT_ENABLED != 0;
_internet_enabled = false;
_drift_seconds = DRIFT_DEFAULT_SECONDS;
_drift_seconds = mesh::CLOCK_SYNC_DRIFT_DEFAULT_SECONDS;
_required_samples = REQUIRED_SAMPLES_DEFAULT;
if (_fs != nullptr && _fs->exists(PREFS_FILE)) {
+4 -2
View File
@@ -22,8 +22,10 @@ WiFiClient seeder_client;
// WiFiClient::flush() clears received bytes rather than flushing TX. Disabling
// the serial-only flush prevents a fast host reply from being discarded and
// turning every block read into a three-second timeout/retry.
SerialMotaSource seeder_source(seeder_client, 3000, false);
FolderMotaStore folder_store(seeder_client, 3000, false);
SerialMotaSource seeder_source(seeder_client,
MotaStreamWritePolicy::NoFlush, 3000);
FolderMotaStore folder_store(seeder_client,
MotaStreamWritePolicy::NoFlush, 3000);
bool listener_active = false;
bool tcp_folder_attached = false;
+1 -1
View File
@@ -38,7 +38,7 @@ bool FolderMotaStore::txn(uint8_t op, const uint8_t* args, uint16_t arglen,
if (arglen) { memcpy(frame + frame_len, args, arglen); frame_len += arglen; }
frame[frame_len++] = xs;
if (_io.write(frame, frame_len) != frame_len) return false;
if (_flush_after_write) _io.flush();
if (_write_policy == MotaStreamWritePolicy::FlushTransmit) _io.flush();
uint32_t t0 = millis(); bool got = false; uint8_t prev = 0;
while ((millis() - t0) < _to) { // scan for response magic 'm' 's' (tolerate noise)
+5 -4
View File
@@ -1,6 +1,7 @@
#pragma once
#include <Arduino.h>
#include "MotaStreamWritePolicy.h"
#include "OtaStore.h"
// An OtaStore that captures an in-transit `.mota` onto a HOST folder over the mota-seeder link (the WRITE
@@ -22,9 +23,9 @@ namespace ota {
class FolderMotaStore : public OtaStore {
public:
explicit FolderMotaStore(Stream& io, uint32_t timeout_ms = 3000,
bool flush_after_write = true)
: _io(io), _to(timeout_ms), _flush_after_write(flush_after_write) {}
explicit FolderMotaStore(Stream& io, MotaStreamWritePolicy write_policy,
uint32_t timeout_ms = 3000)
: _io(io), _to(timeout_ms), _write_policy(write_policy) {}
// The container being pulled - set from the chosen `ota pull` mid before begin()/reopen().
void set_mid(const uint8_t mid[4]) { memcpy(_mid, mid, 4); }
@@ -51,7 +52,7 @@ private:
Stream& _io;
uint32_t _to;
bool _flush_after_write;
MotaStreamWritePolicy _write_policy;
uint8_t _mid[4] = {0, 0, 0, 0};
uint32_t _total = 0;
};
+1 -1
View File
@@ -36,7 +36,7 @@ bool SerialMotaSource::txn(uint8_t op, const uint8_t* args, uint8_t arglen,
if (arglen) { memcpy(frame + frame_len, args, arglen); frame_len += arglen; }
frame[frame_len++] = xs;
if (_io.write(frame, frame_len) != frame_len) return false;
if (_flush_after_write) _io.flush();
if (_write_policy == MotaStreamWritePolicy::FlushTransmit) _io.flush();
// scan for response magic 'm' 's' (tolerate leading noise)
uint32_t t0 = millis(); bool got = false;
+10 -12
View File
@@ -1,25 +1,23 @@
#pragma once
#include <Arduino.h>
#include "MotaStreamWritePolicy.h"
#include "OtaSource.h"
// A MotaSource backed by a host "mota-seeder" daemon over a dedicated Stream (a spare UART / USB-UART).
// The device pulls catalog + bytes on demand (MotaSeederProto.h); the folder image is never held on the
// device - it streams through. Use a stream that is NOT the text-CLI console so the binary framing never
// collides with command/log text. Reads block on the Stream up to `timeout_ms`; keep the daemon on a fast
// link so a serial round-trip does not stall the primary transfer.
// A MotaSource backed by a host "mota-seeder" daemon over a dedicated framed Stream, such as a spare
// UART, USB CDC, or TCP client. The device pulls catalog + bytes on demand (MotaSeederProto.h); the folder
// image is never held on the device - it streams through. Do not share the Stream with a text CLI because
// command/log text would collide with the binary framing. Reads block up to `timeout_ms`; keep the host
// endpoint responsive so a round-trip does not stall the primary transfer.
namespace mesh {
namespace ota {
class SerialMotaSource : public MotaSource {
public:
// Hardware serial needs flush() to finish transmitting before waiting for a
// reply. NetworkClient/WiFiClient gives flush() receive-side semantics and
// discards bytes, so TCP users must pass flush_after_write=false.
explicit SerialMotaSource(Stream& io, uint32_t timeout_ms = 400,
bool flush_after_write = true)
: _io(io), _to(timeout_ms), _flush_after_write(flush_after_write) {}
explicit SerialMotaSource(Stream& io, MotaStreamWritePolicy write_policy,
uint32_t timeout_ms = 400)
: _io(io), _to(timeout_ms), _write_policy(write_policy) {}
uint8_t count() override;
bool describe(uint8_t idx, MotaDesc& out) override;
@@ -34,7 +32,7 @@ private:
Stream& _io;
uint32_t _to;
bool _flush_after_write;
MotaStreamWritePolicy _write_policy;
};
} // namespace ota
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <stdint.h>
namespace mesh {
namespace ota {
// Stream::flush() does not have consistent semantics across Arduino transports.
// Hardware serial implementations wait for queued TX bytes, while ESP32
// WiFiClient and nRF52 BLEUart discard queued RX bytes. Require every mOTA
// stream caller to choose explicitly so a new transport cannot inherit an
// unsafe default.
enum class MotaStreamWritePolicy : uint8_t {
FlushTransmit,
NoFlush,
};
} // namespace ota
} // namespace mesh
+2 -1
View File
@@ -375,7 +375,8 @@ struct OtaContext {
// then advertises + relays them alongside its own fw). Only built when OTA_FOLDER_SERIAL is configured.
#if defined(OTA_FOLDER_SERIAL)
bool attach_folder(char* msg, size_t cap) {
static SerialMotaSource src(OTA_FOLDER_SERIAL_STREAM, 600);
static SerialMotaSource src(OTA_FOLDER_SERIAL_STREAM,
MotaStreamWritePolicy::FlushTransmit, 600);
#ifdef OTA_FOLDER_SERIAL_BEGIN
OTA_FOLDER_SERIAL_STREAM.begin(OTA_FOLDER_SERIAL_BAUD); // dedicated UART; console is already up
#endif
+2 -1
View File
@@ -55,6 +55,7 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_companion_node_prefs` | `examples/companion_radio/NodePrefs.h` | independent device power saving, RXPS, Wi-Fi, and FEM preferences; one-time migration of the regressed power-saving default |
| `test_config_serializer` | `src/helpers/ConfigSerializer.cpp`, Companion `NodePrefs` | escaped config save/load, whitespace and malformed input, unknown fields, and FEM preference round trips |
| `test_deferred_cli_command` | `src/helpers/DeferredCliCommand.h` | copying authenticated command context, single-pending-command enforcement, clearing, and length rejection |
| `test_host_cli_bridge` | `src/helpers/HostCliBridge.h` | bounded request/reply parsing, Base64URL serial framing, correlation preservation, one-time service-claim proofs, request-token fields, line safety, and UTF-8-safe truncation |
| `test_kiss_modem` | `examples/kiss_modem/KissModem.cpp` | KISS escaping/framing and packet metadata under partial writes, host TX backpressure, queue saturation, and radio completion; run with `native_kiss_modem` |
| `test_mesh_tables` | `src/helpers/SimpleMeshTables.h` | packet and ACK/multipart deduplication, scope-independent identity, route-prefix matching, and deterministic recent-repeater expiry/eviction |
| `test_mqtt_lifecycle` | `src/helpers/MQTTLifecycle.h` | idempotent start/stop, initialization rollback, cooperative stop acknowledgment and timeout, callback ownership, restart, and the OTA flash barrier |
@@ -71,7 +72,7 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_utf8_helpers` | `src/helpers/UTF8Helpers.h` | byte-limit truncation at complete code-point boundaries and rejection of malformed or truncated UTF-8 |
| `test_wifi_ota_seeder_policy` | `src/helpers/WiFiOtaSeederPolicy.h`, `WiFiOtaSeederStatus.h` | listener state versus network availability, serial/TCP folder ownership, detach detection, and bounded status formatting |
| `test_ota` | `src/helpers/ota/` | v2 application/v3 bootloader parser separation; legacy XIAO, generic internal, and exact MeshTower V2 SD embedded identity, vector, capability, explicit-confirmation, codec-isolation, scratch-headroom/shared-slot no-EndF gates, and no-autofetch gates; container and EndF integrity; protocol codecs; transfer, resume, and layered apply safety; adaptive 2-to-4 block-request window growth and stall contraction; active-transfer priority classification |
| `test_trace_retry` | `src/Mesh.cpp` retry and relay policy | app-v2 and boot-v3 traffic sharing `PAYLOAD_TYPE_OTA=0x0C` and the TempRadio suspend policy; opaque OTA relay behavior; background discovery priority; immediate primary transfer relay, receive-delay bypass, fast CAD retry, and no generic flood retry; trace and non-OTA flood retry timing |
| `test_trace_retry` | `src/Mesh.cpp`, `RTCClock`, `ClockSyncUtils.h`, retry and relay policy | app-v2 and boot-v3 traffic sharing `PAYLOAD_TYPE_OTA=0x0C` and the TempRadio suspend policy; opaque OTA relay behavior; background discovery priority; immediate primary transfer relay, receive-delay bypass, fast CAD retry, and no generic flood retry; trace and non-OTA flood retry timing; backward RTC correction; clock consensus/path policy and the 10-minute default drift threshold |
| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) |
## Conventions (and how to add a suite)
@@ -319,14 +319,27 @@ TEST(CLICommandUtils, RejectsMalformedTerminalPaths) {
"A1B2C3,D4E5F6", route, sizeof(route), 63, path));
}
TEST(CLICommandUtils, MasksOnlyTerminalLoginPasswordInput) {
TEST(CLICommandUtils, MasksTerminalPasswordInput) {
EXPECT_FALSE(mesh::cli::shouldMaskTerminalInput("login"));
EXPECT_FALSE(mesh::cli::shouldMaskTerminalInput("login "));
EXPECT_TRUE(mesh::cli::shouldMaskTerminalInput("login s"));
EXPECT_TRUE(mesh::cli::shouldMaskTerminalInput("LOGIN s"));
EXPECT_TRUE(mesh::cli::shouldMaskTerminalInput(" login secret phrase"));
EXPECT_FALSE(mesh::cli::shouldMaskTerminalInput("set wifi.pwd"));
EXPECT_FALSE(mesh::cli::shouldMaskTerminalInput("set wifi.pwd "));
EXPECT_TRUE(mesh::cli::shouldMaskTerminalInput("set wifi.pwd s"));
EXPECT_TRUE(mesh::cli::shouldMaskTerminalInput("SET WIFI.PWD secret phrase"));
EXPECT_TRUE(mesh::cli::shouldMaskTerminalInput(" set wifi.pwd secret"));
EXPECT_FALSE(mesh::cli::shouldMaskTerminalInput("cmd login secret"));
EXPECT_FALSE(mesh::cli::shouldMaskTerminalInput("cmd set wifi.pwd secret"));
EXPECT_FALSE(mesh::cli::shouldMaskTerminalInput("set wifi.pwd-status secret"));
EXPECT_FALSE(mesh::cli::shouldMaskTerminalInput("login-status"));
const char* login = " login secret phrase";
EXPECT_STREQ("secret phrase", mesh::cli::terminalPasswordInput(login));
const char* wifi = "set wifi.pwd secret phrase";
EXPECT_STREQ("secret phrase", mesh::cli::terminalPasswordInput(wifi));
EXPECT_EQ(nullptr, mesh::cli::terminalPasswordInput("set wifi.pwd "));
}
TEST(CLICommandUtils, BackspaceErasesAsciiAndUtf8Characters) {
@@ -0,0 +1,172 @@
#include <gtest/gtest.h>
#include <helpers/HostCliBridge.h>
TEST(HostCliBridge, ParsesPlainAndCorrelatedRequests) {
mesh::HostCliBridge::RequestView request;
EXPECT_EQ(mesh::HostCliBridge::VALID_HOST_COMMAND,
mesh::HostCliBridge::parseRequest("host cpu-temp", request));
ASSERT_NE(nullptr, request.text);
EXPECT_STREQ("cpu-temp", request.text);
EXPECT_EQ(8U, request.text_len);
EXPECT_EQ(0U, request.correlation_len);
EXPECT_EQ(mesh::HostCliBridge::VALID_HOST_COMMAND,
mesh::HostCliBridge::parseRequest("A7|host get status", request));
ASSERT_NE(nullptr, request.correlation);
EXPECT_EQ(0, memcmp("A7|", request.correlation, 3));
EXPECT_EQ(3U, request.correlation_len);
EXPECT_STREQ("get status", request.text);
EXPECT_EQ(mesh::HostCliBridge::VALID_HOST_COMMAND,
mesh::HostCliBridge::parseRequest(
"host cpu-temp \t\r\n", request));
EXPECT_EQ(8U, request.text_len);
char record[mesh::HostCliBridge::USB_SIGNED_CONTENT_MAX + 1U];
ASSERT_TRUE(mesh::HostCliBridge::formatUsbRequest(
record, sizeof(record), 1U, 2U, request.text, request.text_len));
EXPECT_STREQ("HOSTCLI/1 REQUEST 00000001 0000000000000002 "
"Y3B1LXRlbXA", record);
}
TEST(HostCliBridge, DistinguishesInvalidHostRequestsFromOtherCommands) {
mesh::HostCliBridge::RequestView request;
EXPECT_EQ(mesh::HostCliBridge::INVALID_HOST_COMMAND,
mesh::HostCliBridge::parseRequest("host", request));
EXPECT_EQ(mesh::HostCliBridge::INVALID_HOST_COMMAND,
mesh::HostCliBridge::parseRequest("host ", request));
EXPECT_EQ(mesh::HostCliBridge::NOT_HOST_COMMAND,
mesh::HostCliBridge::parseRequest("host.reply 12345678 OK", request));
EXPECT_EQ(mesh::HostCliBridge::NOT_HOST_COMMAND,
mesh::HostCliBridge::parseRequest("get host", request));
}
TEST(HostCliBridge, EnforcesRemoteCommandLengthWithAndWithoutCorrelation) {
char command[mesh::HostCliBridge::REMOTE_COMMAND_MAX + 2U];
memcpy(command, "host ", 5);
memset(command + 5, 'x', mesh::HostCliBridge::REQUEST_TEXT_MAX);
command[mesh::HostCliBridge::REMOTE_COMMAND_MAX] = 0;
mesh::HostCliBridge::RequestView request;
EXPECT_EQ(mesh::HostCliBridge::VALID_HOST_COMMAND,
mesh::HostCliBridge::parseRequest(command, request));
command[mesh::HostCliBridge::REMOTE_COMMAND_MAX] = 'x';
command[mesh::HostCliBridge::REMOTE_COMMAND_MAX + 1U] = 0;
EXPECT_EQ(mesh::HostCliBridge::INVALID_HOST_COMMAND,
mesh::HostCliBridge::parseRequest(command, request));
memcpy(command, "Q2|host ", 8);
memset(command + 8, 'y',
mesh::HostCliBridge::REQUEST_TEXT_WITH_CORRELATION_MAX);
command[mesh::HostCliBridge::REMOTE_COMMAND_MAX] = 0;
EXPECT_EQ(mesh::HostCliBridge::VALID_HOST_COMMAND,
mesh::HostCliBridge::parseRequest(command, request));
}
TEST(HostCliBridge, FormatsLineSafeBase64UrlUsbRecord) {
char record[mesh::HostCliBridge::USB_SIGNED_CONTENT_MAX + 1U];
ASSERT_TRUE(mesh::HostCliBridge::formatUsbRequest(
record, sizeof(record), 0x89ABCDEFU, 0x0123456789ABCDEFULL,
"cpu-temp", 8));
EXPECT_STREQ(
"HOSTCLI/1 REQUEST 89ABCDEF 0123456789ABCDEF Y3B1LXRlbXA",
record);
const char unsafe[] = "line one\nline two";
ASSERT_TRUE(mesh::HostCliBridge::formatUsbRequest(
record, sizeof(record), 1U, 2U, unsafe, strlen(unsafe)));
EXPECT_EQ(nullptr, strchr(record, '\n'));
EXPECT_EQ(nullptr, strchr(record, '='));
}
TEST(HostCliBridge, ParsesAndFormatsServiceClaimProof) {
uint64_t challenge = 0;
EXPECT_TRUE(mesh::HostCliBridge::parseServiceClaim(
"@claim=0123456789ABCDEF", 23U, challenge));
EXPECT_EQ(0x0123456789ABCDEFULL, challenge);
EXPECT_FALSE(mesh::HostCliBridge::parseServiceClaim(
"@claim=0123456789ABCDEG", 23U, challenge));
EXPECT_FALSE(mesh::HostCliBridge::parseServiceClaim(
"@claim=0000000000000000", 23U, challenge));
EXPECT_FALSE(mesh::HostCliBridge::parseServiceClaim(
"@claim=0123456789ABCDE", 22U, challenge));
char record[mesh::HostCliBridge::CLAIMED_SIGNED_CONTENT_MAX + 1U];
ASSERT_TRUE(mesh::HostCliBridge::formatUsbClaim(
record, sizeof(record), 0x89ABCDEFU, 0x0123456789ABCDEFULL,
0xFEDCBA9876543210ULL));
EXPECT_STREQ(
"HOSTCLI/1 CLAIMED 89ABCDEF 0123456789ABCDEF FEDCBA9876543210",
record);
}
TEST(HostCliBridge, ParsesOnlyBoundedLineSafeUtf8Replies) {
mesh::HostCliBridge::ReplyView reply;
EXPECT_EQ(mesh::HostCliBridge::VALID_HOST_COMMAND,
mesh::HostCliBridge::parseReply(
"host.reply 89abcdef 0123456789abcdef CPU 47.2 C", reply));
EXPECT_EQ(0x89ABCDEFU, reply.request_id);
EXPECT_EQ(0x0123456789ABCDEFULL, reply.request_nonce);
EXPECT_STREQ("CPU 47.2 C", reply.text);
EXPECT_EQ(mesh::HostCliBridge::INVALID_HOST_COMMAND,
mesh::HostCliBridge::parseReply(
"host.reply 89ABCDE 0123456789ABCDEF CPU 1 C", reply));
EXPECT_EQ(mesh::HostCliBridge::INVALID_HOST_COMMAND,
mesh::HostCliBridge::parseReply("host.reply ", reply));
EXPECT_EQ(mesh::HostCliBridge::INVALID_HOST_COMMAND,
mesh::HostCliBridge::parseReply(
"host.reply 89ABCDEF 0123456789ABCDEG CPU 1 C", reply));
EXPECT_EQ(mesh::HostCliBridge::INVALID_HOST_COMMAND,
mesh::HostCliBridge::parseReply(
"host.reply 89ABCDEF 0123456789ABCDEF first\nsecond", reply));
EXPECT_EQ(mesh::HostCliBridge::NOT_HOST_COMMAND,
mesh::HostCliBridge::parseReply(
"host.replyx 89ABCDEF 0123456789ABCDEF OK", reply));
const char valid_utf8[] =
"host.reply 12345678 FEDCBA9876543210 " "\xE2\x82\xAC";
EXPECT_EQ(mesh::HostCliBridge::VALID_HOST_COMMAND,
mesh::HostCliBridge::parseReply(valid_utf8, reply));
const char invalid_utf8[] =
"host.reply 12345678 FEDCBA9876543210 " "\xE2\x82";
EXPECT_EQ(mesh::HostCliBridge::INVALID_HOST_COMMAND,
mesh::HostCliBridge::parseReply(invalid_utf8, reply));
}
TEST(HostCliBridge, EnforcesMaximumReplyLength) {
char command[mesh::HostCliBridge::SERIAL_REPLY_COMMAND_MAX + 2U];
const int prefix_len = snprintf(command, sizeof(command),
"host.reply 12345678 FEDCBA9876543210 ");
ASSERT_GT(prefix_len, 0);
memset(command + prefix_len, 'r', mesh::HostCliBridge::REMOTE_REPLY_MAX);
command[prefix_len + mesh::HostCliBridge::REMOTE_REPLY_MAX] = 0;
mesh::HostCliBridge::ReplyView reply;
EXPECT_EQ(mesh::HostCliBridge::VALID_HOST_COMMAND,
mesh::HostCliBridge::parseReply(command, reply));
command[prefix_len + mesh::HostCliBridge::REMOTE_REPLY_MAX] = 'r';
command[prefix_len + mesh::HostCliBridge::REMOTE_REPLY_MAX + 1U] = 0;
EXPECT_EQ(mesh::HostCliBridge::INVALID_HOST_COMMAND,
mesh::HostCliBridge::parseReply(command, reply));
}
TEST(HostCliBridge, PreservesCorrelationAndDoesNotSplitUtf8AtLimit) {
char output[mesh::HostCliBridge::REMOTE_REPLY_MAX + 1U];
EXPECT_EQ(5U, mesh::HostCliBridge::formatRemoteReply(
output, sizeof(output), "B4|host cpu-temp", "OK"));
EXPECT_STREQ("B4|OK", output);
char short_output[5];
const char service_reply[] = "abc" "\xE2\x82\xAC";
EXPECT_EQ(3U, mesh::HostCliBridge::formatRemoteReply(
short_output, sizeof(short_output), "host test",
service_reply));
EXPECT_STREQ("abc", short_output);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+119 -3
View File
@@ -2,6 +2,7 @@
#include <array>
#include <vector>
#include <cstring>
#include <type_traits>
#include "helpers/ota/MotaContainer.h"
#include "helpers/ota/MerkleTree.h"
@@ -10,6 +11,7 @@
#include "helpers/ota/FirmwareInfo.h"
#include "helpers/ota/MotaSeederProto.h"
#include "helpers/ota/MotaSourceSerial.h"
#include "helpers/ota/FolderMotaStore.h"
#include "helpers/ota/SignerAllowlist.h"
#include "helpers/ota/OtaStore.h"
#include "helpers/ota/OtaProtocol.h"
@@ -688,21 +690,43 @@ public:
}
int read() override {
return response_pos < response.size() ? response[response_pos++] : -1;
if (response_pos < response.size()) return response[response_pos++];
++g_mock_millis;
return -1;
}
void flush() override {
++flush_calls;
if (flush_discards_receive) {
response.clear();
response_pos = 0;
}
}
std::vector<uint32_t> offsets;
std::vector<uint16_t> lengths;
bool request_valid = true;
bool flush_discards_receive = false;
size_t flush_calls = 0;
private:
std::vector<uint8_t> response;
size_t response_pos = 0;
};
static_assert(!std::is_constructible<SerialMotaSource, Stream&>::value,
"mOTA source callers must choose a stream write policy");
static_assert(!std::is_constructible<SerialMotaSource, Stream&, uint32_t>::value,
"a timeout must not be mistaken for a stream write policy");
static_assert(!std::is_constructible<FolderMotaStore, Stream&>::value,
"folder store callers must choose a stream write policy");
static_assert(!std::is_constructible<FolderMotaStore, Stream&, uint32_t>::value,
"a timeout must not be mistaken for a stream write policy");
TEST(MotaSourceSerial, SplitsOneKilobyteReadsBelowCdcReceiveRing) {
resetArduinoMock();
FakeMotaSeederStream stream;
SerialMotaSource source(stream);
SerialMotaSource source(stream, MotaStreamWritePolicy::FlushTransmit);
std::array<uint8_t, 1024> data{};
ASSERT_TRUE(source.read(0, 0x1000, data.data(), data.size()));
@@ -711,14 +735,16 @@ TEST(MotaSourceSerial, SplitsOneKilobyteReadsBelowCdcReceiveRing) {
(std::vector<uint32_t>{0x1000, 0x10C0, 0x1180, 0x1240, 0x1300, 0x13C0}));
EXPECT_EQ(stream.lengths,
(std::vector<uint16_t>{192, 192, 192, 192, 192, 64}));
EXPECT_EQ(stream.flush_calls, 6U);
for (size_t i = 0; i < data.size(); i++) {
EXPECT_EQ(data[i], (uint8_t)(0x1000 + i));
}
}
TEST(MotaSourceSerial, AcceptsEmptyReadAndRejectsInvalidRange) {
resetArduinoMock();
FakeMotaSeederStream stream;
SerialMotaSource source(stream);
SerialMotaSource source(stream, MotaStreamWritePolicy::FlushTransmit);
uint8_t byte = 0;
EXPECT_TRUE(source.read(0, 123, nullptr, 0));
@@ -727,6 +753,96 @@ TEST(MotaSourceSerial, AcceptsEmptyReadAndRejectsInvalidRange) {
EXPECT_TRUE(stream.offsets.empty());
}
TEST(MotaSourceSerial, NetworkPolicyPreservesReplyWhenFlushWouldDiscardRx) {
resetArduinoMock();
FakeMotaSeederStream stream;
stream.flush_discards_receive = true;
SerialMotaSource source(stream, MotaStreamWritePolicy::NoFlush, 20);
std::array<uint8_t, 16> data{};
ASSERT_TRUE(source.read(0, 0x2000, data.data(), data.size()));
EXPECT_EQ(stream.flush_calls, 0U);
for (size_t i = 0; i < data.size(); i++) {
EXPECT_EQ(data[i], (uint8_t)(0x2000 + i));
}
}
TEST(MotaSourceSerial, WrongFlushPolicyDemonstratesNetworkReplyLoss) {
resetArduinoMock();
FakeMotaSeederStream stream;
stream.flush_discards_receive = true;
SerialMotaSource source(stream, MotaStreamWritePolicy::FlushTransmit, 20);
std::array<uint8_t, 4> data{};
EXPECT_FALSE(source.read(0, 0x3000, data.data(), data.size()));
EXPECT_EQ(stream.flush_calls, 1U);
}
class FakeFolderMotaSeederStream : public Stream {
public:
using Stream::write;
size_t write(const uint8_t* request, size_t len) override {
request_valid = len >= 4
&& request[0] == MOTA_SEEDER_REQ_MAGIC0
&& request[1] == MOTA_SEEDER_REQ_MAGIC1;
if (!request_valid) return len;
uint8_t checksum = request[2];
for (size_t i = 3; i + 1 < len; i++) checksum ^= request[i];
request_valid = checksum == request[len - 1];
if (!request_valid) return len;
operations.push_back(request[2]);
response = {
MOTA_SEEDER_RSP_MAGIC0,
MOTA_SEEDER_RSP_MAGIC1,
request[2],
MS_STATUS_OK,
(uint8_t)(MOTA_SEEDER_RSP_MAGIC0 ^ MOTA_SEEDER_RSP_MAGIC1
^ request[2] ^ MS_STATUS_OK),
};
response_pos = 0;
return len;
}
int read() override {
if (response_pos < response.size()) return response[response_pos++];
++g_mock_millis;
return -1;
}
void flush() override {
++flush_calls;
response.clear();
response_pos = 0;
}
std::vector<uint8_t> operations;
bool request_valid = true;
size_t flush_calls = 0;
private:
std::vector<uint8_t> response;
size_t response_pos = 0;
};
TEST(FolderMotaStore, NetworkPolicyPreservesFastBeginAndWriteReplies) {
resetArduinoMock();
FakeFolderMotaSeederStream stream;
FolderMotaStore store(stream, MotaStreamWritePolicy::NoFlush, 20);
const uint8_t mid[4] = {0x10, 0x20, 0x30, 0x40};
const uint8_t data[4] = {1, 2, 3, 4};
store.set_mid(mid);
ASSERT_TRUE(store.begin(64));
ASSERT_TRUE(store.write(0, data, sizeof(data)));
EXPECT_TRUE(stream.request_valid);
EXPECT_EQ(stream.operations,
(std::vector<uint8_t>{MS_OP_BEGIN, MS_OP_WRITE}));
EXPECT_EQ(stream.flush_calls, 0U);
}
// Build a flashed-image layout (body || fixed 56-byte EndF) the way the host packager / build hook do:
// marker(4) body_len(4) body_hash8(8) fw_version(4) target_id(4) hw_id(32). Identity is always present
// (zero/"" = unknown).
@@ -458,6 +458,10 @@ TEST(ClockSyncPathPolicy, EdgeModeAllowsOnePath) {
EXPECT_FALSE(mesh::clockSyncRequiresUniquePath(true));
}
TEST(ClockSyncDefaults, DriftCorrectionThresholdIsTenMinutes) {
EXPECT_EQ(600U, mesh::CLOCK_SYNC_DRIFT_DEFAULT_SECONDS);
}
TEST(MeshReceiveHooks, GroupPacketIsObservedWhenForwardingIsDisabled) {
TraceTestClock clock;
TraceTestRTC rtc;
+176 -11
View File
@@ -77,6 +77,12 @@ INSTALL_RECONCILE_WAIT_SECONDS = 20
DEFAULT_RELAY_TX_DELAY = 0.3
RELAY_TIMING_COMMANDS_PER_RELAY = 12
RELAY_TIMING_RECOVERY_FILE = "relay-timing-settings.json"
MIN_MESHCLI_VERSION = (1, 6, 0)
# This module is also imported by the pinned RAK3401 chain runner and by the
# offline test suite. Keep diagnostics disabled until either CLI main enables
# them; an uninitialized module global would break those direct callers.
DEBUG = False
T = TypeVar("T")
@@ -862,13 +868,69 @@ def load_base_image(path: Path, target: TargetInfo) -> EndFInfo:
return identity
def redact_text(value: str, sensitive_values: tuple[str, ...] = ()) -> str:
for sensitive in sensitive_values:
if sensitive:
value = value.replace(sensitive, "<REDACTED>")
return value
def _debug_text(
value: str | bytes | None,
sensitive_values: tuple[str, ...] = (),
) -> str:
if value is None:
return "<empty>"
if isinstance(value, bytes):
value = value.decode("utf-8", "replace")
return redact_text(value, sensitive_values).rstrip() or "<empty>"
def debug_stream(
label: str,
value: str | bytes | None,
sensitive_values: tuple[str, ...] = (),
) -> None:
if not DEBUG:
return
print(f"[debug] {label}:")
print(_debug_text(value, sensitive_values))
def meshcli_sensitive_values(commands: list[str]) -> tuple[str, ...]:
return tuple(
commands[index + 2]
for index, token in enumerate(commands)
if token == "login" and index + 2 < len(commands)
)
def redact_meshcli_commands(commands: list[str]) -> list[str]:
"""Return a printable copy with every remote-admin password removed."""
redacted = list(commands)
for index, token in enumerate(commands):
# Controller command chains encode login as three consecutive tokens:
# login, target, password. Redact from the original list so a target or
# command literally named "login" cannot hide a later real password.
if token == "login" and index + 2 < len(redacted):
redacted[index + 2] = "<REDACTED>"
return redacted
def run_checked(
command: list[str],
*,
label: str,
timeout: float | None = None,
sensitive_values: tuple[str, ...] = (),
) -> subprocess.CompletedProcess[str]:
print(f"[run] {label}")
if DEBUG:
printable_command = [
redact_text(token, sensitive_values) for token in command
]
print(f"[debug] command: {shlex.join(printable_command)}")
print(f"[debug] timeout: {timeout if timeout is not None else 'none'}")
try:
result = subprocess.run(
command,
@@ -881,9 +943,19 @@ def run_checked(
except FileNotFoundError as exc:
raise OtaError(f"required command was not found: {command[0]}") from exc
except subprocess.TimeoutExpired as exc:
if DEBUG:
print("[debug] timed out")
debug_stream("partial stdout", exc.stdout, sensitive_values)
debug_stream("partial stderr", exc.stderr, sensitive_values)
raise OtaError(f"timed out while running {label}") from exc
if DEBUG:
print(f"[debug] exit code: {result.returncode}")
debug_stream("stdout", result.stdout, sensitive_values)
debug_stream("stderr", result.stderr, sensitive_values)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
detail = redact_text(
(result.stderr or result.stdout).strip(), sensitive_values
)
raise OtaError(f"{label} failed: {detail or f'exit {result.returncode}'}")
return result
@@ -1162,9 +1234,15 @@ class PersistentMeshcliSession:
self.close()
self.output_queue = queue.Queue()
self.pending = bytearray()
launch_command = [*self.command, "-C", "-i"]
if DEBUG:
print(
"[debug] persistent meshcli launch: "
f"{shlex.join(launch_command)}"
)
try:
self.process = subprocess.Popen(
[*self.command, "-C", "-i"],
launch_command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
@@ -1189,7 +1267,13 @@ class PersistentMeshcliSession:
rb"(?:^|\r?\n)" + re.escape(marker.encode("ascii")) + rb"\r?\n"
)
def _read_frame(self, start: str, end: str, timeout: float) -> str:
def _read_frame(
self,
start: str,
end: str,
timeout: float,
sensitive_values: tuple[str, ...] = (),
) -> str:
start_pattern = self._line_pattern(start)
end_pattern = self._line_pattern(end)
deadline = time.monotonic() + timeout
@@ -1208,6 +1292,11 @@ class PersistentMeshcliSession:
raise OtaError("persistent meshcli output exceeded 8 MiB")
remaining = deadline - time.monotonic()
if remaining <= 0:
debug_stream(
"persistent meshcli pending output",
self.pending[-4096:],
sensitive_values,
)
self.close()
raise OtaError("persistent meshcli command timed out")
try:
@@ -1215,6 +1304,10 @@ class PersistentMeshcliSession:
except queue.Empty:
if self.process is not None and self.process.poll() is not None:
detail = bytes(self.pending[-4096:]).decode("utf-8", "replace")
debug_stream(
"persistent meshcli final output", detail,
sensitive_values,
)
self.close()
raise OtaError(
"persistent meshcli session exited"
@@ -1223,6 +1316,10 @@ class PersistentMeshcliSession:
continue
if chunk is None:
detail = bytes(self.pending[-4096:]).decode("utf-8", "replace")
debug_stream(
"persistent meshcli final output", detail,
sensitive_values,
)
self.close()
raise OtaError(
"persistent meshcli session closed"
@@ -1236,18 +1333,31 @@ class PersistentMeshcliSession:
start_marker: str,
end_marker: str,
timeout: float,
sensitive_values: tuple[str, ...] = (),
) -> str:
if self.process is None or self.process.poll() is not None:
self._start()
assert self.process is not None and self.process.stdin is not None
command = shlex.join(["script", str(script_path)]) + "\n"
if DEBUG:
print(f"[debug] persistent meshcli input: {command.rstrip()}")
print(f"[debug] persistent meshcli timeout: {timeout}")
try:
self.process.stdin.write(command.encode("utf-8"))
self.process.stdin.flush()
except (BrokenPipeError, OSError) as exc:
self.close()
raise OtaError(f"persistent meshcli input failed: {exc}") from exc
output = self._read_frame(start_marker, end_marker, timeout)
output = self._read_frame(
start_marker, end_marker, timeout, sensitive_values
)
output = redact_text(output, sensitive_values)
if DEBUG:
print("[debug] persistent meshcli process: running")
debug_stream(
"persistent meshcli stdout", output, sensitive_values
)
debug_stream("persistent meshcli stderr", None)
if not self.announced:
print("[controller] persistent meshcli connection established")
self.announced = True
@@ -1338,6 +1448,7 @@ class Controller:
# us one safely quoted command line. The temporary file is removed even
# when meshcli times out or fails.
script_path: Path | None = None
sensitive_values = meshcli_sensitive_values(commands)
frame_start = f"MESHCORE_HOST_START_{secrets.token_hex(16)}"
frame_end = f"MESHCORE_HOST_END_{secrets.token_hex(16)}"
try:
@@ -1354,12 +1465,18 @@ class Controller:
script.write(shlex.join(["echo", frame_end]))
script.write("\n")
script_path = Path(script.name)
if DEBUG:
printable = redact_meshcli_commands(commands)
print(f"[debug] meshcli operation: {label}")
print(f"[debug] meshcli commands: {shlex.join(printable)}")
print(f"[debug] meshcli script: {script_path}")
if os.name != "nt":
script_path.chmod(0o600)
timeout = max(90, self.reply_timeout + 60)
if self._meshcli_session is not None:
stdout = self._meshcli_session.run_script(
script_path, frame_start, frame_end, timeout
script_path, frame_start, frame_end, timeout,
sensitive_values,
)
result = subprocess.CompletedProcess(
[], 0, stdout=stdout, stderr=""
@@ -1369,11 +1486,21 @@ class Controller:
self.meshcli, "-j", "-c", "off", *self.connection,
"script", str(script_path),
]
result = run_checked(command, label=label, timeout=timeout)
result = run_checked(
command,
label=label,
timeout=timeout,
sensitive_values=sensitive_values,
)
finally:
if script_path is not None:
script_path.unlink(missing_ok=True)
return result
return subprocess.CompletedProcess(
result.args,
result.returncode,
stdout=redact_text(result.stdout, sensitive_values),
stderr=redact_text(result.stderr, sensitive_values),
)
@staticmethod
def _no_json_error(result: subprocess.CompletedProcess[str], label: str) -> OtaError:
@@ -1753,7 +1880,15 @@ def source_cli_command(args: argparse.Namespace, command_text: str, check: bool
except (OSError, UnicodeError) as exc:
raise TransmissionError(f"source TCP console failed: {exc}") from exc
text = response.decode("utf-8", "replace")
match = re.search(r"(?:^|\r?\n)\s*->\s*(.*?)\r?\n>\s*$", text, re.DOTALL)
# Legacy port 5002 prefixes its bounded OTA reply with `->`.
# Full Companion now exposes the same terminal as USB and writes
# the command reply directly. Accept both wire formats so current
# automation can manage old and new source firmware.
match = re.search(
r"(?:^|\r?\n)[ \t]*(?:->[ \t]*)?(.*?)\r?\n>[ \t]*$",
text,
re.DOTALL,
)
if not match:
raise TransmissionError(
f"source TCP console returned no command reply: {text.strip() or 'no output'}"
@@ -1834,7 +1969,7 @@ def preflight_source_cli(args: argparse.Namespace) -> None:
raise OtaError(
"OTA source did not return a valid `ota status`. Use an OTA-enabled "
"repeater raw text CLI, nRF52 full Companion USB port, or "
"companion_radio_full TCP console."
"companion_radio_full TCP terminal."
)
@@ -2905,7 +3040,7 @@ def build_parser() -> argparse.ArgumentParser:
)
source_cli.add_argument(
"--source-cli-tcp", metavar="HOST[:PORT]",
help="companion_radio_full text console for a TCP seeder source (default port 5002)",
help="companion_radio_full TCP terminal for a seeder source (default port 5002)",
)
parser.add_argument("--controller-baud", type=int, default=115200)
parser.add_argument("--source-baud", type=int, default=115200)
@@ -2951,6 +3086,14 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--work-dir", type=Path)
parser.add_argument("--meshcli", default="meshcli")
parser.add_argument("--motatool", default="motatool")
parser.add_argument(
"--debug",
action="store_true",
help=(
"show redacted motatool/meshcli commands, subprocess status, "
"stdout, stderr, and timeouts"
),
)
parser.add_argument("--reply-timeout", type=int, default=20)
parser.add_argument("--discovery-timeout", type=int, default=180)
parser.add_argument("--discovery-interval", type=int, default=8)
@@ -3178,6 +3321,25 @@ def require_command(command: str, label: str) -> None:
)
def require_meshcli_version(command: str) -> tuple[int, int, int]:
require_command(command, "meshcli")
result = run_checked(
[command, "-v"], label="read meshcli version", timeout=30
)
match = re.search(
r"\bv(\d+)\.(\d+)\.(\d+)\b", result.stdout + result.stderr
)
if match is None:
raise OtaError("could not determine meshcli version")
version = tuple(int(part) for part in match.groups())
if version < MIN_MESHCLI_VERSION:
need = ".".join(str(part) for part in MIN_MESHCLI_VERSION)
got = ".".join(str(part) for part in version)
raise OtaError(f"meshcli {got} is too old; install {need} or newer")
print(f"[host] meshcli {'.'.join(str(part) for part in version)}")
return version
def preflight_inputs(args: argparse.Namespace) -> None:
if not args.package.is_file():
raise OtaError(f"package does not exist: {args.package.resolve()}")
@@ -3192,7 +3354,7 @@ def preflight_inputs(args: argparse.Namespace) -> None:
raise OtaError(f"{label} file does not exist: {path.resolve()}")
require_command(args.motatool, "motatool")
if not args.prepare_only:
require_command(args.meshcli, "meshcli")
require_meshcli_version(args.meshcli)
def make_work_dir(requested: Path | None) -> Path:
@@ -3234,8 +3396,11 @@ def main(
*,
controller_override: Controller | None = None,
) -> int:
global DEBUG
parser = build_parser()
args = parser.parse_args(argv)
DEBUG = bool(args.debug)
validate_args(args, parser)
work_dir: Path | None = None
controller: Controller | None = controller_override
+10 -12
View File
@@ -125,7 +125,6 @@ EXPECTED_STEP_COUNT = 9
EXPECTED_START_VERSION = "1.16.7.0"
EXPECTED_FINAL_VERSION = "1.17.1.02"
SUPERSEDED_FINAL_VERSION = "1.17.1.0"
MIN_MESHCLI_VERSION = (1, 6, 0)
WATCHDOG_RESET_WAIT_SECONDS = 90
WATCHDOG_STABILITY_WAIT_SECONDS = 90
TRANSFER_SETTINGS_FILE = "target-transfer-settings.json"
@@ -753,17 +752,7 @@ def verify_motatool(args: argparse.Namespace, steps: list[ChainStep]) -> None:
def require_meshcli_version(command: str) -> None:
ota.require_command(command, "meshcli")
result = ota.run_checked([command, "-v"], label="read meshcli version", timeout=30)
match = re.search(r"\bv(\d+)\.(\d+)\.(\d+)\b", result.stdout + result.stderr)
if match is None:
raise ota.OtaError("could not determine meshcli version")
version = tuple(int(part) for part in match.groups())
if version < MIN_MESHCLI_VERSION:
need = ".".join(str(part) for part in MIN_MESHCLI_VERSION)
got = ".".join(str(part) for part in version)
raise ota.OtaError(f"meshcli {got} is too old; install {need} or newer")
print(f"[host] meshcli {'.'.join(str(part) for part in version)}")
ota.require_meshcli_version(command)
def controller_namespace(args: argparse.Namespace, target: str = "pending") -> SimpleNamespace:
@@ -1415,6 +1404,14 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--source-baud", type=int, default=115200)
parser.add_argument("--meshcli", default="meshcli")
parser.add_argument("--motatool", default="motatool")
parser.add_argument(
"--debug",
action="store_true",
help=(
"show redacted motatool/meshcli commands, subprocess status, "
"stdout, stderr, and timeouts"
),
)
parser.add_argument("--reply-timeout", type=int, default=45)
parser.add_argument("--discovery-timeout", type=int, default=180)
parser.add_argument("--discovery-interval", type=int, default=8)
@@ -1570,6 +1567,7 @@ def confirm_chain(
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
ota.DEBUG = bool(args.debug)
validate_args(args, parser)
work_dir = args.work_dir.resolve()
work_dir.mkdir(parents=True, exist_ok=True)
+161 -2
View File
@@ -271,13 +271,150 @@ class FormatTests(unittest.TestCase):
ota.read_bounded_file(path, 3, "mOTA file")
class DebugTests(unittest.TestCase):
def setUp(self) -> None:
self.previous_debug = ota.DEBUG
ota.DEBUG = False
def tearDown(self) -> None:
ota.DEBUG = self.previous_debug
def test_debug_flag_is_available_in_both_runners(self) -> None:
generic = ota.build_parser().parse_args(
["release.mota", "remote", "--debug"]
)
chain = rak_chain.build_parser().parse_args(["--debug"])
self.assertTrue(generic.debug)
self.assertTrue(chain.debug)
self.assertFalse(
ota.build_parser().parse_args(["release.mota", "remote"]).debug
)
def test_run_checked_is_safe_for_direct_module_callers(self) -> None:
completed = subprocess.CompletedProcess(
args=["tool"], returncode=0, stdout="ok", stderr=""
)
with mock.patch.object(ota.subprocess, "run", return_value=completed):
result = ota.run_checked(["tool"], label="direct call")
self.assertEqual(result.stdout, "ok")
def test_debug_reports_success_and_timeout_output(self) -> None:
ota.DEBUG = True
completed = subprocess.CompletedProcess(
args=["tool"], returncode=0, stdout="hello\n", stderr="warning\n"
)
output = io.StringIO()
with (
mock.patch.object(ota.subprocess, "run", return_value=completed),
contextlib.redirect_stdout(output),
):
ota.run_checked(
["tool", "argument with spaces"], label="debug success", timeout=5
)
rendered = output.getvalue()
self.assertIn("command: tool 'argument with spaces'", rendered)
self.assertIn("timeout: 5", rendered)
self.assertIn("exit code: 0", rendered)
self.assertIn("hello", rendered)
self.assertIn("warning", rendered)
timeout = subprocess.TimeoutExpired(
["tool"], 3, output="partial out", stderr="partial err"
)
output = io.StringIO()
with (
mock.patch.object(ota.subprocess, "run", side_effect=timeout),
contextlib.redirect_stdout(output),
self.assertRaisesRegex(ota.OtaError, "timed out"),
):
ota.run_checked(["tool"], label="debug timeout", timeout=3)
rendered = output.getvalue()
self.assertIn("partial out", rendered)
self.assertIn("partial err", rendered)
def test_run_checked_redacts_secrets_from_debug_command_and_output(self) -> None:
ota.DEBUG = True
completed = subprocess.CompletedProcess(
args=[], returncode=0, stdout="reply top-secret", stderr=""
)
output = io.StringIO()
with (
mock.patch.object(ota.subprocess, "run", return_value=completed),
contextlib.redirect_stdout(output),
):
ota.run_checked(
["tool", "--password", "top-secret"],
label="redaction test",
sensitive_values=("top-secret",),
)
rendered = output.getvalue()
self.assertNotIn("top-secret", rendered)
self.assertIn("<REDACTED>", rendered)
def test_meshcli_debug_redacts_admin_password(self) -> None:
ota.DEBUG = True
args = argparse.Namespace(
meshcli="meshcli",
reply_timeout=20,
controller_serial="/dev/controller",
controller_tcp=None,
controller_ble=None,
controller_baud=115200,
)
controller = ota.Controller(args, "top-secret", persistent=False)
completed = subprocess.CompletedProcess(
args=[], returncode=0, stdout="echo top-secret", stderr=""
)
output = io.StringIO()
with (
mock.patch.object(ota, "run_checked", return_value=completed),
contextlib.redirect_stdout(output),
):
result = controller._execute(
[
"contact_info", "login", "login", "login", "top-secret",
"sync_msgs",
],
"redaction test",
)
rendered = output.getvalue()
self.assertNotIn("top-secret", rendered)
self.assertIn("<REDACTED>", rendered)
self.assertNotIn("top-secret", result.stdout)
def test_meshcli_minimum_version_is_enforced(self) -> None:
old = subprocess.CompletedProcess(
args=[], returncode=0,
stdout="meshcore-cli version v1.5.9", stderr="",
)
with (
mock.patch.object(ota, "require_command"),
mock.patch.object(ota, "run_checked", return_value=old),
self.assertRaisesRegex(ota.OtaError, "too old"),
):
ota.require_meshcli_version("meshcli")
current = subprocess.CompletedProcess(
args=[], returncode=0,
stdout="meshcore-cli version v1.6.0", stderr="",
)
with (
mock.patch.object(ota, "require_command"),
mock.patch.object(ota, "run_checked", return_value=current),
contextlib.redirect_stdout(io.StringIO()),
):
self.assertEqual(
ota.require_meshcli_version("meshcli"), (1, 6, 0)
)
class SourceCliTests(unittest.TestCase):
def test_full_companion_tcp_console_command(self) -> None:
connection = mock.MagicMock()
connection.__enter__.return_value = connection
connection.recv.side_effect = [
b"OTA console - type `ota ...`\r\n> ",
b" -> OTA seeder | install:disabled | serving:1\r\n> ",
b"===== MeshCore Full Companion Terminal =====\r\n> ",
b" OTA seeder | install:disabled | serving:1\r\n> ",
]
args = argparse.Namespace(
source_cli_serial=None,
@@ -296,6 +433,28 @@ class SourceCliTests(unittest.TestCase):
connection.sendall.assert_called_once_with(b"ota status\r\n")
self.assertEqual(output, "OTA seeder | install:disabled | serving:1")
def test_legacy_tcp_ota_console_reply_is_still_accepted(self) -> None:
connection = mock.MagicMock()
connection.__enter__.return_value = connection
connection.recv.side_effect = [
b"OTA console - type `ota ...`\r\n> ",
b" -> OTA seeder | install:disabled | serving:1\r\n> ",
]
args = argparse.Namespace(
source_cli_serial=None,
source_serial=None,
source_cli_tcp="192.0.2.10",
meshcli="meshcli",
source_baud=115200,
)
with mock.patch.object(
ota.socket, "create_connection", return_value=connection
):
output = ota.source_cli_command(args, "ota status")
self.assertEqual(output, "OTA seeder | install:disabled | serving:1")
def test_full_companion_tcp_source_arguments_validate(self) -> None:
parser = ota.build_parser()
args = parser.parse_args([
+1
View File
@@ -60,6 +60,7 @@ build_flags = ${lora_e5.build_flags}
-D LFS_FLASH_TOTAL_SIZE=16384
-D MESH_ENABLE_CLOCK_SYNC=0 ; preserve the 16 KiB filesystem on this 256 KiB part
-D MESH_ENABLE_FLOOD_GROUP_MODERATION=0 ; keep core routing and radio scheduling in flash
-D MESH_ENABLE_HOST_CLI=0 ; RS232 bridge image cannot also fit the USB/MQTT host service
-D MESH_ENABLE_RECENT_REPEATERS=1
-D LORA_TX_POWER=22
-D ADVERT_NAME='"WIO-E5 Repeater"'