diff --git a/docs/WiFi.md b/docs/WiFi.md index 816d4544..082b9073 100644 --- a/docs/WiFi.md +++ b/docs/WiFi.md @@ -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. diff --git a/docs/cli_build_matrix.md b/docs/cli_build_matrix.md index 28c7bd6a..52d7404f 100644 --- a/docs/cli_build_matrix.md +++ b/docs/cli_build_matrix.md @@ -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. diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index dcccc0b6..5727070a 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -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 `](cli_commands.md#send-a-repeater-flood-text) | Repeater | Yes | Yes | Yes | +| System | [`host `; `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 `](cli_commands.md#send-a-repeater-flood-text) | Repeater | Yes | Yes | Yes | Yes | Yes | +| System | [`host `; `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 | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 034613ef..267a8a14 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -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 ` - 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 [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 diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index 7b189573..b21aa519 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -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 ` and `cmd `, 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 diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index 97cd1e92..bb3178c8 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -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 `, `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 ` | `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 ` | `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 `, `set outpath direct`, `set outpath clear`, `set outpath flood` | `set outpath A1B2C3,D4E5F6` | | `altpath` | Adds a secondary direct route for repeater replies to the current remote client. | `get altpath`, `set altpath `, `set altpath direct`, `set altpath clear`, `set altpath flood` | `set altpath 71CE82,BA09F0` | diff --git a/docs/host_cli_service.md b/docs/host_cli_service.md new file mode 100644 index 00000000..9337fdd4 --- /dev/null +++ b/docs/host_cli_service.md @@ -0,0 +1,182 @@ +# LoRa CLI Host Service + +The repeater `host ` 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 [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 [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=` 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. diff --git a/docs/index.md b/docs/index.md index 4cdb4cba..88717c92 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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) diff --git a/docs/lora_ota_automation.md b/docs/lora_ota_automation.md index a69758b6..70b84b31 100644 --- a/docs/lora_ota_automation.md +++ b/docs/lora_ota_automation.md @@ -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: diff --git a/docs/ota_easy.md b/docs/ota_easy.md index 4a6e9e2e..75bf2ea1 100644 --- a/docs/ota_easy.md +++ b/docs/ota_easy.md @@ -134,8 +134,9 @@ motatool serve --dir ./motas --tcp :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 diff --git a/docs/telemetry_decoder.md b/docs/telemetry_decoder.md index b3a8cb22..4e3311c0 100644 --- a/docs/telemetry_decoder.md +++ b/docs/telemetry_decoder.md @@ -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
diff --git a/examples/companion_radio/CompanionWiFi.h b/examples/companion_radio/CompanionWiFi.h index 825b40f1..a1a009bd 100644 --- a/examples/companion_radio/CompanionWiFi.h +++ b/examples/companion_radio/CompanionWiFi.h @@ -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 diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index d50d7d1d..15107afb 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -586,12 +586,13 @@ void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path } #ifdef ENABLE_USB_INTERFACE - if (_terminal_mode && _terminal_display.shouldShowAdvert()) { - Serial.printf("\r\nADVERT from -> %s\r\n", contact.name); - Serial.printf(" type: %s\r\n", terminalContactTypeName(contact.type)); - Serial.print(" public key: "); - mesh::Utils::printHex(Serial, contact.id.pub_key, PUB_KEY_SIZE); - Serial.print("\r\n> "); + if (hasTerminalOutput() && _terminal_display.shouldShowAdvert()) { + Stream& output = terminalOutput(); + output.printf("\r\nADVERT from -> %s\r\n", contact.name); + output.printf(" type: %s\r\n", terminalContactTypeName(contact.type)); + output.print(" public key: "); + mesh::Utils::printHex(output, contact.id.pub_key, PUB_KEY_SIZE); + output.print("\r\n> "); } #endif @@ -639,11 +640,12 @@ int MyMesh::getRecentlyHeard(AdvertPath dest[], int max_num) { void MyMesh::onContactVisit(const ContactInfo& contact) { if (contact.type == ADV_TYPE_NONE) return; - Serial.printf(" %s (%s) - ", contact.name, terminalContactTypeName(contact.type)); + Stream& output = terminalOutput(); + output.printf(" %s (%s) - ", contact.name, terminalContactTypeName(contact.type)); char relative_time[40]; int32_t seconds_from_now = contact.last_advert_timestamp - getRTCClock()->getCurrentTime(); AdvertTimeHelper::formatRelativeTimeDiff(relative_time, seconds_from_now, false); - Serial.println(relative_time); + output.println(relative_time); } #endif @@ -655,8 +657,8 @@ void MyMesh::onContactPathUpdated(const ContactInfo &contact) { scheduleContactWrite(contact); #ifdef ENABLE_USB_INTERFACE - if (_terminal_mode) { - Serial.printf("\r\nPATH updated -> %s\r\n> ", contact.name); + if (hasTerminalOutput()) { + terminalOutput().printf("\r\nPATH updated -> %s\r\n> ", contact.name); } #endif } @@ -682,8 +684,8 @@ void MyMesh::expireExpectedAcks() { if (entry.expires_at == now || millisHasNowPassed(entry.expires_at)) { if (!hasActiveRetries(entry.retry_key)) { #ifdef ENABLE_USB_INTERFACE - if (entry.terminal_origin && _terminal_mode) { - Serial.print("\r\n ERROR: timed out, no ACK.\r\n> "); + if (entry.terminal_origin && hasTerminalOutput()) { + terminalOutput().print("\r\n ERROR: timed out, no ACK.\r\n> "); } #endif clearExpectedAck(entry, false); @@ -736,9 +738,9 @@ ContactInfo* MyMesh::processAck(const uint8_t *data) { _serial->writeFrame(out_frame, 9); #ifdef ENABLE_USB_INTERFACE - if (expected_ack_table[i].terminal_origin && _terminal_mode) { - Serial.printf("\r\n Got ACK! (round trip: %lu ms)\r\n> ", - (unsigned long)trip_time); + if (expected_ack_table[i].terminal_origin && hasTerminalOutput()) { + terminalOutput().printf("\r\n Got ACK! (round trip: %lu ms)\r\n> ", + (unsigned long)trip_time); } #endif @@ -791,17 +793,17 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe } #ifdef ENABLE_USB_INTERFACE - if (_terminal_mode) { + if (hasTerminalOutput()) { + Stream& output = terminalOutput(); const char* kind = txt_type == TXT_TYPE_CLI_DATA ? "CLI" : "MSG"; - Serial.printf("\r\n(%s) %s -> from %s", - mesh::cli::terminalInboundRouteLabel( - pkt->isRouteDirect()), kind, - from.name); + output.printf("\r\n(%s) %s -> from %s", + mesh::cli::terminalInboundRouteLabel(pkt->isRouteDirect()), + kind, from.name); if (terminal_command_reply) { - Serial.printf(" (round trip %lu ms)", + output.printf(" (round trip %lu ms)", (unsigned long)terminal_command_elapsed_millis); } - Serial.printf("\r\n %s\r\n> ", text); + output.printf("\r\n %s\r\n> ", text); } #endif @@ -963,12 +965,13 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe } #ifdef ENABLE_USB_INTERFACE - if (_terminal_mode + if (hasTerminalOutput() && _terminal_display.shouldShowChannel(is_emergency_channel)) { ChannelDetails details; const char* channel_name = getChannel(channel_idx, details) ? details.name : "Unknown"; - Serial.printf("\r\nCHANNEL MSG -> %s (%s)\r\n %s\r\n> ", - channel_name, pkt->isRouteDirect() ? "DIRECT" : "FLOOD", text); + terminalOutput().printf( + "\r\nCHANNEL MSG -> %s (%s)\r\n %s\r\n> ", channel_name, + pkt->isRouteDirect() ? "DIRECT" : "FLOOD", text); } #endif #ifdef DISPLAY_CLASS @@ -1143,16 +1146,17 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, _serial->writeFrame(out_frame, i); #ifdef ENABLE_USB_INTERFACE if (terminal_login_response) { - if (_terminal_mode) { + if (hasTerminalOutput()) { + Stream& output = terminalOutput(); if (login_success && modern_login) { - Serial.printf("\r\nLOGIN -> %s accepted (ACL permissions 0x%02X, server v%u)\r\n> ", - _terminal_login_target, (unsigned)data[7], - (unsigned)data[12]); + output.printf( + "\r\nLOGIN -> %s accepted (ACL permissions 0x%02X, server v%u)\r\n> ", + _terminal_login_target, (unsigned)data[7], (unsigned)data[12]); } else if (login_success) { - Serial.printf("\r\nLOGIN -> %s accepted (legacy server)\r\n> ", + output.printf("\r\nLOGIN -> %s accepted (legacy server)\r\n> ", _terminal_login_target); } else { - Serial.printf("\r\nLOGIN -> %s rejected\r\n> ", + output.printf("\r\nLOGIN -> %s rejected\r\n> ", _terminal_login_target); } } @@ -1294,29 +1298,30 @@ void MyMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, out_frame[i++] = (int8_t)(packet->getSNR() * 4); // extra/final SNR (to this node) #ifdef ENABLE_USB_INTERFACE - if (_terminal_mode && _terminal_trace_pending + if (hasTerminalOutput() && _terminal_trace_pending && tag == _terminal_trace_tag && auth_code == _terminal_trace_auth) { + Stream& output = terminalOutput(); const uint8_t hash_size = _terminal_trace_hash_size; const uint8_t hop_count = hash_size == 0 ? 0 : path_len / hash_size; const uint8_t response_hash_size = 1 << (flags & 0x03); const unsigned long elapsed = _ms->getMillis() - _terminal_trace_sent_at; - Serial.printf("\r\nTRACE -> %s (%lu ms)\r\n", + output.printf("\r\nTRACE -> %s (%lu ms)\r\n", _terminal_trace_target, elapsed); if (hash_size == 0 || response_hash_size != hash_size || path_len % hash_size != 0 || hop_count >= MAX_PATH_SIZE) { - Serial.print(" ERROR: malformed trace response\r\n> "); + output.print(" ERROR: malformed trace response\r\n> "); } else { - Serial.print(" "); + output.print(" "); for (uint8_t hop = 0; hop < hop_count; hop++) { - Serial.print(((float)(int8_t)path_snrs[hop]) / 4.0f, 2); - Serial.print(" dB -> ["); - mesh::Utils::printHex(Serial, &path_hashes[hop * hash_size], + output.print(((float)(int8_t)path_snrs[hop]) / 4.0f, 2); + output.print(" dB -> ["); + mesh::Utils::printHex(output, &path_hashes[hop * hash_size], hash_size); - Serial.print("] -> "); + output.print("] -> "); } - Serial.print(packet->getSNR(), 2); - Serial.print(" dB\r\n> "); + output.print(packet->getSNR(), 2); + output.print(" dB\r\n> "); } clearTerminalTrace(); } @@ -1350,6 +1355,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _cli_rescue = false; #ifdef ENABLE_USB_INTERFACE _terminal_mode = false; + _terminal_output = NULL; _terminal_recipient_set = false; memset(_terminal_recipient_key, 0, sizeof(_terminal_recipient_key)); _terminal_login_pending = false; @@ -1782,6 +1788,148 @@ bool MyMesh::handleFullOtaCommand(const char* command, char* reply, while (*command == ' ') command++; #if defined(ESP32) && defined(WIFI_SSID) +#ifdef WITH_WEBCONFIG + if (strncmp(command, "get ", 4) == 0) { + const mesh::cli::StandaloneWiFiKey wifi_key = + mesh::cli::classifyStandaloneWiFiGet(command + 4); + switch (wifi_key) { + case mesh::cli::StandaloneWiFiKey::SSID: + WebConfigServer::formatWiFiSSID(reply, reply_size); + return true; + case mesh::cli::StandaloneWiFiKey::Status: + WebConfigServer::formatWiFiStatus(reply, reply_size); + return true; + case mesh::cli::StandaloneWiFiKey::CLI: + WebConfigServer::formatWiFiCliStatus(reply, reply_size); + return true; + default: + break; + } + } + + if (strncmp(command, "set ", 4) == 0) { + const char* value = NULL; + const mesh::cli::StandaloneWiFiKey wifi_key = + mesh::cli::classifyStandaloneWiFiSet(command + 4, &value); + bool saved = false; + switch (wifi_key) { + case mesh::cli::StandaloneWiFiKey::SSID: + saved = WebConfigServer::setStandaloneWiFiSSID( + value, reply, reply_size); + break; + case mesh::cli::StandaloneWiFiKey::Password: + saved = WebConfigServer::setStandaloneWiFiPassword( + value, reply, reply_size); + break; + case mesh::cli::StandaloneWiFiKey::CLI: + WebConfigServer::setWiFiCliEnabled(value, reply, reply_size); + return true; + default: + break; + } + if (wifi_key == mesh::cli::StandaloneWiFiKey::SSID + || wifi_key == mesh::cli::StandaloneWiFiKey::Password) { + if (saved) { + char saved_ssid[32] = {0}; + char saved_password[64] = {0}; + uint8_t saved_power_save = mesh::wifi::kDefaultPowerSave; + const bool has_ssid = WebConfigServer::loadStandaloneWiFi( + saved_ssid, sizeof(saved_ssid), saved_password, + sizeof(saved_password), &saved_power_save); + memset(saved_password, 0, sizeof(saved_password)); + if (_webconfig) { + _webconfig->reloadStandaloneWiFi(); + if (has_ssid && _webconfig->isRunning()) { + _webconfig->requestStop(); + } + } + if (has_ssid) { + scheduleCompanionWiFiCredentialReload(); + snprintf(reply, reply_size, + "OK - WiFi %s saved; reconnect scheduled", + wifi_key == mesh::cli::StandaloneWiFiKey::SSID + ? "SSID" : "password"); + } else { + snprintf(reply, reply_size, + "OK - WiFi password saved; set wifi.ssid to connect"); + } + } + return true; + } + } + + if (strcmp(command, "get webui") == 0) { + const bool enabled = WebConfigServer::loadEnabled(true); + if (!_webconfig + || (!_webconfig->isRunning() && !_webconfig->isStopping())) { + snprintf(reply, reply_size, "> %s, inactive", enabled ? "on" : "off"); + } else if (_webconfig->mode() == WebConfigServer::MODE_SETUP) { + char ssid[33] = {0}; + char ip[16] = {0}; + WebConfigServer::getSetupInfo(ssid, sizeof(ssid), ip, sizeof(ip)); + snprintf(reply, reply_size, "> %s, setup AP %s http://%s/", + enabled ? "on" : "off", ssid, ip); + } else if (_webconfig->mode() == WebConfigServer::MODE_CONNECTING) { + snprintf(reply, reply_size, "> %s, connecting to WiFi", + enabled ? "on" : "off"); + } else { + snprintf(reply, reply_size, "> %s, http://%s/", + enabled ? "on" : "off", + WiFi.localIP().toString().c_str()); + } + return true; + } + + if (strcmp(command, "set webui on") == 0 + || strcmp(command, "set webui off") == 0) { + const bool enabled = command[10] == 'o' && command[11] == 'n'; + if (!WebConfigServer::saveEnabled(enabled)) { + snprintf(reply, reply_size, "Error: failed to save webui setting"); + } else if (!enabled) { + if (_webconfig && _webconfig->isRunning()) _webconfig->requestStop(); + snprintf(reply, reply_size, "OK - webui off"); + } else if (_webconfig + && (_webconfig->isRunning() || _webconfig->isStopping())) { + snprintf(reply, reply_size, "OK - webui on (already active)"); + } else { + char start_reply[160] = {0}; + startWebConfig(false, start_reply); + if (strncmp(start_reply, "WebConfig", 9) == 0) { + snprintf(reply, reply_size, "OK - webui on; %s", start_reply); + } else { + snprintf(reply, reply_size, "%s", start_reply); + } + } + return true; + } + + if (strncmp(command, "set webui", 9) == 0 + && (command[9] == 0 || command[9] == ' ' || command[9] == '\t')) { + snprintf(reply, reply_size, "Error: usage set webui on|off"); + return true; + } + + if (strcmp(command, "start webconfig") == 0 + || strcmp(command, "start webconfig ap") == 0) { + startWebConfig(strcmp(command, "start webconfig ap") == 0, reply); + return true; + } + if (strncmp(command, "start webconfig", 15) == 0 + && (command[15] == 0 || command[15] == ' ' || command[15] == '\t')) { + snprintf(reply, reply_size, "ERR: usage start webconfig [ap]"); + return true; + } + if (strcmp(command, "stop webconfig") == 0) { + if (!_webconfig || !_webconfig->isRunning()) { + snprintf(reply, reply_size, "Err: webconfig not running"); + } else { + _webconfig->requestStop(); + snprintf(reply, reply_size, "OK - webconfig stopping"); + } + return true; + } +#endif + if (strcmp(command, "get wifi.powersave") == 0) { formatWiFiPowerSaving(reply, reply_size); return true; @@ -3941,31 +4089,77 @@ bool MyMesh::applyAndSaveRxPowerSaving(const char* value, char* reply) { } #ifdef ENABLE_USB_INTERFACE -void MyMesh::enterTerminalMode() { - _terminal_mode = true; +Stream& MyMesh::terminalOutput() { + return _terminal_output != NULL ? *_terminal_output : Serial; +} + +void MyMesh::resetTerminalSession() { _terminal_recipient_set = false; memset(_terminal_recipient_key, 0, sizeof(_terminal_recipient_key)); clearTerminalLogin(); clearTerminalCommand(); clearTerminalTrace(); - Serial.print("\r\n===== MeshCore Chat Terminal =====\r\n\r\n"); - Serial.printf("WELCOME %s\r\n", _prefs.node_name); - mesh::Utils::printHex(Serial, self_id.pub_key, PUB_KEY_SIZE); - Serial.printf("\r\nCompanion %s\r\n", FIRMWARE_VERSION); - Serial.print(" (enter 'help' for commands)\r\n"); - Serial.print(" (+++MESHCORE-TERM-STOP returns to Binary mode)\r\n\r\n> "); + // A reply from an abandoned TCP or USB terminal session must not be shown + // to whichever terminal connects next. Keep the radio retry alive for the + // Binary Companion interface, but remove its old text-terminal ownership. + for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) { + expected_ack_table[i].terminal_origin = false; + } +} + +void MyMesh::printTerminalBanner(bool show_binary_stop) { + Stream& output = terminalOutput(); +#if defined(COMPANION_RADIO_FULL) + output.print("\r\n===== MeshCore Full Companion Terminal =====\r\n\r\n"); +#else + output.print("\r\n===== MeshCore Companion Terminal =====\r\n\r\n"); +#endif + output.printf("WELCOME %s\r\n", _prefs.node_name); + mesh::Utils::printHex(output, self_id.pub_key, PUB_KEY_SIZE); + output.printf("\r\nCompanion %s\r\n", FIRMWARE_VERSION); + output.print(" (enter 'help' for commands)\r\n"); + if (show_binary_stop) { + output.print(" (+++MESHCORE-TERM-STOP returns to Binary mode)\r\n"); + } else { + output.print(" (disconnect to close this TCP terminal)\r\n"); + } + output.print("\r\n> "); +} + +void MyMesh::enterTerminalMode() { + _terminal_mode = true; + _terminal_output = &Serial; + resetTerminalSession(); + printTerminalBanner(true); } void MyMesh::exitTerminalMode() { _terminal_mode = false; - _terminal_recipient_set = false; - memset(_terminal_recipient_key, 0, sizeof(_terminal_recipient_key)); - clearTerminalLogin(); - clearTerminalCommand(); - clearTerminalTrace(); + resetTerminalSession(); + if (_terminal_output == &Serial) _terminal_output = NULL; } +#if defined(COMPANION_RADIO_FULL) +bool MyMesh::enterNetworkTerminalMode(Stream& output) { + if (_terminal_mode) return false; + _terminal_output = &output; + resetTerminalSession(); + printTerminalBanner(false); + return true; +} + +void MyMesh::exitNetworkTerminalMode(Stream& output) { + if (_terminal_mode || _terminal_output != &output) return; + resetTerminalSession(); + _terminal_output = NULL; +} + +bool MyMesh::isNetworkTerminalMode(const Stream& output) const { + return !_terminal_mode && _terminal_output == &output; +} +#endif + ContactInfo* MyMesh::getTerminalRecipient() { if (!_terminal_recipient_set) return NULL; @@ -3978,30 +4172,30 @@ ContactInfo* MyMesh::getTerminalRecipient() { } void MyMesh::printTerminalPath(const ContactInfo& recipient) { - Serial.printf(" Path to %s: ", recipient.name); + terminalOutput().printf(" Path to %s: ", recipient.name); if (recipient.out_path_len == OUT_PATH_UNKNOWN) { - Serial.print("unknown (next send uses FLOOD)\r\n"); + terminalOutput().print("unknown (next send uses FLOOD)\r\n"); return; } if (!mesh::Packet::isValidPathLen(recipient.out_path_len)) { - Serial.print("invalid\r\n"); + terminalOutput().print("invalid\r\n"); return; } const uint8_t hash_size = (recipient.out_path_len >> 6) + 1; const uint8_t hop_count = recipient.out_path_len & 63; if (hop_count == 0) { - Serial.print("direct (zero hop)\r\n"); + terminalOutput().print("direct (zero hop)\r\n"); return; } for (uint8_t hop = 0; hop < hop_count; hop++) { - if (hop != 0) Serial.print(','); - mesh::Utils::printHex(Serial, + if (hop != 0) terminalOutput().print(','); + mesh::Utils::printHex(terminalOutput(), &recipient.out_path[(size_t)hop * hash_size], hash_size); } - Serial.printf(" (%u %s, %u-byte hashes; used by DIRECT sends)\r\n", + terminalOutput().printf(" (%u %s, %u-byte hashes; used by DIRECT sends)\r\n", (unsigned)hop_count, hop_count == 1 ? "hop" : "hops", (unsigned)hash_size); } @@ -4021,22 +4215,22 @@ void MyMesh::handleTerminalPath(ContactInfo& recipient, case mesh::cli::TerminalPathParseResult::Valid: break; case mesh::cli::TerminalPathParseResult::Missing: - Serial.print(" ERROR: use path \r\n"); + terminalOutput().print(" ERROR: use path \r\n"); return; case mesh::cli::TerminalPathParseResult::InvalidPrefix: - Serial.print(" ERROR: each path hop must be 2, 4, or 6 hex digits\r\n"); + terminalOutput().print(" ERROR: each path hop must be 2, 4, or 6 hex digits\r\n"); return; case mesh::cli::TerminalPathParseResult::MixedPrefixSize: - Serial.print(" ERROR: all path hops must use the same width\r\n"); + terminalOutput().print(" ERROR: all path hops must use the same width\r\n"); return; case mesh::cli::TerminalPathParseResult::InvalidSeparator: - Serial.print(" ERROR: separate path hops with spaces or commas\r\n"); + terminalOutput().print(" ERROR: separate path hops with spaces or commas\r\n"); return; case mesh::cli::TerminalPathParseResult::TooManyHops: - Serial.print(" ERROR: path must contain at most 63 hops\r\n"); + terminalOutput().print(" ERROR: path must contain at most 63 hops\r\n"); return; case mesh::cli::TerminalPathParseResult::RouteTooLong: - Serial.print(" ERROR: path is too long\r\n"); + terminalOutput().print(" ERROR: path is too long\r\n"); return; } @@ -4086,7 +4280,7 @@ void MyMesh::rememberTerminalAck(ContactInfo& recipient, const char* text, void MyMesh::importTerminalCard(char* command) { while (*command == ' ') command++; if (strncmp(command, "meshcore://", 11) != 0) { - Serial.print(" ERROR: invalid card format\r\n"); + terminalOutput().print(" ERROR: invalid card format\r\n"); return; } @@ -4099,31 +4293,31 @@ void MyMesh::importTerminalCard(char* command) { size_t encoded_len = strlen(encoded); if (encoded_len == 0 || (encoded_len & 1) != 0 || encoded_len / 2 > sizeof(_terminal_tmp_buf)) { - Serial.print(" ERROR: invalid card format\r\n"); + terminalOutput().print(" ERROR: invalid card format\r\n"); return; } size_t raw_len = encoded_len / 2; if (!mesh::Utils::fromHex(_terminal_tmp_buf, raw_len, encoded) || !importContact(_terminal_tmp_buf, raw_len)) { - Serial.print(" ERROR: invalid card\r\n"); + terminalOutput().print(" ERROR: invalid card\r\n"); return; } - Serial.print(" OK - contact import queued\r\n"); + terminalOutput().print(" OK - contact import queued\r\n"); } void MyMesh::listTerminalChannels() { bool found = false; - Serial.print("Channels:\r\n"); + terminalOutput().print("Channels:\r\n"); for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { ChannelDetails channel; if (getChannel(i, channel) && channel.name[0] != 0) { - Serial.printf(" %d: %s\r\n", i, channel.name); + terminalOutput().printf(" %d: %s\r\n", i, channel.name); found = true; } } - if (!found) Serial.print(" (none configured)\r\n"); + if (!found) terminalOutput().print(" (none configured)\r\n"); } void MyMesh::handleTerminalDisplayCommand(const char* arguments) { @@ -4133,13 +4327,13 @@ void MyMesh::handleTerminalDisplayCommand(const char* arguments) { if (result == mesh::TerminalDisplayParseResult::InvalidCategory || result == mesh::TerminalDisplayParseResult::InvalidValue) { - Serial.print( + terminalOutput().print( " ERROR: use show [adverts|channels|emergency] [on|off]\r\n"); return; } if (result == mesh::TerminalDisplayParseResult::StatusAll) { - Serial.printf(" Terminal display: adverts %s, channels %s, emergency %s\r\n", + terminalOutput().printf(" Terminal display: adverts %s, channels %s, emergency %s\r\n", _terminal_display.isEnabled( mesh::TerminalDisplayCategory::Adverts) ? "on" : "off", _terminal_display.isEnabled( @@ -4153,7 +4347,7 @@ void MyMesh::handleTerminalDisplayCommand(const char* arguments) { _terminal_display.setEnabled(display_command.category, display_command.enabled); } - Serial.printf(" Terminal display: %s %s\r\n", + terminalOutput().printf(" Terminal display: %s %s\r\n", mesh::terminalDisplayCategoryName(display_command.category), _terminal_display.isEnabled(display_command.category) ? "on" : "off"); @@ -4166,42 +4360,42 @@ void MyMesh::sendTerminalChannelMessage(ChannelDetails& channel, ? MAX_TEXT_LEN - prefix_len : 0; const size_t text_len = text == NULL ? 0 : strlen(text); if (text_len == 0) { - Serial.print(" ERROR: message is empty\r\n"); + terminalOutput().print(" ERROR: message is empty\r\n"); } else if (text_len > max_text_len) { - Serial.printf(" ERROR: message must be 1-%u UTF-8 bytes for this node name\r\n", + terminalOutput().printf(" ERROR: message must be 1-%u UTF-8 bytes for this node name\r\n", (unsigned)max_text_len); } else if (sendGroupMessage(getRTCClock()->getCurrentTimeUnique(), channel.channel, _prefs.node_name, text, text_len)) { - Serial.printf(" Sent to %s.\r\n", channel.name); + terminalOutput().printf(" Sent to %s.\r\n", channel.name); } else { - Serial.print(" ERROR: unable to send\r\n"); + terminalOutput().print(" ERROR: unable to send\r\n"); } } void MyMesh::printTerminalSendStatus(const char* operation, const ContactInfo& recipient, int result, uint32_t timeout_millis) { - Serial.printf(" %s sent to %s (", operation, recipient.name); + terminalOutput().printf(" %s sent to %s (", operation, recipient.name); if (result == MSG_SEND_SENT_FLOOD) { - Serial.print("FLOOD"); + terminalOutput().print("FLOOD"); } else { const uint8_t hop_count = recipient.out_path_len & 63; if (hop_count == 0) { - Serial.print("zero-hop DIRECT"); + terminalOutput().print("zero-hop DIRECT"); } else { const uint8_t hash_size = (recipient.out_path_len >> 6) + 1; - Serial.print("DIRECT via path "); + terminalOutput().print("DIRECT via path "); for (uint8_t hop = 0; hop < hop_count; hop++) { - if (hop != 0) Serial.print(','); + if (hop != 0) terminalOutput().print(','); mesh::Utils::printHex( - Serial, &recipient.out_path[(size_t)hop * hash_size], hash_size); + terminalOutput(), &recipient.out_path[(size_t)hop * hash_size], hash_size); } - Serial.printf("; %u %s, %u-byte hashes", (unsigned)hop_count, + terminalOutput().printf("; %u %s, %u-byte hashes", (unsigned)hop_count, hop_count == 1 ? "hop" : "hops", (unsigned)hash_size); } } - Serial.printf(", timeout %lu ms)\r\n", (unsigned long)timeout_millis); + terminalOutput().printf(", timeout %lu ms)\r\n", (unsigned long)timeout_millis); } void MyMesh::clearTerminalLogin() { @@ -4224,8 +4418,8 @@ void MyMesh::serviceTerminalLogin() { return; } - if (_terminal_mode) { - Serial.printf("\r\n ERROR: login to %s timed out (wrong password or no response).\r\n> ", + if (hasTerminalOutput()) { + terminalOutput().printf("\r\n ERROR: login to %s timed out (wrong password or no response).\r\n> ", _terminal_login_target); } clearTerminalLogin(); @@ -4235,21 +4429,21 @@ void MyMesh::sendTerminalLogin(ContactInfo& recipient, const char* password) { serviceTerminalLogin(); if (_terminal_login_pending) { - Serial.printf(" ERROR: login to %s is still pending\r\n", + terminalOutput().printf(" ERROR: login to %s is still pending\r\n", _terminal_login_target); return; } const size_t password_len = password == NULL ? 0 : strlen(password); if (password_len == 0 || password_len > 15) { - Serial.print(" ERROR: password must be 1-15 UTF-8 bytes\r\n"); + terminalOutput().print(" ERROR: password must be 1-15 UTF-8 bytes\r\n"); return; } uint32_t est_timeout = 0; const int result = sendLogin(recipient, password, est_timeout); if (result == MSG_SEND_FAILED) { - Serial.print(" ERROR: unable to send login\r\n"); + terminalOutput().print(" ERROR: unable to send login\r\n"); return; } @@ -4274,8 +4468,8 @@ void MyMesh::serviceTerminalCommand() { uint32_t elapsed_millis = 0; if (!_terminal_command.expire(_ms->getMillis(), elapsed_millis)) return; - if (_terminal_mode) { - Serial.printf("\r\n ERROR: command to %s timed out after %lu ms.\r\n> ", + if (hasTerminalOutput()) { + terminalOutput().printf("\r\n ERROR: command to %s timed out after %lu ms.\r\n> ", _terminal_command_target, (unsigned long)elapsed_millis); } _terminal_command_target[0] = 0; @@ -4285,14 +4479,14 @@ void MyMesh::sendTerminalCommand(ContactInfo& recipient, const char* command) { serviceTerminalLogin(); if (_terminal_login_pending) { - Serial.printf(" ERROR: login to %s is still pending\r\n", + terminalOutput().printf(" ERROR: login to %s is still pending\r\n", _terminal_login_target); return; } serviceTerminalCommand(); if (_terminal_command.isPending()) { - Serial.printf(" ERROR: command to %s is still pending\r\n", + terminalOutput().printf(" ERROR: command to %s is still pending\r\n", _terminal_command_target); return; } @@ -4310,7 +4504,7 @@ void MyMesh::sendTerminalCommand(ContactInfo& recipient, const size_t command_len = on_air_command == NULL ? 0 : strlen(on_air_command); if (command_len == 0 || command_len > MAX_TEXT_LEN) { - Serial.printf(" ERROR: remote command must be 1-%u UTF-8 bytes\r\n", + terminalOutput().printf(" ERROR: remote command must be 1-%u UTF-8 bytes\r\n", (unsigned)MAX_TEXT_LEN); return; } @@ -4323,7 +4517,7 @@ void MyMesh::sendTerminalCommand(ContactInfo& recipient, const int result = sendCommandData(recipient, timestamp, 0, on_air_command, est_timeout, logical_request_id); if (result == MSG_SEND_FAILED) { - Serial.print(" ERROR: unable to send remote command\r\n"); + terminalOutput().print(" ERROR: unable to send remote command\r\n"); return; } @@ -4353,8 +4547,8 @@ void MyMesh::serviceTerminalTrace() { return; } - if (_terminal_mode) { - Serial.printf("\r\n ERROR: trace to %s timed out.\r\n> ", + if (hasTerminalOutput()) { + terminalOutput().printf("\r\n ERROR: trace to %s timed out.\r\n> ", _terminal_trace_target); } clearTerminalTrace(); @@ -4364,30 +4558,30 @@ void MyMesh::sendTerminalTraceRoute(const uint8_t* route, uint8_t hash_size, uint8_t hop_count, const char* target) { serviceTerminalTrace(); if (_terminal_trace_pending) { - Serial.printf(" ERROR: trace to %s is still pending\r\n", + terminalOutput().printf(" ERROR: trace to %s is still pending\r\n", _terminal_trace_target); return; } if (route == NULL || hop_count == 0 || hop_count >= MAX_PATH_SIZE) { - Serial.print(" ERROR: trace path must contain 1-63 prefixes\r\n"); + terminalOutput().print(" ERROR: trace path must contain 1-63 prefixes\r\n"); return; } const size_t route_byte_len = static_cast(hash_size) * hop_count; if (route_byte_len > MAX_PACKET_PAYLOAD - 9) { - Serial.print(" ERROR: trace path is too long\r\n"); + terminalOutput().print(" ERROR: trace path is too long\r\n"); return; } const uint8_t flags = mesh::traceFlagsForHashSize(hash_size); if (flags == 0xFF) { - Serial.print(" ERROR: trace hash size must be 1, 2, or 4 bytes\r\n"); + terminalOutput().print(" ERROR: trace hash size must be 1, 2, or 4 bytes\r\n"); return; } const uint32_t airtime = _radio->getEstAirtimeFor(9 + route_byte_len + 2); if (airtime == 0) { - Serial.print(" ERROR: unable to estimate trace timeout\r\n"); + terminalOutput().print(" ERROR: unable to estimate trace timeout\r\n"); return; } const uint32_t base_timeout = @@ -4395,7 +4589,7 @@ void MyMesh::sendTerminalTraceRoute(const uint8_t* route, uint8_t hash_size, uint32_t trace_timeout = 0; if (!mesh::calculateTerminalTraceTimeoutMillis(base_timeout, trace_timeout)) { - Serial.print(" ERROR: trace timeout is out of range\r\n"); + terminalOutput().print(" ERROR: trace timeout is out of range\r\n"); return; } @@ -4405,12 +4599,12 @@ void MyMesh::sendTerminalTraceRoute(const uint8_t* route, uint8_t hash_size, getRNG()->random((uint8_t*)&auth, sizeof(auth)); mesh::Packet* packet = createTrace(tag, auth, flags); if (packet == NULL) { - Serial.print(" ERROR: unable to allocate trace packet\r\n"); + terminalOutput().print(" ERROR: unable to allocate trace packet\r\n"); return; } if (!sendDirect(packet, route, static_cast(route_byte_len))) { - Serial.print(" ERROR: unable to send trace\r\n"); + terminalOutput().print(" ERROR: unable to send trace\r\n"); return; } @@ -4422,7 +4616,7 @@ void MyMesh::sendTerminalTraceRoute(const uint8_t* route, uint8_t hash_size, _terminal_trace_expires_at = futureMillis(trace_timeout); StrHelper::strzcpy(_terminal_trace_target, target, sizeof(_terminal_trace_target)); - Serial.printf(" Trace sent to %s (%u route hops, timeout %lu ms)\r\n", + terminalOutput().printf(" Trace sent to %s (%u route hops, timeout %lu ms)\r\n", target, (unsigned)hop_count, (unsigned long)trace_timeout); } @@ -4430,7 +4624,7 @@ void MyMesh::sendTerminalTraceRoute(const uint8_t* route, uint8_t hash_size, void MyMesh::sendTerminalTrace(ContactInfo& recipient) { if (recipient.out_path_len == OUT_PATH_UNKNOWN || !mesh::Packet::isValidPathLen(recipient.out_path_len)) { - Serial.print(" ERROR: recipient has no valid direct path\r\n"); + terminalOutput().print(" ERROR: recipient has no valid direct path\r\n"); return; } @@ -4441,11 +4635,11 @@ void MyMesh::sendTerminalTrace(ContactInfo& recipient) { recipient.out_path, recipient.out_path_len, recipient.id.pub_key, include_endpoint, _terminal_tmp_buf, MAX_PACKET_PAYLOAD - 9, route)) { - Serial.print(" ERROR: recipient has no traceable round-trip path\r\n"); + terminalOutput().print(" ERROR: recipient has no traceable round-trip path\r\n"); return; } if (route.hop_count >= MAX_PATH_SIZE) { - Serial.print(" ERROR: round-trip trace path is too long\r\n"); + terminalOutput().print(" ERROR: round-trip trace path is too long\r\n"); return; } @@ -4463,20 +4657,20 @@ void MyMesh::sendTerminalRawTrace(const char* arguments) { break; case mesh::RawTracePathParseResult::MissingHashSize: case mesh::RawTracePathParseResult::MissingPrefixes: - Serial.print(" ERROR: use trace path <1|2|4> \r\n"); + terminalOutput().print(" ERROR: use trace path <1|2|4> \r\n"); return; case mesh::RawTracePathParseResult::InvalidHashSize: - Serial.print(" ERROR: trace hash size must be 1, 2, or 4 bytes\r\n"); + terminalOutput().print(" ERROR: trace hash size must be 1, 2, or 4 bytes\r\n"); return; case mesh::RawTracePathParseResult::InvalidPrefix: - Serial.printf(" ERROR: every prefix must be exactly %u hex digits\r\n", + terminalOutput().printf(" ERROR: every prefix must be exactly %u hex digits\r\n", (unsigned)route.hash_size * 2); return; case mesh::RawTracePathParseResult::TooManyHops: - Serial.print(" ERROR: trace path must contain at most 63 prefixes\r\n"); + terminalOutput().print(" ERROR: trace path must contain at most 63 prefixes\r\n"); return; case mesh::RawTracePathParseResult::RouteTooLong: - Serial.print(" ERROR: trace path is too long\r\n"); + terminalOutput().print(" ERROR: trace path is too long\r\n"); return; } @@ -4495,7 +4689,7 @@ void MyMesh::handleTerminalCommand(char* command) { #if defined(COMPANION_RADIO_FULL) char full_reply[160]; if (handleFullOtaCommand(command, full_reply, sizeof(full_reply))) { - Serial.printf(" %s\r\n", full_reply); + terminalOutput().printf(" %s\r\n", full_reply); return; } #endif @@ -4531,7 +4725,7 @@ void MyMesh::handleTerminalCommand(char* command) { listTerminalChannels(); } else if (channel_match != mesh::cli::TerminalChannelCommandMatch::NoMatch) { if (channel_match != mesh::cli::TerminalChannelCommandMatch::Valid) { - Serial.print(" ERROR: use channel \r\n"); + terminalOutput().print(" ERROR: use channel \r\n"); return; } @@ -4554,7 +4748,7 @@ void MyMesh::handleTerminalCommand(char* command) { } if (!found) { - Serial.print(" ERROR: channel not found (use 'channels')\r\n"); + terminalOutput().print(" ERROR: channel not found (use 'channels')\r\n"); } else { sendTerminalChannelMessage(channel, channel_message.text); } @@ -4562,9 +4756,9 @@ void MyMesh::handleTerminalCommand(char* command) { ContactInfo* recipient = getTerminalRecipient(); const char* text = command + 5; if (recipient == NULL) { - Serial.print(" ERROR: no recipient selected (use 'to' first)\r\n"); + terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n"); } else if (*text == 0 || strlen(text) > MAX_TEXT_LEN) { - Serial.printf(" ERROR: message must be 1-%u characters\r\n", (unsigned)MAX_TEXT_LEN); + terminalOutput().printf(" ERROR: message must be 1-%u characters\r\n", (unsigned)MAX_TEXT_LEN); } else { uint32_t expected_ack = 0; uint32_t est_timeout = 0; @@ -4580,11 +4774,11 @@ void MyMesh::handleTerminalCommand(char* command) { expected_ack, est_timeout, packet_retry_key, NULL, text_fingerprint); if (result == MSG_SEND_FAILED) { - Serial.print(" ERROR: unable to send\r\n"); + terminalOutput().print(" ERROR: unable to send\r\n"); } else { rememberTerminalAck(*recipient, text, message_timestamp, expected_ack, est_timeout, packet_retry_key, replacement_entry); - Serial.printf(" message sent - %s\r\n", + terminalOutput().printf(" message sent - %s\r\n", result == MSG_SEND_SENT_FLOOD ? "FLOOD" : "DIRECT"); } } @@ -4592,7 +4786,7 @@ void MyMesh::handleTerminalCommand(char* command) { ChannelDetails channel; const char* text = command + 7; if (!getChannel(0, channel) || channel.name[0] == 0) { - Serial.print(" ERROR: Public channel is unavailable\r\n"); + terminalOutput().print(" ERROR: Public channel is unavailable\r\n"); } else { sendTerminalChannelMessage(channel, text); } @@ -4601,16 +4795,16 @@ void MyMesh::handleTerminalCommand(char* command) { scanRecentContacts(count, this); } else if (strcmp(command, "clock") == 0) { DateTime dt(getRTCClock()->getCurrentTime()); - Serial.printf("%02d:%02d - %d/%d/%d UTC\r\n", + terminalOutput().printf("%02d:%02d - %d/%d/%d UTC\r\n", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); } else if (strncmp(command, "time ", 5) == 0) { uint32_t timestamp = strtoul(command + 5, NULL, 10); uint32_t current = getRTCClock()->getCurrentTime(); if (timestamp >= current) { getRTCClock()->setCurrentTime(timestamp); - Serial.print(" OK - clock set\r\n"); + terminalOutput().print(" OK - clock set\r\n"); } else { - Serial.print(" ERROR: clock cannot go backwards\r\n"); + terminalOutput().print(" ERROR: clock cannot go backwards\r\n"); } } else if (recipient_match == mesh::cli::TerminalArgumentCommandMatch::Valid) { @@ -4619,25 +4813,25 @@ void MyMesh::handleTerminalCommand(char* command) { recipient = searchContactsByPrefix(recipient_prefix); } if (recipient == NULL || recipient->type == ADV_TYPE_NONE) { - Serial.print(" ERROR: name prefix not found\r\n"); + terminalOutput().print(" ERROR: name prefix not found\r\n"); } else { memcpy(_terminal_recipient_key, recipient->id.pub_key, PUB_KEY_SIZE); _terminal_recipient_set = true; - Serial.printf(" Recipient %s selected\r\n", recipient->name); + terminalOutput().printf(" Recipient %s selected\r\n", recipient->name); } } else if (recipient_match == mesh::cli::TerminalArgumentCommandMatch::MissingArgument) { ContactInfo* recipient = getTerminalRecipient(); if (recipient != NULL) { - Serial.printf(" Current recipient: %s\r\n", recipient->name); + terminalOutput().printf(" Current recipient: %s\r\n", recipient->name); } else { - Serial.print(" No recipient selected\r\n"); + terminalOutput().print(" No recipient selected\r\n"); } } else if (path_match != mesh::cli::TerminalArgumentCommandMatch::NoMatch) { ContactInfo* recipient = getTerminalRecipient(); if (recipient == NULL) { - Serial.print(" ERROR: no recipient selected (use 'to' first)\r\n"); + terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n"); } else { handleTerminalPath( *recipient, @@ -4649,9 +4843,9 @@ void MyMesh::handleTerminalCommand(char* command) { ContactInfo* recipient = getTerminalRecipient(); if (login_match != mesh::cli::TerminalArgumentCommandMatch::Valid) { - Serial.print(" ERROR: use login \r\n"); + terminalOutput().print(" ERROR: use login \r\n"); } else if (recipient == NULL) { - Serial.print(" ERROR: no recipient selected (use 'to' first)\r\n"); + terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n"); } else { sendTerminalLogin(*recipient, login_password); } @@ -4660,9 +4854,9 @@ void MyMesh::handleTerminalCommand(char* command) { ContactInfo* recipient = getTerminalRecipient(); if (command_match != mesh::cli::TerminalArgumentCommandMatch::Valid) { - Serial.print(" ERROR: use cmd \r\n"); + terminalOutput().print(" ERROR: use cmd \r\n"); } else if (recipient == NULL) { - Serial.print(" ERROR: no recipient selected (use 'to' first)\r\n"); + terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n"); } else { sendTerminalCommand(*recipient, remote_command); } @@ -4680,63 +4874,63 @@ void MyMesh::handleTerminalCommand(char* command) { recipient = searchContactsByPrefix(prefix); } if (recipient == NULL || recipient->type == ADV_TYPE_NONE) { - Serial.print(" ERROR: recipient prefix not found\r\n"); + terminalOutput().print(" ERROR: recipient prefix not found\r\n"); return; } } else { recipient = getTerminalRecipient(); if (recipient == NULL) { - Serial.print(" ERROR: no recipient selected (use 'to' first)\r\n"); + terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n"); return; } } sendTerminalTrace(*recipient); } else if (strcmp(command, "advert") == 0) { - Serial.print(advert() ? " advert sent (zero hop)\r\n" + terminalOutput().print(advert() ? " advert sent (zero hop)\r\n" : " ERROR: unable to send advert\r\n"); } else if (strcmp(command, "reset path") == 0) { ContactInfo* recipient = getTerminalRecipient(); if (recipient == NULL) { - Serial.print(" ERROR: no recipient selected\r\n"); + terminalOutput().print(" ERROR: no recipient selected\r\n"); } else { resetPathTo(*recipient); scheduleContactWrite(*recipient); - Serial.print(" Done.\r\n"); + terminalOutput().print(" Done.\r\n"); } } else if (strcmp(command, "card") == 0) { mesh::Packet* packet = _prefs.advert_loc_policy == ADVERT_LOC_NONE ? createSelfAdvert(_prefs.node_name) : createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon); if (packet == NULL) { - Serial.print(" ERROR: unable to create card\r\n"); + terminalOutput().print(" ERROR: unable to create card\r\n"); } else { packet->header |= ROUTE_TYPE_FLOOD; uint8_t raw_len = packet->writeTo(_terminal_tmp_buf); releasePacket(packet); - Serial.print("meshcore://"); - mesh::Utils::printHex(Serial, _terminal_tmp_buf, raw_len); - Serial.print("\r\n"); + terminalOutput().print("meshcore://"); + mesh::Utils::printHex(terminalOutput(), _terminal_tmp_buf, raw_len); + terminalOutput().print("\r\n"); } } else if (strncmp(command, "import ", 7) == 0) { importTerminalCard(command + 7); } else if (strcmp(command, "powersaving") == 0 || strcmp(command, "get powersaving") == 0) { - Serial.printf(" powersaving %s\r\n", + terminalOutput().printf(" powersaving %s\r\n", _prefs.powersaving_enabled ? "on" : "off"); #if MESH_USB_LOGGING_AVAILABLE } else if (strcmp(command, "get usb.logging") == 0) { - Serial.printf(" usb.logging %s\r\n", + terminalOutput().printf(" usb.logging %s\r\n", mesh::isUsbLoggingEnabled() ? "on" : "off"); #endif } else if (strncmp(command, "powersaving ", 12) == 0) { char reply[160]; applyAndSavePowerSaving(command + 12, reply); - Serial.printf(" %s\r\n", reply); + terminalOutput().printf(" %s\r\n", reply); } else if (strcmp(command, "get radio.rxps") == 0) { if (!radio_driver.supportsRxPowerSaving()) { - Serial.print(" ERROR: RX power saving is unsupported on this radio\r\n"); + terminalOutput().print(" ERROR: RX power saving is unsupported on this radio\r\n"); } else { - Serial.printf(" radio.rxps %s,level=%u,preamble=%u,rx=%lu,sleep=%lu\r\n", + terminalOutput().printf(" radio.rxps %s,level=%u,preamble=%u,rx=%lu,sleep=%lu\r\n", _prefs.rx_powersaving_enabled ? "on" : "off", (unsigned)_prefs.rx_ps_level, (unsigned)_prefs.rx_ps_preamble, @@ -4747,27 +4941,27 @@ void MyMesh::handleTerminalCommand(char* command) { } else if (strcmp(command, "get wifi.powersave") == 0) { char reply[160]; formatWiFiPowerSaving(reply, sizeof(reply)); - Serial.printf(" %s\r\n", reply); + terminalOutput().printf(" %s\r\n", reply); #endif } else if (strcmp(command, "get radio.rxgain") == 0) { if (!radio_driver.supportsRxBoostedGainMode()) { - Serial.print(" ERROR: RX boosted gain is unsupported on this radio\r\n"); + terminalOutput().print(" ERROR: RX boosted gain is unsupported on this radio\r\n"); } else { - Serial.printf(" radio.rxgain %s\r\n", + terminalOutput().printf(" radio.rxgain %s\r\n", _prefs.rx_boosted_gain ? "on" : "off"); } } else if (strcmp(command, "get radio.fem.rxgain") == 0) { if (!board.canControlLoRaFemLna()) { - Serial.print(" ERROR: FEM RX gain control is unsupported on this board\r\n"); + terminalOutput().print(" ERROR: FEM RX gain control is unsupported on this board\r\n"); } else { - Serial.printf(" FEM RX gain: %s\r\n", + terminalOutput().printf(" FEM RX gain: %s\r\n", board.isLoRaFemLnaEnabled() ? "on" : "off"); } } else if (strcmp(command, "get radio.fem.txgain") == 0) { if (!board.canControlLoRaFemPaGain()) { - Serial.print(" ERROR: FEM TX gain control is unsupported on this board\r\n"); + terminalOutput().print(" ERROR: FEM TX gain control is unsupported on this board\r\n"); } else { - Serial.printf(" FEM TX gain: %s\r\n", + terminalOutput().printf(" FEM TX gain: %s\r\n", board.isLoRaFemPaGainEnabled() ? "on" : "off"); } } else if (strncmp(command, "set ", 4) == 0) { @@ -4779,11 +4973,11 @@ void MyMesh::handleTerminalCommand(char* command) { const char* value = config + 11; while (*value == ' ' || *value == '\t') value++; if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { - Serial.print(" ERROR: use set usb.logging \r\n"); + terminalOutput().print(" ERROR: use set usb.logging \r\n"); } else { const bool enabled = strcmp(value, "on") == 0; mesh::setUsbLoggingEnabled(enabled); - Serial.printf(" OK - USB logging %s until reboot\r\n", + terminalOutput().printf(" OK - USB logging %s until reboot\r\n", enabled ? "on" : "off"); } } else @@ -4791,11 +4985,11 @@ void MyMesh::handleTerminalCommand(char* command) { if (strncmp(config, "powersaving ", 12) == 0) { char reply[160]; applyAndSavePowerSaving(config + 12, reply); - Serial.printf(" %s\r\n", reply); + terminalOutput().printf(" %s\r\n", reply); } else if (strncmp(config, "radio.rxps ", 11) == 0) { char reply[160]; applyAndSaveRxPowerSaving(config + 11, reply); - Serial.printf(" %s\r\n", reply); + terminalOutput().printf(" %s\r\n", reply); #if defined(ESP32) && defined(WIFI_SSID) } else if (strncmp(config, "wifi.powersave", 14) == 0 && (config[14] == 0 || config[14] == ' ' @@ -4804,7 +4998,7 @@ void MyMesh::handleTerminalCommand(char* command) { while (*value == ' ' || *value == '\t') value++; char reply[160]; applyAndSaveWiFiPowerSaving(value, reply, sizeof(reply)); - Serial.printf(" %s\r\n", reply); + terminalOutput().printf(" %s\r\n", reply); #endif } else if (strncmp(config, "radio.rxgain", 12) == 0 && (config[12] == 0 || config[12] == ' ' @@ -4812,51 +5006,51 @@ void MyMesh::handleTerminalCommand(char* command) { const char* value = config + 12; while (*value == ' ' || *value == '\t') value++; if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { - Serial.print(" ERROR: use set radio.rxgain \r\n"); + terminalOutput().print(" ERROR: use set radio.rxgain \r\n"); } else if (!radio_driver.supportsRxBoostedGainMode()) { - Serial.print(" ERROR: RX boosted gain is unsupported on this radio\r\n"); + terminalOutput().print(" ERROR: RX boosted gain is unsupported on this radio\r\n"); } else if (!applyAndSaveRxBoostedGain(strcmp(value, "on") == 0)) { - Serial.print(" ERROR: radio busy; retry\r\n"); + terminalOutput().print(" ERROR: radio busy; retry\r\n"); } else { - Serial.printf(" OK - radio.rxgain %s\r\n", value); + terminalOutput().printf(" OK - radio.rxgain %s\r\n", value); } } else if (strncmp(config, "af ", 3) == 0) { _prefs.airtime_factor = constrain((float)atof(config + 3), 0.0f, 9.0f); savePrefs(); - Serial.print(" OK\r\n"); + terminalOutput().print(" OK\r\n"); } else if (strncmp(config, "name ", 5) == 0 && config[5] != 0) { StrHelper::strncpy(_prefs.node_name, config + 5, sizeof(_prefs.node_name)); savePrefs(); - Serial.print(" OK\r\n"); + terminalOutput().print(" OK\r\n"); } else if (strncmp(config, "lat ", 4) == 0) { sensors.node_lat = constrain(atof(config + 4), -90.0, 90.0); savePrefs(); - Serial.print(" OK\r\n"); + terminalOutput().print(" OK\r\n"); } else if (strncmp(config, "lon ", 4) == 0) { sensors.node_lon = constrain(atof(config + 4), -180.0, 180.0); savePrefs(); - Serial.print(" OK\r\n"); + terminalOutput().print(" OK\r\n"); } else if (strncmp(config, "tx ", 3) == 0) { _prefs.tx_power_dbm = constrain(atoi(config + 3), -9, MAX_LORA_TX_POWER); savePrefs(); - Serial.print(" OK - reboot to apply\r\n"); + terminalOutput().print(" OK - reboot to apply\r\n"); } else if (strncmp(config, "freq ", 5) == 0) { _prefs.freq = constrain((float)atof(config + 5), 150.0f, 2500.0f); savePrefs(); - Serial.print(" OK - reboot to apply\r\n"); + terminalOutput().print(" OK - reboot to apply\r\n"); } else if (strncmp(config, "radio.fem.rxgain", 16) == 0 && (config[16] == 0 || config[16] == ' ' || config[16] == '\t')) { const char* value = config + 16; while (*value == ' ' || *value == '\t') value++; if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { - Serial.print(" ERROR: use set radio.fem.rxgain \r\n"); + terminalOutput().print(" ERROR: use set radio.fem.rxgain \r\n"); } else if (!board.canControlLoRaFemLna()) { - Serial.print(" ERROR: FEM RX gain control is unsupported on this board\r\n"); + terminalOutput().print(" ERROR: FEM RX gain control is unsupported on this board\r\n"); } else if (!applyAndSaveFemRxGain(strcmp(value, "on") == 0)) { - Serial.print(" ERROR: failed to apply FEM RX gain\r\n"); + terminalOutput().print(" ERROR: failed to apply FEM RX gain\r\n"); } else { - Serial.printf(" OK - FEM RX gain %s\r\n", value); + terminalOutput().printf(" OK - FEM RX gain %s\r\n", value); } } else if (strncmp(config, "radio.fem.txgain", 16) == 0 && (config[16] == 0 || config[16] == ' ' @@ -4864,67 +5058,81 @@ void MyMesh::handleTerminalCommand(char* command) { const char* value = config + 16; while (*value == ' ' || *value == '\t') value++; if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { - Serial.print(" ERROR: use set radio.fem.txgain \r\n"); + terminalOutput().print(" ERROR: use set radio.fem.txgain \r\n"); } else if (!board.canControlLoRaFemPaGain()) { - Serial.print(" ERROR: FEM TX gain control is unsupported on this board\r\n"); + terminalOutput().print(" ERROR: FEM TX gain control is unsupported on this board\r\n"); } else if (!applyAndSaveFemTxGain(strcmp(value, "on") == 0)) { - Serial.print(" ERROR: failed to apply FEM TX gain\r\n"); + terminalOutput().print(" ERROR: failed to apply FEM TX gain\r\n"); } else { - Serial.printf(" OK - FEM TX gain %s\r\n", value); + terminalOutput().printf(" OK - FEM TX gain %s\r\n", value); } } else { - Serial.printf(" ERROR: unknown setting: %s\r\n", config); + terminalOutput().printf(" ERROR: unknown setting: %s\r\n", config); } } else if (strcmp(command, "ver") == 0) { - Serial.printf("Companion %s (protocol %u, build %s)\r\n", + terminalOutput().printf("Companion %s (protocol %u, build %s)\r\n", FIRMWARE_VERSION, (unsigned)FIRMWARE_VER_CODE, FIRMWARE_BUILD_DATE); } else if (strcmp(command, "help") == 0) { - Serial.print("Commands:\r\n"); - Serial.print(" set {name|lat|lon|freq|tx|af} {value}\r\n"); - Serial.print(" powersaving [on|off]\r\n"); + terminalOutput().print("Commands:\r\n"); + terminalOutput().print(" set {name|lat|lon|freq|tx|af} {value}\r\n"); + terminalOutput().print(" powersaving [on|off]\r\n"); #if MESH_USB_LOGGING_AVAILABLE - Serial.print(" get usb.logging\r\n"); - Serial.print(" set usb.logging \r\n"); + terminalOutput().print(" get usb.logging\r\n"); + terminalOutput().print(" set usb.logging \r\n"); #endif #if defined(ESP32) && defined(WIFI_SSID) - Serial.print(" get wifi.powersave\r\n"); - Serial.print(" set wifi.powersave \r\n"); + terminalOutput().print(" get wifi.powersave\r\n"); + terminalOutput().print(" set wifi.powersave \r\n"); +#ifdef WITH_WEBCONFIG + terminalOutput().print(" get wifi.{ssid|status|cli}\r\n"); + terminalOutput().print(" set wifi.ssid \r\n"); + terminalOutput().print(" set wifi.pwd \r\n"); + terminalOutput().print(" set wifi.cli \r\n"); + terminalOutput().print(" get webui\r\n"); + terminalOutput().print(" set webui \r\n"); + terminalOutput().print(" start webconfig [ap]\r\n"); + terminalOutput().print(" stop webconfig\r\n"); #endif - Serial.print(" get radio.rxps\r\n"); - Serial.print(" set radio.rxps \r\n"); - Serial.print(" get radio.rxgain\r\n"); - Serial.print(" set radio.rxgain \r\n"); - Serial.print(" get radio.fem.rxgain\r\n"); - Serial.print(" set radio.fem.rxgain \r\n"); - Serial.print(" get radio.fem.txgain\r\n"); - Serial.print(" set radio.fem.txgain \r\n"); - Serial.print(" card\r\n"); - Serial.print(" import \r\n"); - Serial.print(" clock\r\n"); - Serial.print(" time \r\n"); - Serial.print(" list [n]\r\n"); - Serial.print(" show [adverts|channels|emergency] [on|off]\r\n"); - Serial.print(" to [recipient name or prefix]\r\n"); - Serial.print(" path [direct|clear|hops separated by spaces or commas]\r\n"); - Serial.print(" send \r\n"); - Serial.print(" login \r\n"); - Serial.print(" cmd \r\n"); - Serial.print(" trace [recipient name or prefix]\r\n"); - Serial.print(" trace path <1|2|4> \r\n"); - Serial.print(" advert\r\n"); - Serial.print(" reset path\r\n"); - Serial.print(" public \r\n"); - Serial.print(" channels\r\n"); - Serial.print(" channel \r\n"); +#endif + terminalOutput().print(" get radio.rxps\r\n"); + terminalOutput().print(" set radio.rxps \r\n"); + terminalOutput().print(" get radio.rxgain\r\n"); + terminalOutput().print(" set radio.rxgain \r\n"); + terminalOutput().print(" get radio.fem.rxgain\r\n"); + terminalOutput().print(" set radio.fem.rxgain \r\n"); + terminalOutput().print(" get radio.fem.txgain\r\n"); + terminalOutput().print(" set radio.fem.txgain \r\n"); + terminalOutput().print(" card\r\n"); + terminalOutput().print(" import \r\n"); + terminalOutput().print(" clock\r\n"); + terminalOutput().print(" time \r\n"); + terminalOutput().print(" list [n]\r\n"); + terminalOutput().print(" show [adverts|channels|emergency] [on|off]\r\n"); + terminalOutput().print(" to [recipient name or prefix]\r\n"); + terminalOutput().print(" path [direct|clear|hops separated by spaces or commas]\r\n"); + terminalOutput().print(" send \r\n"); + terminalOutput().print(" login \r\n"); + terminalOutput().print(" cmd \r\n"); + terminalOutput().print(" trace [recipient name or prefix]\r\n"); + terminalOutput().print(" trace path <1|2|4> \r\n"); + terminalOutput().print(" advert\r\n"); + terminalOutput().print(" reset path\r\n"); + terminalOutput().print(" public \r\n"); + terminalOutput().print(" channels\r\n"); + terminalOutput().print(" channel \r\n"); #if defined(COMPANION_RADIO_FULL) - Serial.print(" tempradio [freq,bw,sf,cr,minutes]\r\n"); - Serial.print(" normalradio\r\n"); - Serial.print(" ota {status|ls|announce|folder|config|...}\r\n"); + terminalOutput().print(" tempradio [freq,bw,sf,cr,minutes]\r\n"); + terminalOutput().print(" normalradio\r\n"); + terminalOutput().print(" ota {status|ls|announce|folder|config|...}\r\n"); #endif - Serial.print(" ver\r\n"); - Serial.print(" +++MESHCORE-TERM-STOP\r\n"); + terminalOutput().print(" ver\r\n"); + if (_terminal_mode) { + terminalOutput().print(" +++MESHCORE-TERM-STOP\r\n"); + } else { + terminalOutput().print(" disconnect (closes the TCP terminal)\r\n"); + } } else { - Serial.printf(" ERROR: unknown command: %s\r\n", command); + terminalOutput().printf(" ERROR: unknown command: %s\r\n", command); } } #endif diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index fb43f98e..7df924db 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -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]; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index fc96ef73..b2998bfe 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -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(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 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 // mesh::ota::handle_ota_command(line, reply, board) #include @@ -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 %d)", + OTA_CONSOLE_TCP_PORT, OTA_CONSOLE_TCP_PORT); +#else WIFI_DEBUG_PRINTLN("OTA console listening on :%d (nc %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(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 diff --git a/examples/host_cli_service/README.md b/examples/host_cli_service/README.md new file mode 100644 index 00000000..943a2841 --- /dev/null +++ b/examples/host_cli_service/README.md @@ -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 [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 [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=` 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. diff --git a/examples/host_cli_service/host_cli_service.py b/examples/host_cli_service/host_cli_service.py new file mode 100755 index 00000000..1ddf760d --- /dev/null +++ b/examples/host_cli_service/host_cli_service.py @@ -0,0 +1,1107 @@ +#!/usr/bin/env python3 +"""MQTT endpoint for the repeater LoRa-to-host CLI service. + +Built-in requests use a small fixed allowlist; optional local programs use a +separate alias and argument allowlist. Requests are accepted only after the +repeater's Ed25519 signature, identity, framing, and size limits have all been +verified and the repeater completes a live one-time challenge. Replies use +meshcoretomqtt's signed remote-serial channel. +""" + +from __future__ import annotations + +import argparse +import base64 +from collections import OrderedDict +from dataclasses import dataclass +import hmac +import json +import logging +import os +from pathlib import Path +import re +import secrets +import shlex +import shutil +import socket +import stat +import subprocess +import sys +import threading +import time +from typing import Any, Callable + + +LOGGER = logging.getLogger("meshcore-host-cli") +REQUEST_PROTOCOL_PREFIX = "DEBUG HOSTCLI/1 REQUEST " +CLAIM_PROTOCOL_PREFIX = "DEBUG HOSTCLI/1 CLAIMED " +MAX_REQUEST_BYTES = 155 +MAX_REQUEST_ENCODED_BYTES = (MAX_REQUEST_BYTES * 4 + 2) // 3 +MAX_REPLY_BYTES = 162 +CHALLENGE_TIMEOUT_SECONDS = 4.0 +REQUEST_RE = re.compile( + r"^DEBUG (HOSTCLI/1 REQUEST " + r"([0-9A-Fa-f]{8}) " + r"([0-9A-Fa-f]{16}) " + r"([A-Za-z0-9_-]{2," + str(MAX_REQUEST_ENCODED_BYTES) + r"})) " + r"([0-9A-Fa-f]{128})$" +) +CLAIM_RE = re.compile( + r"^DEBUG (HOSTCLI/1 CLAIMED " + r"([0-9A-Fa-f]{8}) " + r"([0-9A-Fa-f]{16}) " + r"([0-9A-Fa-f]{16})) " + r"([0-9A-Fa-f]{128})$" +) +KEY_CHECK_MESSAGE = b"MeshCore HOSTCLI/1 service key check" + +Signer = Callable[[bytes, bytes, bytes], bytes] +Verifier = Callable[[bytes, bytes, bytes], bool] + + +@dataclass(frozen=True) +class HostRequest: + request_id: str + request_nonce: str + text: str + + +@dataclass(frozen=True) +class HostClaim: + request_id: str + request_nonce: str + challenge: str + + +@dataclass(frozen=True) +class PendingHostAction: + request: HostRequest + challenge: str + expires_at: float + + +@dataclass(frozen=True) +class ServiceKey: + public_key: str + private_key: str + + +@dataclass(frozen=True) +class HostResult: + text: str + reboot_requested: bool = False + + +@dataclass(frozen=True) +class ProgramArgumentRule: + name: str + kind: str + choices: tuple[str, ...] = () + minimum: int | None = None + maximum: int | None = None + max_bytes: int = 32 + + +@dataclass(frozen=True) +class ProgramDefinition: + alias: str + argv: tuple[str, ...] + arguments: tuple[ProgramArgumentRule, ...] + timeout_seconds: int = 3 + + +def _orlp_signer() -> Signer: + try: + from ed25519_orlp import ed25519_sign + except ImportError as exc: + raise RuntimeError( + "ed25519-orlp is required; use meshcoretomqtt's virtualenv" + ) from exc + return ed25519_sign + + +def _orlp_verifier() -> Verifier: + try: + from ed25519_orlp import ed25519_verify + except ImportError as exc: + raise RuntimeError( + "ed25519-orlp is required; use meshcoretomqtt's virtualenv" + ) from exc + return ed25519_verify + + +def normalize_key(value: str, expected_bytes: int, label: str) -> str: + normalized = "".join(value.split()).upper() + if len(normalized) != expected_bytes * 2: + raise ValueError( + f"{label} must be {expected_bytes} bytes " + f"({expected_bytes * 2} hex characters)" + ) + try: + bytes.fromhex(normalized) + except ValueError as exc: + raise ValueError(f"{label} is not valid hexadecimal") from exc + return normalized + + +def verify_service_key( + key: ServiceKey, + signer: Signer | None = None, + verifier: Verifier | None = None, +) -> None: + signer = signer or _orlp_signer() + verifier = verifier or _orlp_verifier() + public_bytes = bytes.fromhex(key.public_key) + private_bytes = bytes.fromhex(key.private_key) + signature = signer(KEY_CHECK_MESSAGE, public_bytes, private_bytes) + if len(signature) != 64 or not verifier( + signature, KEY_CHECK_MESSAGE, public_bytes + ): + raise ValueError("service public and private keys do not form a keypair") + + +def load_service_key(path: Path) -> ServiceKey: + file_mode = stat.S_IMODE(path.stat().st_mode) + if file_mode & 0o077: + raise ValueError( + f"service key {path} must not be accessible by group or others; " + "use chmod 600" + ) + data = json.loads(path.read_text(encoding="ascii")) + if not isinstance(data, dict): + raise ValueError("service key file must contain a JSON object") + key = ServiceKey( + public_key=normalize_key(str(data.get("public_key", "")), 32, + "public key"), + private_key=normalize_key(str(data.get("private_key", "")), 64, + "private key"), + ) + verify_service_key(key) + return key + + +def generate_service_key(path: Path) -> str: + try: + from ed25519_orlp import ed25519_create_keypair + except ImportError as exc: + raise RuntimeError( + "ed25519-orlp is required; use meshcoretomqtt's virtualenv" + ) from exc + + public_key, private_key, _seed = ed25519_create_keypair() + document = json.dumps( + { + "public_key": public_key.hex().upper(), + "private_key": private_key.hex().upper(), + }, + indent=2, + ) + "\n" + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(descriptor, "w", encoding="ascii") as stream: + stream.write(document) + except BaseException: + try: + path.unlink() + except OSError: + pass + raise + return public_key.hex().upper() + + +def _decode_request_text(encoded: str) -> str: + padding = "=" * ((4 - len(encoded) % 4) % 4) + try: + raw = base64.b64decode( + (encoded + padding).encode("ascii"), altchars=b"-_", validate=True + ) + except (ValueError, UnicodeError) as exc: + raise ValueError("host request has invalid Base64URL text") from exc + if not raw or len(raw) > MAX_REQUEST_BYTES: + raise ValueError("host request text length is invalid") + try: + return raw.decode("utf-8", errors="strict") + except UnicodeDecodeError as exc: + raise ValueError("host request text is not valid UTF-8") from exc + + +def _load_mqtt_debug_document( + mqtt_payload: bytes | str, +) -> tuple[dict[str, Any], str | None]: + if isinstance(mqtt_payload, bytes): + try: + mqtt_payload = mqtt_payload.decode("utf-8", errors="strict") + except UnicodeDecodeError as exc: + raise ValueError("MQTT debug payload is not valid UTF-8") from exc + try: + document = json.loads(mqtt_payload) + except json.JSONDecodeError as exc: + raise ValueError("MQTT debug payload is not valid JSON") from exc + if not isinstance(document, dict): + raise ValueError("MQTT debug payload must be a JSON object") + message = document.get("message") + return document, message if isinstance(message, str) else None + + +def _verify_signed_record( + document: dict[str, Any], + message: str, + pattern: re.Pattern[str], + repeater_public_key: str, + label: str, + verifier: Verifier | None, +) -> re.Match[str]: + if document.get("type") != "DEBUG": + raise ValueError(f"{label} has the wrong MQTT message type") + + expected_public_key = normalize_key( + repeater_public_key, 32, "repeater public key" + ) + origin_id = normalize_key( + str(document.get("origin_id", "")), 32, "MQTT origin_id" + ) + if origin_id != expected_public_key: + raise ValueError(f"{label} MQTT origin does not match the repeater") + + match = pattern.fullmatch(message) + if match is None: + raise ValueError(f"{label} framing or length is invalid") + signed_content = match.group(1) + signature = bytes.fromhex(match.group(match.lastindex or 0)) + verifier = verifier or _orlp_verifier() + if not verifier( + signature, + signed_content.encode("ascii"), + bytes.fromhex(expected_public_key), + ): + raise ValueError(f"{label} signature is invalid") + return match + + +def parse_and_verify_request( + mqtt_payload: bytes | str, + repeater_public_key: str, + verifier: Verifier | None = None, +) -> HostRequest | None: + document, message = _load_mqtt_debug_document(mqtt_payload) + + if message is None or not message.startswith(REQUEST_PROTOCOL_PREFIX): + return None + match = _verify_signed_record( + document, message, REQUEST_RE, repeater_public_key, "host request", + verifier, + ) + ( + _signed_content, + request_id, + request_nonce, + encoded, + _signature_hex, + ) = match.groups() + + return HostRequest( + request_id=request_id.upper(), + request_nonce=request_nonce.upper(), + text=_decode_request_text(encoded), + ) + + +def parse_and_verify_claim( + mqtt_payload: bytes | str, + repeater_public_key: str, + verifier: Verifier | None = None, +) -> HostClaim | None: + document, message = _load_mqtt_debug_document(mqtt_payload) + if message is None or not message.startswith(CLAIM_PROTOCOL_PREFIX): + return None + match = _verify_signed_record( + document, message, CLAIM_RE, repeater_public_key, "host claim", + verifier, + ) + ( + _signed_content, + request_id, + request_nonce, + challenge, + _signature_hex, + ) = match.groups() + if int(challenge, 16) == 0: + raise ValueError("host claim challenge must not be zero") + return HostClaim( + request_id=request_id.upper(), + request_nonce=request_nonce.upper(), + challenge=challenge.upper(), + ) + + +def read_cpu_temperature(path: Path) -> str: + try: + milli_celsius = int(path.read_text(encoding="ascii").strip()) + except (OSError, UnicodeError, ValueError): + return "Err - CPU temperature unavailable" + if milli_celsius < -40000 or milli_celsius > 200000: + return "Err - CPU temperature unavailable" + return f"CPU {milli_celsius / 1000.0:.1f} C" + + +def read_uptime(path: Path) -> str: + try: + seconds = int(float(path.read_text(encoding="ascii").split()[0])) + except (IndexError, OSError, UnicodeError, ValueError): + return "Err - host uptime unavailable" + days, remainder = divmod(seconds, 86400) + hours, remainder = divmod(remainder, 3600) + minutes = remainder // 60 + return f"Uptime {days}d {hours}h {minutes}m" + + +def read_load_average(path: Path) -> str: + try: + values = path.read_text(encoding="ascii").split()[:3] + if len(values) != 3: + raise ValueError("missing load averages") + parsed = [float(value) for value in values] + except (OSError, UnicodeError, ValueError): + return "Err - host load unavailable" + return "Load " + " ".join(f"{value:.2f}" for value in parsed) + + +def read_memory(path: Path) -> str: + try: + fields: dict[str, int] = {} + for line in path.read_text(encoding="ascii").splitlines(): + name, separator, value = line.partition(":") + if separator and name in ("MemTotal", "MemAvailable"): + fields[name] = int(value.split()[0]) + total_kib = fields["MemTotal"] + available_kib = fields["MemAvailable"] + if total_kib <= 0 or available_kib < 0 or available_kib > total_kib: + raise ValueError("invalid memory totals") + except (IndexError, KeyError, OSError, UnicodeError, ValueError): + return "Err - host memory unavailable" + return ( + f"Memory {available_kib // 1024}/{total_kib // 1024} MiB available" + ) + + +def read_disk_free(path: Path) -> str: + try: + usage = shutil.disk_usage(path) + except OSError: + return "Err - host disk unavailable" + gib = 1024.0 * 1024.0 * 1024.0 + return f"Disk {usage.free / gib:.1f}/{usage.total / gib:.1f} GiB free" + + +def _safe_config_text(value: Any, label: str, max_bytes: int = 128) -> str: + if not isinstance(value, str) or not value: + raise ValueError(f"{label} must be a nonempty string") + if len(value.encode("utf-8")) > max_bytes or any( + not character.isprintable() for character in value + ): + raise ValueError(f"{label} contains invalid characters or is too long") + return value + + +def load_programs_file(path: Path) -> dict[str, ProgramDefinition]: + file_mode = stat.S_IMODE(path.stat().st_mode) + if file_mode & 0o022: + raise ValueError( + f"program allowlist {path} must not be group/world writable" + ) + document = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(document, dict) or set(document) != {"programs"}: + raise ValueError("program allowlist must contain only a programs object") + raw_programs = document["programs"] + if not isinstance(raw_programs, dict) or len(raw_programs) > 16: + raise ValueError("programs must be an object with at most 16 entries") + + programs: dict[str, ProgramDefinition] = {} + for raw_alias, raw_definition in raw_programs.items(): + alias = _safe_config_text(raw_alias, "program alias", 32) + if not re.fullmatch(r"[a-z][a-z0-9-]{0,31}", alias): + raise ValueError(f"invalid program alias: {alias}") + if not isinstance(raw_definition, dict): + raise ValueError(f"program {alias} must be an object") + allowed_fields = {"argv", "arguments", "timeout_seconds"} + unknown_fields = set(raw_definition) - allowed_fields + if unknown_fields: + raise ValueError( + f"program {alias} has unknown fields: " + + ", ".join(sorted(unknown_fields)) + ) + + raw_argv = raw_definition.get("argv") + if not isinstance(raw_argv, list) or not 1 <= len(raw_argv) <= 16: + raise ValueError(f"program {alias} argv must contain 1-16 strings") + argv = tuple( + _safe_config_text(value, f"program {alias} argv", 128) + for value in raw_argv + ) + executable = Path(argv[0]) + if not executable.is_absolute(): + raise ValueError(f"program {alias} executable must be absolute") + resolved_executable = executable.resolve(strict=True) + executable_mode = stat.S_IMODE(resolved_executable.stat().st_mode) + if ( + not resolved_executable.is_file() + or not os.access(resolved_executable, os.X_OK) + or executable_mode & 0o022 + ): + raise ValueError( + f"program {alias} executable must be executable and not " + "group/world writable" + ) + argv = (str(resolved_executable), *argv[1:]) + + raw_arguments = raw_definition.get("arguments", []) + if not isinstance(raw_arguments, list) or len(raw_arguments) > 8: + raise ValueError(f"program {alias} arguments must have at most 8 entries") + arguments: list[ProgramArgumentRule] = [] + for index, raw_rule in enumerate(raw_arguments): + if not isinstance(raw_rule, dict): + raise ValueError(f"program {alias} argument {index} must be an object") + name = _safe_config_text( + raw_rule.get("name"), f"program {alias} argument name", 24 + ) + if not re.fullmatch(r"[a-z][a-z0-9_-]{0,23}", name): + raise ValueError(f"program {alias} has invalid argument name {name}") + kind = raw_rule.get("type") + common_fields = {"name", "type"} + if kind == "choice": + if set(raw_rule) - (common_fields | {"choices"}): + raise ValueError( + f"program {alias} choice {name} has unknown fields" + ) + raw_choices = raw_rule.get("choices") + if not isinstance(raw_choices, list) or not 1 <= len(raw_choices) <= 32: + raise ValueError(f"program {alias} choice {name} needs 1-32 values") + choices = tuple( + _safe_config_text( + choice, f"program {alias} choice {name}", 64 + ) + for choice in raw_choices + ) + if any(choice.startswith("-") for choice in choices): + raise ValueError( + f"program {alias} choice {name} cannot start with '-'" + ) + arguments.append(ProgramArgumentRule(name, kind, choices=choices)) + elif kind == "integer": + if set(raw_rule) - (common_fields | {"min", "max"}): + raise ValueError( + f"program {alias} integer {name} has unknown fields" + ) + minimum = raw_rule.get("min") + maximum = raw_rule.get("max") + if ( + isinstance(minimum, bool) + or isinstance(maximum, bool) + or not isinstance(minimum, int) + or not isinstance(maximum, int) + or minimum < 0 + or maximum > 1000000000 + or minimum > maximum + ): + raise ValueError( + f"program {alias} integer {name} has invalid bounds" + ) + arguments.append( + ProgramArgumentRule( + name, kind, minimum=minimum, maximum=maximum + ) + ) + elif kind == "token": + if set(raw_rule) - (common_fields | {"max_bytes"}): + raise ValueError(f"program {alias} token {name} has unknown fields") + max_bytes = raw_rule.get("max_bytes", 32) + if ( + isinstance(max_bytes, bool) + or not isinstance(max_bytes, int) + or not 1 <= max_bytes <= 64 + ): + raise ValueError( + f"program {alias} token {name} has invalid max_bytes" + ) + arguments.append( + ProgramArgumentRule(name, kind, max_bytes=max_bytes) + ) + else: + raise ValueError( + f"program {alias} argument {name} has unsupported type" + ) + + timeout_seconds = raw_definition.get("timeout_seconds", 3) + if ( + isinstance(timeout_seconds, bool) + or not isinstance(timeout_seconds, int) + or not 1 <= timeout_seconds <= 5 + ): + raise ValueError(f"program {alias} timeout_seconds must be 1-5") + programs[alias] = ProgramDefinition( + alias=alias, + argv=argv, + arguments=tuple(arguments), + timeout_seconds=timeout_seconds, + ) + return programs + + +def _validate_program_argument( + value: str, rule: ProgramArgumentRule +) -> str | None: + if rule.kind == "choice": + return value if value in rule.choices else None + if rule.kind == "integer": + if not re.fullmatch(r"(?:0|[1-9][0-9]*)", value): + return None + parsed = int(value) + if rule.minimum is None or rule.maximum is None: + return None + if parsed < rule.minimum or parsed > rule.maximum: + return None + return str(parsed) + if rule.kind == "token": + if len(value.encode("utf-8")) > rule.max_bytes: + return None + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:@+,-]*", value): + return None + return value + return None + + +def run_configured_program( + request_text: str, + programs: dict[str, ProgramDefinition], +) -> HostResult: + if any(not character.isprintable() for character in request_text): + return HostResult("Err - invalid program request") + try: + tokens = shlex.split(request_text, posix=True) + except ValueError: + return HostResult("Err - invalid program request") + if len(tokens) < 2 or tokens[0] != "run": + return HostResult("Err - use: run [arguments]") + definition = programs.get(tokens[1]) + if definition is None: + return HostResult("Err - program alias is not allowed") + supplied = tokens[2:] + if len(supplied) != len(definition.arguments): + names = " ".join(f"<{rule.name}>" for rule in definition.arguments) + usage = f"Err - use: run {definition.alias}" + if names: + usage += " " + names + return HostResult(usage) + + validated: list[str] = [] + for value, rule in zip(supplied, definition.arguments): + safe_value = _validate_program_argument(value, rule) + if safe_value is None: + return HostResult(f"Err - invalid {rule.name}") + validated.append(safe_value) + + argv = [*definition.argv, *validated] + try: + completed = subprocess.run( + argv, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + timeout=definition.timeout_seconds, + check=False, + shell=False, + cwd="/", + env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8"}, + ) + except subprocess.TimeoutExpired: + return HostResult(f"Err - {definition.alias} timed out") + except OSError: + return HostResult(f"Err - {definition.alias} could not start") + if completed.returncode != 0: + return HostResult( + f"Err - {definition.alias} failed ({completed.returncode})" + ) + output = bounded_line_text(completed.stdout or "") + if output == "Err - empty host reply": + output = f"OK - {definition.alias} completed" + return HostResult(output) + + +def handle_request( + request_text: str, + temperature_path: Path, + *, + allow_reboot: bool = False, + reboot_delay: int = 5, + uptime_path: Path = Path("/proc/uptime"), + load_path: Path = Path("/proc/loadavg"), + memory_path: Path = Path("/proc/meminfo"), + disk_path: Path = Path("/"), + programs: dict[str, ProgramDefinition] | None = None, +) -> HostResult: + # Built-ins are exact strings. Program requests reach argv only after an + # alias lookup, exact arity check, and per-argument allowlist validation; + # request text is never used as a shell command, executable, or file path. + if request_text == "help": + program_aliases = ",".join(sorted(programs or {})) or "off" + return HostResult( + "Commands: cpu-temp,hostname,uptime,load,memory,disk-free; " + f"reboot={'on' if allow_reboot else 'off'}; run={program_aliases}" + ) + if request_text == "cpu-temp": + return HostResult(read_cpu_temperature(temperature_path)) + if request_text == "hostname": + return HostResult("Hostname " + socket.gethostname()) + if request_text == "uptime": + return HostResult(read_uptime(uptime_path)) + if request_text == "load": + return HostResult(read_load_average(load_path)) + if request_text == "memory": + return HostResult(read_memory(memory_path)) + if request_text == "disk-free": + return HostResult(read_disk_free(disk_path)) + if request_text == "reboot": + if not allow_reboot: + return HostResult("Err - host reboot is disabled") + return HostResult( + f"OK - host reboot scheduled in {reboot_delay}s", + reboot_requested=True, + ) + if request_text == "run" or request_text.startswith("run "): + return run_configured_program(request_text, programs or {}) + return HostResult("Err - unsupported host request") + + +def schedule_host_reboot(delay_seconds: int) -> None: + # Fixed absolute paths and argv are intentional. Never substitute request + # text into this action and never enable shell=True. + command = [ + "/usr/bin/sudo", "-n", "/usr/bin/systemctl", "reboot" + ] + + def reboot() -> None: + try: + completed = subprocess.run( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + timeout=15, + check=False, + ) + if completed.returncode != 0: + LOGGER.error( + "Host reboot failed with code %d: %s", + completed.returncode, + completed.stderr.strip(), + ) + except (OSError, subprocess.SubprocessError) as exc: + LOGGER.error("Host reboot failed: %s", exc) + + timer = threading.Timer(delay_seconds, reboot) + timer.daemon = True + timer.start() + + +def bounded_line_text(text: str, max_bytes: int = MAX_REPLY_BYTES) -> str: + line_safe = "".join( + character if character.isprintable() else " " + for character in text + ).strip() + if not line_safe: + line_safe = "Err - empty host reply" + encoded = line_safe.encode("utf-8") + if len(encoded) <= max_bytes: + return line_safe + return encoded[:max_bytes].decode("utf-8", errors="ignore") + + +def make_serial_reply(request: HostRequest, response: str) -> str: + return ( + f"host.reply {request.request_id} {request.request_nonce} " + f"{bounded_line_text(response)}" + ) + + +def generate_service_challenge() -> str: + while True: + challenge = secrets.token_hex(8).upper() + if int(challenge, 16) != 0: + return challenge + + +def _base64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def create_auth_token( + key: ServiceKey, + claims: dict[str, Any], + expiry_seconds: int = 30, + now: int | None = None, + signer: Signer | None = None, +) -> str: + issued_at = int(time.time()) if now is None else now + header = {"alg": "Ed25519", "typ": "JWT"} + payload: dict[str, Any] = { + "publicKey": key.public_key, + "iat": issued_at, + "exp": issued_at + expiry_seconds, + } + payload.update(claims) + header_part = _base64url( + json.dumps(header, separators=(",", ":")).encode("utf-8") + ) + payload_part = _base64url( + json.dumps(payload, separators=(",", ":")).encode("utf-8") + ) + signing_input = f"{header_part}.{payload_part}" + signer = signer or _orlp_signer() + signature = signer( + signing_input.encode("ascii"), + bytes.fromhex(key.public_key), + bytes.fromhex(key.private_key), + ) + if len(signature) != 64: + raise ValueError("Ed25519 signer returned the wrong signature length") + return f"{signing_input}.{signature.hex().upper()}" + + +class HostCliEndpoint: + def __init__( + self, + client: Any, + repeater_public_key: str, + service_key: ServiceKey, + command_topic: str, + temperature_path: Path, + allow_reboot: bool = False, + reboot_delay: int = 5, + reboot_scheduler: Callable[[int], None] = schedule_host_reboot, + programs: dict[str, ProgramDefinition] | None = None, + dedupe_seconds: float = 60.0, + challenge_timeout_seconds: float = CHALLENGE_TIMEOUT_SECONDS, + challenge_generator: Callable[[], str] = generate_service_challenge, + monotonic: Callable[[], float] = time.monotonic, + ) -> None: + self.client = client + self.repeater_public_key = normalize_key( + repeater_public_key, 32, "repeater public key" + ) + self.service_key = service_key + self.command_topic = command_topic + self.temperature_path = temperature_path + self.allow_reboot = allow_reboot + self.reboot_delay = reboot_delay + self.reboot_scheduler = reboot_scheduler + self.programs = programs or {} + self.dedupe_seconds = dedupe_seconds + self.challenge_timeout_seconds = challenge_timeout_seconds + self.challenge_generator = challenge_generator + self.monotonic = monotonic + self.seen: OrderedDict[tuple[str, str], float] = OrderedDict() + self.pending: PendingHostAction | None = None + + def _already_seen(self, request: HostRequest) -> bool: + now = self.monotonic() + cutoff = now - self.dedupe_seconds + while self.seen and next(iter(self.seen.values())) < cutoff: + self.seen.popitem(last=False) + key = (request.request_id, request.request_nonce) + if key in self.seen: + return True + self.seen[key] = now + while len(self.seen) > 64: + self.seen.popitem(last=False) + return False + + def _publish_serial_reply( + self, request: HostRequest, response: str + ) -> Any: + serial_command = make_serial_reply(request, response) + token = create_auth_token( + self.service_key, + { + "command": serial_command, + "target": self.repeater_public_key, + "nonce": secrets.token_hex(16), + }, + ) + result = self.client.publish(self.command_topic, token, qos=1) + return_code = getattr(result, "rc", 0) + if return_code != 0: + raise RuntimeError(f"MQTT publish failed with code {return_code}") + return result + + def _handle_request(self, request: HostRequest) -> bool: + if self._already_seen(request): + LOGGER.info("Ignoring duplicate host request %s", request.request_id) + return True + + now = self.monotonic() + if self.pending is not None and self.pending.expires_at > now: + LOGGER.warning( + "Ignoring host request %s while another claim is pending", + request.request_id, + ) + return True + self.pending = None + + challenge = self.challenge_generator().upper() + if ( + re.fullmatch(r"[0-9A-F]{16}", challenge) is None + or int(challenge, 16) == 0 + ): + self.seen.pop((request.request_id, request.request_nonce), None) + raise RuntimeError("challenge generator returned an invalid value") + self.pending = PendingHostAction( + request=request, + challenge=challenge, + expires_at=now + self.challenge_timeout_seconds, + ) + try: + self._publish_serial_reply(request, "@claim=" + challenge) + except Exception: + self.pending = None + self.seen.pop((request.request_id, request.request_nonce), None) + raise + LOGGER.info("Requested live proof for host request %s", request.request_id) + return True + + def _handle_claim(self, claim: HostClaim) -> bool: + pending = self.pending + if pending is None: + LOGGER.info("Ignoring host claim with no pending request") + return True + if pending.expires_at <= self.monotonic(): + self.pending = None + LOGGER.info("Ignoring expired host claim %s", claim.request_id) + return True + request = pending.request + if not ( + hmac.compare_digest(claim.request_id, request.request_id) + and hmac.compare_digest(claim.request_nonce, request.request_nonce) + and hmac.compare_digest(claim.challenge, pending.challenge) + ): + LOGGER.warning("Ignoring mismatched host claim %s", claim.request_id) + return True + + # Remove the one-time proof before doing anything with side effects. + # MQTT redelivery or a replay can therefore never execute the action twice. + self.pending = None + response = handle_request( + request.text, + self.temperature_path, + allow_reboot=self.allow_reboot, + reboot_delay=self.reboot_delay, + programs=self.programs, + ) + result = self._publish_serial_reply(request, response.text) + if response.reboot_requested: + wait_for_publish = getattr(result, "wait_for_publish", None) + if callable(wait_for_publish): + wait_for_publish(timeout=2.0) + self.reboot_scheduler(self.reboot_delay) + LOGGER.info( + "Replied to verified host request %s (%r)", + request.request_id, + request.text, + ) + return True + + def handle_mqtt_message(self, mqtt_payload: bytes | str) -> bool: + request = parse_and_verify_request( + mqtt_payload, self.repeater_public_key + ) + if request is not None: + return self._handle_request(request) + claim = parse_and_verify_claim(mqtt_payload, self.repeater_public_key) + if claim is not None: + return self._handle_claim(claim) + return False + + +def _read_password(path: Path | None) -> str | None: + if path is None: + return None + password = path.read_text(encoding="utf-8").rstrip("\r\n") + if not password: + raise ValueError("MQTT password file is empty") + return password + + +def run_endpoint(args: argparse.Namespace) -> None: + try: + import paho.mqtt.client as mqtt + except ImportError as exc: + raise RuntimeError( + "paho-mqtt is required; use meshcoretomqtt's virtualenv" + ) from exc + + service_key = load_service_key(args.service_key) + repeater_public_key = normalize_key( + args.repeater_key, 32, "repeater public key" + ) + iata = args.iata.upper() + if not re.fullmatch(r"[A-Z0-9]{3}", iata): + raise ValueError("IATA must be exactly three letters or digits") + + request_topic = args.request_topic or ( + f"meshcore/{iata}/{repeater_public_key}/debug" + ) + command_topic = args.command_topic or ( + f"meshcore/{iata}/{repeater_public_key}/serial/commands" + ) + client_id = args.client_id or ( + "meshcore-host-" + repeater_public_key[:8].lower() + ) + + try: + client = mqtt.Client( + mqtt.CallbackAPIVersion.VERSION2, client_id=client_id + ) + except AttributeError: + client = mqtt.Client(client_id=client_id) + + if args.username is not None: + client.username_pw_set(args.username, _read_password(args.password_file)) + elif args.password_file is not None: + raise ValueError("--password-file requires --username") + if args.tls: + client.tls_set(ca_certs=str(args.ca_cert) if args.ca_cert else None) + + if args.allow_reboot: + required_binaries = (Path("/usr/bin/sudo"), Path("/usr/bin/systemctl")) + missing_binaries = [ + str(path) for path in required_binaries + if not path.is_file() or not os.access(path, os.X_OK) + ] + if missing_binaries: + raise ValueError( + "host reboot needs executable " + ", ".join(missing_binaries) + ) + + programs = ( + load_programs_file(args.programs_file) + if args.programs_file is not None else {} + ) + + endpoint = HostCliEndpoint( + client=client, + repeater_public_key=repeater_public_key, + service_key=service_key, + command_topic=command_topic, + temperature_path=args.temperature_path, + allow_reboot=args.allow_reboot, + reboot_delay=args.reboot_delay, + programs=programs, + ) + + def on_connect( + connected_client: Any, + _userdata: Any, + _flags: Any, + reason_code: Any, + _properties: Any = None, + ) -> None: + if reason_code != 0: + LOGGER.error("MQTT connection failed: %s", reason_code) + return + connected_client.subscribe(request_topic, qos=1) + LOGGER.info("Listening for signed host requests on %s", request_topic) + + def on_message( + _client: Any, _userdata: Any, message: Any + ) -> None: + try: + endpoint.handle_mqtt_message(message.payload) + except (RuntimeError, ValueError) as exc: + LOGGER.warning("Rejected host request: %s", exc) + + client.on_connect = on_connect + client.on_message = on_message + client.connect(args.broker, args.port, keepalive=60) + LOGGER.info("Connecting to MQTT broker %s:%d", args.broker, args.port) + client.loop_forever(retry_first_connection=True) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Verified MeshCore host CLI Raspberry Pi MQTT endpoint" + ) + parser.add_argument( + "--generate-key", type=Path, + help="create a mode-0600 service key file and exit", + ) + parser.add_argument("--broker") + parser.add_argument("--port", type=int, default=1883) + parser.add_argument("--iata") + parser.add_argument("--repeater-key") + parser.add_argument("--service-key", type=Path) + parser.add_argument("--username") + parser.add_argument("--password-file", type=Path) + parser.add_argument("--tls", action="store_true") + parser.add_argument("--ca-cert", type=Path) + parser.add_argument("--request-topic") + parser.add_argument("--command-topic") + parser.add_argument("--client-id") + parser.add_argument( + "--temperature-path", type=Path, + default=Path("/sys/class/thermal/thermal_zone0/temp"), + ) + parser.add_argument( + "--allow-reboot", action="store_true", + help="allow the exact 'reboot' request for the attached host", + ) + parser.add_argument( + "--reboot-delay", type=int, default=5, + help="seconds to wait after publishing the reply before rebooting (3-60)", + ) + parser.add_argument( + "--programs-file", type=Path, + help="JSON allowlist for fixed programs and validated arguments", + ) + parser.add_argument( + "--log-level", choices=("DEBUG", "INFO", "WARNING", "ERROR"), + default="INFO", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + logging.basicConfig( + level=getattr(logging, args.log_level), + format="%(asctime)s %(levelname)s %(message)s", + ) + try: + if args.generate_key is not None: + public_key = generate_service_key(args.generate_key) + print(f"Created {args.generate_key} (mode 600)") + print(f"Add this public key to allowed_companions: {public_key}") + return 0 + required = { + "--broker": args.broker, + "--iata": args.iata, + "--repeater-key": args.repeater_key, + "--service-key": args.service_key, + } + missing = [name for name, value in required.items() if value is None] + if missing: + parser.error("required for service mode: " + ", ".join(missing)) + if not 3 <= args.reboot_delay <= 60: + parser.error("--reboot-delay must be from 3 through 60 seconds") + run_endpoint(args) + return 0 + except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc: + LOGGER.error("%s", exc) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/host_cli_service/pi_cpu_temp_service.py b/examples/host_cli_service/pi_cpu_temp_service.py new file mode 100755 index 00000000..d3f84d41 --- /dev/null +++ b/examples/host_cli_service/pi_cpu_temp_service.py @@ -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()) diff --git a/examples/host_cli_service/programs.example.json b/examples/host_cli_service/programs.example.json new file mode 100644 index 00000000..470080b7 --- /dev/null +++ b/examples/host_cli_service/programs.example.json @@ -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 + } + ] + } + } +} diff --git a/examples/host_cli_service/test_host_cli_service.py b/examples/host_cli_service/test_host_cli_service.py new file mode 100644 index 00000000..bd63fd2f --- /dev/null +++ b/examples/host_cli_service/test_host_cli_service.py @@ -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() diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 3f92a5b0..9d837a3e 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -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 "); + } 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 = diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 59446b56..2acd4269 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -83,6 +83,12 @@ #include #include #include +#ifndef MESH_ENABLE_HOST_CLI +#define MESH_ENABLE_HOST_CLI 1 +#endif +#if MESH_ENABLE_HOST_CLI +#include +#endif #include #include #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; diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 4961099d..a4c837c4 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -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); diff --git a/platformio.ini b/platformio.ini index 4e92e4d6..017e3f94 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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> diff --git a/src/helpers/CLICommandUtils.h b/src/helpers/CLICommandUtils.h index bf17c487..6dfbab61 100644 --- a/src/helpers/CLICommandUtils.h +++ b/src/helpers/CLICommandUtils.h @@ -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 diff --git a/src/helpers/ClockSyncUtils.h b/src/helpers/ClockSyncUtils.h index 52ef7651..21beccf2 100644 --- a/src/helpers/ClockSyncUtils.h +++ b/src/helpers/ClockSyncUtils.h @@ -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; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index f74dbf8f..03f47a34 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -7,6 +7,7 @@ #include "AlertReporter.h" // for alertReporterBannedChannelMatch() #if defined(NRF52_PLATFORM) #include "AtomicFileWriter.h" +#include "ota/OtaFlashLayout_nrf52.h" #endif #include #include @@ -50,11 +51,16 @@ static void resetToUf2Bootloader() { #ifdef ESP_PLATFORM #include #include +#include +#include #include #include #else #include // mallinfo() for the `memory` command on nRF52/RP2040 #endif +#if defined(STM32_PLATFORM) +#include +#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 diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index bf248fca..b306fa15 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -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); diff --git a/src/helpers/HostCliBridge.h b/src/helpers/HostCliBridge.h new file mode 100644 index 00000000..0dc4ac18 --- /dev/null +++ b/src/helpers/HostCliBridge.h @@ -0,0 +1,294 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +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 diff --git a/src/helpers/MeshClockSync.cpp b/src/helpers/MeshClockSync.cpp index 3f1cea80..92dbc2b8 100644 --- a/src/helpers/MeshClockSync.cpp +++ b/src/helpers/MeshClockSync.cpp @@ -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)) { diff --git a/src/helpers/esp32/WiFiOtaSeeder.cpp b/src/helpers/esp32/WiFiOtaSeeder.cpp index 1373be31..27e2c700 100644 --- a/src/helpers/esp32/WiFiOtaSeeder.cpp +++ b/src/helpers/esp32/WiFiOtaSeeder.cpp @@ -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; diff --git a/src/helpers/ota/FolderMotaStore.cpp b/src/helpers/ota/FolderMotaStore.cpp index ba5cfdd9..d15834a0 100644 --- a/src/helpers/ota/FolderMotaStore.cpp +++ b/src/helpers/ota/FolderMotaStore.cpp @@ -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) diff --git a/src/helpers/ota/FolderMotaStore.h b/src/helpers/ota/FolderMotaStore.h index cba8319d..993eea19 100644 --- a/src/helpers/ota/FolderMotaStore.h +++ b/src/helpers/ota/FolderMotaStore.h @@ -1,6 +1,7 @@ #pragma once #include +#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; }; diff --git a/src/helpers/ota/MotaSourceSerial.cpp b/src/helpers/ota/MotaSourceSerial.cpp index fb759de2..f8740c23 100644 --- a/src/helpers/ota/MotaSourceSerial.cpp +++ b/src/helpers/ota/MotaSourceSerial.cpp @@ -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; diff --git a/src/helpers/ota/MotaSourceSerial.h b/src/helpers/ota/MotaSourceSerial.h index 4dc3142c..ae56d758 100644 --- a/src/helpers/ota/MotaSourceSerial.h +++ b/src/helpers/ota/MotaSourceSerial.h @@ -1,25 +1,23 @@ #pragma once #include +#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 diff --git a/src/helpers/ota/MotaStreamWritePolicy.h b/src/helpers/ota/MotaStreamWritePolicy.h new file mode 100644 index 00000000..e814e3ef --- /dev/null +++ b/src/helpers/ota/MotaStreamWritePolicy.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +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 diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 8286c5e3..b50c762d 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -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 diff --git a/test/README.md b/test/README.md index a75de6fd..3354c6ac 100644 --- a/test/README.md +++ b/test/README.md @@ -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) diff --git a/test/test_cli_command_utils/test_cli_command_utils.cpp b/test/test_cli_command_utils/test_cli_command_utils.cpp index 74980f60..95f3d272 100644 --- a/test/test_cli_command_utils/test_cli_command_utils.cpp +++ b/test/test_cli_command_utils/test_cli_command_utils.cpp @@ -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) { diff --git a/test/test_host_cli_bridge/test_host_cli_bridge.cpp b/test/test_host_cli_bridge/test_host_cli_bridge.cpp new file mode 100644 index 00000000..1c7f18bb --- /dev/null +++ b/test/test_host_cli_bridge/test_host_cli_bridge.cpp @@ -0,0 +1,172 @@ +#include + +#include + +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(); +} diff --git a/test/test_ota/test_ota_core.cpp b/test/test_ota/test_ota_core.cpp index 1b99992b..fd945eb5 100644 --- a/test/test_ota/test_ota_core.cpp +++ b/test/test_ota/test_ota_core.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #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 offsets; std::vector lengths; bool request_valid = true; + bool flush_discards_receive = false; + size_t flush_calls = 0; private: std::vector response; size_t response_pos = 0; }; +static_assert(!std::is_constructible::value, + "mOTA source callers must choose a stream write policy"); +static_assert(!std::is_constructible::value, + "a timeout must not be mistaken for a stream write policy"); +static_assert(!std::is_constructible::value, + "folder store callers must choose a stream write policy"); +static_assert(!std::is_constructible::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 data{}; ASSERT_TRUE(source.read(0, 0x1000, data.data(), data.size())); @@ -711,14 +735,16 @@ TEST(MotaSourceSerial, SplitsOneKilobyteReadsBelowCdcReceiveRing) { (std::vector{0x1000, 0x10C0, 0x1180, 0x1240, 0x1300, 0x13C0})); EXPECT_EQ(stream.lengths, (std::vector{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 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 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 operations; + bool request_valid = true; + size_t flush_calls = 0; + +private: + std::vector 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{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). diff --git a/test/test_trace_retry/test_trace_retry.cpp b/test/test_trace_retry/test_trace_retry.cpp index 5824a1c6..e4a0f108 100644 --- a/test/test_trace_retry/test_trace_retry.cpp +++ b/test/test_trace_retry/test_trace_retry.cpp @@ -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; diff --git a/tools/lora_ota/lora_ota.py b/tools/lora_ota/lora_ota.py index 4a971ffb..960c70f2 100755 --- a/tools/lora_ota/lora_ota.py +++ b/tools/lora_ota/lora_ota.py @@ -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, "") + return value + + +def _debug_text( + value: str | bytes | None, + sensitive_values: tuple[str, ...] = (), +) -> str: + if value is None: + return "" + if isinstance(value, bytes): + value = value.decode("utf-8", "replace") + return redact_text(value, sensitive_values).rstrip() or "" + + +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] = "" + 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 diff --git a/tools/lora_ota/rak3401_mota_chain.py b/tools/lora_ota/rak3401_mota_chain.py index 56e2a76a..5f1d02ff 100755 --- a/tools/lora_ota/rak3401_mota_chain.py +++ b/tools/lora_ota/rak3401_mota_chain.py @@ -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) diff --git a/tools/lora_ota/test_lora_ota.py b/tools/lora_ota/test_lora_ota.py index 86e23ff2..78bfb6b7 100644 --- a/tools/lora_ota/test_lora_ota.py +++ b/tools/lora_ota/test_lora_ota.py @@ -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("", 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("", 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([ diff --git a/variants/wio-e5-dev/platformio.ini b/variants/wio-e5-dev/platformio.ini index 46ba9cb0..5b1f763c 100644 --- a/variants/wio-e5-dev/platformio.ini +++ b/variants/wio-e5-dev/platformio.ini @@ -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"'