mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-23 16:52:22 +00:00
Allow local CLI maintenance across direct connections
This commit is contained in:
1 parent
001b4985f7
commit
06c51fdef5
39 files changed
+3338
-2312
No files matched your search
@@ -432,6 +432,8 @@ Each slot (1-6) supports the following commands:
|
||||
- `get mqttN.audience` - Get JWT audience for slot N (custom slots only)
|
||||
- `get mqttN.filter` - Get the slot's packet-type allowlist (`all`, `none`, or numeric CSV)
|
||||
|
||||
- `get mqttN.password` / `get mqttN.token` - Read stored slot credentials from a local USB, TCP/Ethernet, or LAN browser CLI; Companion also accepts binary command `0x42`. LoRa callers cannot read their values.
|
||||
|
||||
#### Set Commands
|
||||
- `set mqttN.preset <name>` - Set slot N to a built-in preset. Use any `name` from [Broker Presets](#broker-presets), which also lists the few presets needing extra setup.
|
||||
- `set mqttN.preset custom` - Set slot N to custom broker (configure server/port/username/password)
|
||||
@@ -605,8 +607,8 @@ These settings apply across all MQTT slots:
|
||||
- `get mqtt.neighbors.interval` - Get neighbors publish interval in hours (neighbors-enabled builds)
|
||||
- `get mqtt.ntp` - Get effective NTP server hostname
|
||||
- `get mqtt.ntp.diag` - Probe every configured NTP server for connectivity (does not change the clock; serial console shows each server's reported time, LoRa shows a compact `<server> ok|fail` list)
|
||||
- `get mqtt.owner` - Get owner public key (serial console only)
|
||||
- `get mqtt.email` - Get owner email address (serial console only)
|
||||
- `get mqtt.owner` - Get owner public key (local connections only)
|
||||
- `get mqtt.email` - Get owner email address (local connections only)
|
||||
|
||||
#### Set Commands
|
||||
- `set mqtt.origin <name>` - Set device origin name
|
||||
|
||||
+13
-6
@@ -372,7 +372,8 @@ 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
|
||||
OTA-enabled Companion builds expose the same local maintenance commands as
|
||||
binary command `0x42`, plus `ota ...` commands. 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
|
||||
@@ -468,8 +469,8 @@ An inactive status is normal when `webui` is off: run `start webconfig` to
|
||||
connect temporarily. Standalone credentials can be changed through WebConfig
|
||||
or with the listed CLI commands. Changing the SSID or password stops an active
|
||||
WebConfig session; start it again to connect with the new values. Use
|
||||
`set wifi.pwd` with no value for an open network. The password is write-only
|
||||
and is never returned by `get`. Standalone WiFi accepts ordinary passphrases up
|
||||
`set wifi.pwd` with no value for an open network. `get wifi.pwd` returns the saved password on a local connection; LoRa
|
||||
callers cannot read it. Standalone WiFi accepts ordinary passphrases up
|
||||
to 63 characters and exact 64-character hexadecimal WPA/WPA2 PSKs; other
|
||||
64-character values and all longer values are rejected. MQTT observer WiFi
|
||||
passwords remain limited to 63 characters by their fixed persisted layout.
|
||||
@@ -492,8 +493,14 @@ get wifi.cli
|
||||
The saved `on` setting becomes active only when the WiFi station client is
|
||||
connected and WebConfig is running in LAN mode. It is never exposed on the open
|
||||
setup access point. When active, the WebConfig page has a **CLI** tab. Repeater
|
||||
and room-server commands use the authenticated administrator parser; commands
|
||||
restricted to a physical serial connection remain unavailable there.
|
||||
and room-server commands use their local CLI after admin authentication.
|
||||
The browser can run `get password`, `get prv.key`, `erase`, `set freq`,
|
||||
`stats-core`, `stats-radio`, `stats-radio-diag`, `stats-packets`, and explicit
|
||||
WiFi/MQTT credential getters. It also streams `get acl` and raw `log` dumps
|
||||
back to the browser. Log reads pause for slow clients and resume as output is
|
||||
read, using a fixed 160-byte file chunk. A dump snapshots the file length, so
|
||||
live logging cannot extend it indefinitely. The browser keeps the complete
|
||||
requested dump, including files larger than 64 KiB.
|
||||
|
||||
Companion uses the same complete terminal as USB and Full Companion TCP port
|
||||
5002, including `card`, `import meshcore://...`, `list`, `to`, messages, remote
|
||||
@@ -503,7 +510,7 @@ for contact import, terminal ownership, and disconnect behavior.
|
||||
|
||||
Type a command at the prompt, or paste multiple lines and confirm the sequence.
|
||||
Blank lines and comments are ignored. The device reports the supported batch
|
||||
limit. Companion sends each line after the previous command finishes and
|
||||
limit. The browser sends each line after the previous command finishes and
|
||||
continues polling for incoming text after the sequence. `help` lists the
|
||||
device's actual terminal commands; Tab completes command names and settings.
|
||||
|
||||
|
||||
@@ -63,6 +63,11 @@ limits reduce selected Generic E22, Heltec V2, Meshadventurer, T-Beam OTA/MQTT,
|
||||
and TLora MQTT profiles to 50. Check the artifact capability manifest and
|
||||
[ESP32 memory budget](esp32_memory_budget.md) for the exact target.
|
||||
|
||||
See [local maintenance commands](terminal_chat_cli.md#local-maintenance-commands)
|
||||
for USB, BLE/binary Companion, TCP/Ethernet, and browser CLI privileges.
|
||||
A direct connection can access local diagnostics and explicit credential
|
||||
getters; relaying a command over LoRa keeps its remote restrictions.
|
||||
|
||||
## Profile matrix
|
||||
|
||||
| Build/profile | Command availability |
|
||||
|
||||
@@ -9,7 +9,11 @@ sensor, and bridge firmware. Full Companion also has its own ASCII USB/TCP
|
||||
terminal; see [feature switches by role](role_feature_switches.md). Companion
|
||||
apps use the companion protocol,
|
||||
KISS firmware uses framed KISS/TNC commands, and terminal-chat firmware has its
|
||||
own interface, so those build roles are not represented here.
|
||||
own interface, so those build roles are not represented in the main matrices.
|
||||
The Companion section below lists its framed maintenance commands.
|
||||
Infrastructure `get password` is available locally in every profile; LoRa
|
||||
requests cannot read it. `get acl` and stored packet `log` are infrastructure
|
||||
features, not Companion contact or live USB-logging commands.
|
||||
|
||||
Build columns mean:
|
||||
|
||||
@@ -45,7 +49,9 @@ Cell values mean:
|
||||
- **No** - the profile does not expose the command.
|
||||
- **Feature** - available only when the target compiles the feature or hardware
|
||||
named in Scope.
|
||||
- **Serial** - available only from the local serial console.
|
||||
- **Local** - a direct USB, BLE/binary Companion, TCP/Ethernet, or LAN browser
|
||||
connection, when that transport and command exist on the role; unavailable
|
||||
over LoRa.
|
||||
- **Manifest** - available only when the MQTT target defines
|
||||
`OTA_MANIFEST_BASE`.
|
||||
- **Limited** - the family exists, but the limitation in Scope applies.
|
||||
@@ -62,6 +68,11 @@ over the normal binary USB, BLE, or TCP connection:
|
||||
|
||||
| Command | Availability |
|
||||
|---|---|
|
||||
| `stats-core`, `stats-radio`, `stats-radio-diag`, `stats-packets` | Local terminal and binary command `0x42` |
|
||||
| `erase`, `set freq <MHz>` | Local terminal and binary command `0x42`; stored changes apply on reboot |
|
||||
| `get prv.key` | Local terminal and binary command `0x42`; requires private key export enabled |
|
||||
| `get password` | Reports that Companion has no admin password; infrastructure returns its password locally |
|
||||
| `get wifi.pwd`, `get mqttN.password`, `get mqttN.token` | Local terminal and binary command `0x42`; corresponding WiFi/MQTT feature required |
|
||||
| [`board`](cli_commands.md#show-the-hardware-name) | Every Companion text terminal and command `0x42` |
|
||||
| [`version`](cli_commands.md#get-the-version) | Every Companion text terminal and protocol-v14 command `0x42`; returns the untruncated build identity |
|
||||
| [`get storage.layout`](cli_commands.md#show-the-storage-layout) | Every Companion text terminal and command `0x42`; reports the running platform's storage layout |
|
||||
@@ -82,14 +93,14 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
|
||||
|---|---|---|---|---|---|
|
||||
| Operational | [`reboot`](cli_commands.md#reboot-the-node) | All text CLI roles | Yes | Yes | Yes |
|
||||
| Operational | [`poweroff`; `shutdown`](cli_commands.md#power-off-the-node) | Board power-off support | Yes | Yes | Yes |
|
||||
| Operational | [`uf2reset`](cli_commands.md#enter-the-uf2-bootloader-nrf52-only) | Local serial; supported UF2 boards | Serial | Serial | Serial |
|
||||
| Operational | [`uf2reset`](cli_commands.md#enter-the-uf2-bootloader-nrf52-only) | Local connection; supported UF2 boards | Local | Local | Local |
|
||||
| Operational | [`clkreboot`](cli_commands.md#reset-the-clock-and-reboot) | All text CLI roles | Yes | Yes | Yes |
|
||||
| Operational | [`clock sync`](cli_commands.md#sync-the-clock-with-the-remote-device) | All text CLI roles | Yes | Yes | Yes |
|
||||
| Operational | [`clock`](cli_commands.md#display-current-time-in-utc) | All text CLI roles | Yes | Yes | Yes |
|
||||
| Operational | [`time <epoch_seconds>`](cli_commands.md#set-the-time-to-a-specific-timestamp) | Clock only moves forward | Yes | Yes | Yes |
|
||||
| Operational | [`advert`](cli_commands.md#send-a-flood-advert) | Advert-capable role | Yes | Yes | Yes |
|
||||
| Operational | [`advert.zerohop`](cli_commands.md#send-a-zero-hop-advert) | Advert-capable role | Yes | Yes | Yes |
|
||||
| Operational | [`erase`](cli_commands.md#erasefactory-reset) | Local serial | Serial | Serial | Serial |
|
||||
| Operational | [`erase`](cli_commands.md#erasefactory-reset) | Local connection | Local | Local | Local |
|
||||
| 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 |
|
||||
@@ -101,20 +112,20 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
|
||||
| Neighbors | [`discover.neighbors`](cli_commands.md#discover-zero-hop-neighbors) | Repeater; some MQTT room servers | Yes | Yes | Yes |
|
||||
| Neighbors | [`discover.scopes`](cli_commands.md#discover-neighbor-scopes-mqtt-observer-neighbors-feature) | MQTT observer with compiled neighbor support | No | No | No |
|
||||
| Statistics | [`clear stats`](cli_commands.md#clear-stats) | All full-parser text CLI roles | Yes | Yes | Yes |
|
||||
| Statistics | [`stats-core`](cli_commands.md#stats-core) | Local serial | Serial | Serial | Serial |
|
||||
| Statistics | [`stats-radio`](cli_commands.md#stats-radio) | Local serial | Serial | Serial | Serial |
|
||||
| Statistics | [`stats-radio-diag`](#stats-radio-diag) | Local serial | Serial | Serial | Serial |
|
||||
| Statistics | [`stats-packets`](cli_commands.md#stats-packets) | Local serial | Serial | Serial | Serial |
|
||||
| Statistics | [`stats-core`](cli_commands.md#stats-core) | Local connection | Local | Local | Local |
|
||||
| Statistics | [`stats-radio`](cli_commands.md#stats-radio) | Local connection | Local | Local | Local |
|
||||
| Statistics | [`stats-radio-diag`](#stats-radio-diag) | Local connection | Local | Local | Local |
|
||||
| Statistics | [`stats-packets`](cli_commands.md#stats-packets) | Local connection | Local | Local | Local |
|
||||
| Statistics | [`get telemetry.temp/volt/volt.i2c`; optional GPS history](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater or room server; I2C command requires a detected voltage monitor; GPS requires a provider; remote access requires administrator | Yes | Yes | Yes |
|
||||
| Statistics | [`set telemetry.gps`; `get/set/send telemetry.tx`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; GPS setting requires a provider; remote access requires administrator | Yes | Yes | Yes |
|
||||
| Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data; other roles can return empty data | Yes | Yes | Yes |
|
||||
| Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local serial | Serial | Serial | Serial |
|
||||
| Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local connection | Local | Local | Local |
|
||||
| Logging | [`get/set usb.logging`; unified FULL `get/set logging.output`](cli_commands.md#control-live-usb-logging) | Ordinary safe-USB artifacts; CommonCLI USB gate is persistent; unified ESP32 FULL selects off/USB/WiFi/both; nRF52 Full Companion can add a reboot-controlled second CDC | Yes | Yes | No |
|
||||
| Radio | [`get radio`; `set radio ...`](cli_commands.md#view-or-change-this-nodes-radio-parameters) | All text CLI roles | Yes | Yes | Yes |
|
||||
| Radio | [`get tx`; `set tx <dbm>`](cli_commands.md#view-or-change-this-nodes-transmit-power) | Board TX-power limits apply | Yes | Yes | Yes |
|
||||
| Radio | [`tempradio ...`; `normalradio`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes |
|
||||
| Radio | [`get/set/del radioat`; `get/set/del tempradioat`](cli_commands.md#schedule-radio-parameter-changes) | Full parser | Yes | Yes | Yes |
|
||||
| Radio | [`get freq`; `set freq <mhz>`](cli_commands.md#view-or-change-this-nodes-frequency) | `set` is local serial only | Yes | Yes | Yes |
|
||||
| Radio | [`get freq`; `set freq <mhz>`](cli_commands.md#view-or-change-this-nodes-frequency) | `set` requires a local connection | Yes | Yes | Yes |
|
||||
| Radio | [`get/set radio.rxgain`](cli_commands.md#view-or-change-this-nodes-rx-boosted-gain-mode-sx12xx-and-lr1110-v1141) | Supported radio | Feature | Feature | Feature |
|
||||
| Radio | [`get/set radio.fem.rxgain`](cli_commands.md#view-or-change-the-lora-fem-receive-path-gain-state-on-supported-boards) | Controllable LoRa FEM | Feature | Feature | Feature |
|
||||
| Radio | [`get/set radio.fem.txgain`](cli_commands.md#view-or-change-the-lora-fem-transmit-path-gain-state-on-supported-boards) | Controllable LoRa FEM | Feature | Feature | Feature |
|
||||
@@ -122,8 +133,8 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
|
||||
| System | [`get/set name`](cli_commands.md#view-or-change-this-nodes-name) | All text CLI roles | Yes | Yes | Yes |
|
||||
| System | [`get/set lat`](cli_commands.md#view-or-change-this-nodes-latitude) | All text CLI roles | Yes | Yes | Yes |
|
||||
| System | [`get/set lon`](cli_commands.md#view-or-change-this-nodes-longitude) | All text CLI roles | Yes | Yes | Yes |
|
||||
| System | [`get/set prv.key`](cli_commands.md#view-or-change-this-nodes-identity-private-key) | `get` is local serial only | Yes | Yes | Yes |
|
||||
| System | [`password <new_password>`](cli_commands.md#change-this-nodes-admin-password) | Administrator | Yes | Yes | Yes |
|
||||
| System | [`get/set prv.key`](cli_commands.md#view-or-change-this-nodes-identity-private-key) | `get` requires a local connection | Yes | Yes | Yes |
|
||||
| System | [`get password`; `password <new_password>`](cli_commands.md#change-this-nodes-admin-password) | `get` requires a local connection; setter requires administrator | Yes | Yes | Yes |
|
||||
| System | [`get/set guest.password`](cli_commands.md#view-or-change-this-nodes-guest-password) | Role with guest administration | Yes | Yes | Yes |
|
||||
| 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 |
|
||||
@@ -164,7 +175,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
|
||||
| Routing | [`get/set outpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes |
|
||||
| Routing | [`get/set altpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes |
|
||||
| ACL | [`setperm <pubkey> <permissions>`](cli_commands.md#add-update-or-remove-permissions-for-a-companion) | Repeater, room server, or sensor | Yes | Yes | Yes |
|
||||
| ACL | [`get acl`](cli_commands.md#view-the-current-acl) | Local serial | Serial | Serial | Serial |
|
||||
| ACL | [`get acl`](cli_commands.md#view-the-current-acl) | Local connection | Local | Local | Local |
|
||||
| ACL | [`get/set allow.read.only`](cli_commands.md#view-or-change-this-room-servers-read-only-flag) | Room server | Yes | Yes | No |
|
||||
| Regions | [`region load`](cli_commands.md#bulk-load-region-lists); [`region save`](cli_commands.md#save-any-changes-to-regions-made-since-reboot) | Role with region storage | Yes | Yes | Yes |
|
||||
| Regions | [`region allowf`](cli_commands.md#allow-a-region); [`region denyf`](cli_commands.md#block-a-region) | Role with region storage | Yes | Yes | Yes |
|
||||
@@ -232,7 +243,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
|
||||
| MQTT | [`get mqtt.ntp.diag`](cli_commands.md#diagnose-ntp-server-connectivity-mqtt-observer-only) | Full MQTT observer | No | No | No |
|
||||
| MQTT | [`get/set timezone`; `get/set timezone.offset`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#timezone-commands) | MQTT observer | No | No | No |
|
||||
| MQTT | [`get/set mqtt.analyzer.us`; `get/set mqtt.analyzer.eu`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#migration-from-old-configuration) | Legacy MQTT aliases | No | No | No |
|
||||
| MQTT | [`get/set mqtt.owner`; `get/set mqtt.email`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer; `get` is local serial only | No | No | No |
|
||||
| MQTT | [`get/set mqtt.owner`; `get/set mqtt.email`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer; `get` requires a local connection | No | No | No |
|
||||
| MQTT | [`get mqtt.config.valid`](#mqtt-config-valid) | MQTT observer | No | No | No |
|
||||
| SNMP | [`get/set snmp`; `get/set snmp.community`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_SNMP.md#cli-commands) | MQTT target compiled with SNMP | No | No | No |
|
||||
| Alerts | [`get/set alert`; `get/set alert.psk`; `get/set alert.hashtag`; `get/set alert.region`; `get/set alert.wifi`; `get/set alert.mqtt`; `get/set alert.interval`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/ALERTS.md#cli) | MQTT observer | No | No | No |
|
||||
@@ -254,7 +265,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
|
||||
| Operational | [`time <epoch_seconds>`](cli_commands.md#set-the-time-to-a-specific-timestamp) | Clock only moves forward | Yes | Yes | Yes | Yes | Yes |
|
||||
| Operational | [`advert`](cli_commands.md#send-a-flood-advert) | Advert-capable role | Yes | Yes | Yes | Yes | Yes |
|
||||
| Operational | [`advert.zerohop`](cli_commands.md#send-a-zero-hop-advert) | Advert-capable role | Yes | Yes | Yes | Yes | Yes |
|
||||
| Operational | [`erase`](cli_commands.md#erasefactory-reset) | Local serial | Serial | Serial | Serial | Serial | Serial |
|
||||
| Operational | [`erase`](cli_commands.md#erasefactory-reset) | Local connection | Local | Local | Local | Local | Local |
|
||||
| Display | [`get/set display.timeout`; `get/set display.flip`](cli_commands.md#set-mqtt-observer-display-timeout-and-flip) | MQTT observer with display; flip needs a supported driver | No | No | No | Feature | No |
|
||||
| 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 |
|
||||
@@ -267,20 +278,20 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
|
||||
| Neighbors | [`discover.neighbors`](cli_commands.md#discover-zero-hop-neighbors) | Repeater; some MQTT room servers | Yes | Yes | Yes | Yes | Yes |
|
||||
| Neighbors | [`discover.scopes`](cli_commands.md#discover-neighbor-scopes-mqtt-observer-neighbors-feature) | MQTT observer with compiled neighbor support | No | No | No | Feature | No |
|
||||
| Statistics | [`clear stats`](cli_commands.md#clear-stats) | Full parser | Yes | Yes | Yes | Yes | Yes |
|
||||
| Statistics | [`stats-core`](cli_commands.md#stats-core) | Local serial | Serial | Serial | Serial | Serial | Serial |
|
||||
| Statistics | [`stats-radio`](cli_commands.md#stats-radio) | Local serial | Serial | Serial | Serial | Serial | Serial |
|
||||
| Statistics | [`stats-radio-diag`](#stats-radio-diag) | Local serial | Serial | Serial | Serial | Serial | Serial |
|
||||
| Statistics | [`stats-packets`](cli_commands.md#stats-packets) | Local serial | Serial | Serial | Serial | Serial | Serial |
|
||||
| Statistics | [`stats-core`](cli_commands.md#stats-core) | Local connection | Local | Local | Local | Local | Local |
|
||||
| Statistics | [`stats-radio`](cli_commands.md#stats-radio) | Local connection | Local | Local | Local | Local | Local |
|
||||
| Statistics | [`stats-radio-diag`](#stats-radio-diag) | Local connection | Local | Local | Local | Local | Local |
|
||||
| Statistics | [`stats-packets`](cli_commands.md#stats-packets) | Local connection | Local | Local | Local | Local | Local |
|
||||
| Statistics | [`get telemetry.temp/volt/volt.i2c`; optional GPS history](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater or room server; I2C command requires a detected voltage monitor; GPS requires a provider; remote access requires administrator | Yes | Yes | Yes | Yes | Yes |
|
||||
| Statistics | [`set telemetry.gps`; `get/set/send telemetry.tx`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; GPS setting requires a provider; remote access requires administrator | Yes | Yes | Yes | Yes | Yes |
|
||||
| Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data | Yes | Yes | Yes | Yes | Yes |
|
||||
| Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local serial | Serial | Serial | Serial | Serial | Serial |
|
||||
| Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local connection | Local | Local | Local | Local | Local |
|
||||
| Logging | [`get/set usb.logging`; unified FULL `get/set logging.output`](cli_commands.md#control-live-usb-logging) | Ordinary safe-USB artifacts; CommonCLI USB gate is persistent; unified ESP32 FULL selects off/USB/WiFi/both; every ESP32 Full Companion uses an input-capable single-TTY logging terminal with framed USB Companion disabled while logging | Yes | Yes | No | No | Yes |
|
||||
| Radio | [`get radio`; `set radio ...`](cli_commands.md#view-or-change-this-nodes-radio-parameters) | All text CLI roles | Yes | Yes | Yes | Yes | Yes |
|
||||
| Radio | [`get tx`; `set tx <dbm>`](cli_commands.md#view-or-change-this-nodes-transmit-power) | Board TX-power limits apply | Yes | Yes | Yes | Yes | Yes |
|
||||
| Radio | [`tempradio ...`; `normalradio`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes | Yes | Yes |
|
||||
| Radio | [`get/set/del radioat`; `get/set/del tempradioat`](cli_commands.md#schedule-radio-parameter-changes) | Full parser | Yes | Yes | Yes | Yes | Yes |
|
||||
| Radio | [`get freq`; `set freq <mhz>`](cli_commands.md#view-or-change-this-nodes-frequency) | `set` is local serial only | Yes | Yes | Yes | Yes | Yes |
|
||||
| Radio | [`get freq`; `set freq <mhz>`](cli_commands.md#view-or-change-this-nodes-frequency) | `set` requires a local connection | Yes | Yes | Yes | Yes | Yes |
|
||||
| Radio | [`get/set radio.rxgain`](cli_commands.md#view-or-change-this-nodes-rx-boosted-gain-mode-sx12xx-and-lr1110-v1141) | Supported radio | Feature | Feature | Feature | Feature | Feature |
|
||||
| Radio | [`get/set espnow.channel`](cli_commands.md#view-or-change-the-primary-esp-nowwifi-channel) | Primary ESP-NOW mesh radio | Feature | Feature | Feature | Feature | Feature |
|
||||
| Radio | [`get/set radio.fem.rxgain`](cli_commands.md#view-or-change-the-lora-fem-receive-path-gain-state-on-supported-boards) | Controllable LoRa FEM | Feature | Feature | Feature | Feature | Feature |
|
||||
@@ -289,8 +300,8 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
|
||||
| System | [`get/set name`](cli_commands.md#view-or-change-this-nodes-name) | All full-parser text CLI roles | Yes | Yes | Yes | Yes | Yes |
|
||||
| System | [`get/set lat`](cli_commands.md#view-or-change-this-nodes-latitude) | All full-parser text CLI roles | Yes | Yes | Yes | Yes | Yes |
|
||||
| System | [`get/set lon`](cli_commands.md#view-or-change-this-nodes-longitude) | All full-parser text CLI roles | Yes | Yes | Yes | Yes | Yes |
|
||||
| System | [`get/set prv.key`](cli_commands.md#view-or-change-this-nodes-identity-private-key) | `get` is local serial only | Yes | Yes | Yes | Yes | Yes |
|
||||
| System | [`password <new_password>`](cli_commands.md#change-this-nodes-admin-password) | Administrator | Yes | Yes | Yes | Yes | Yes |
|
||||
| System | [`get/set prv.key`](cli_commands.md#view-or-change-this-nodes-identity-private-key) | `get` requires a local connection | Yes | Yes | Yes | Yes | Yes |
|
||||
| System | [`get password`; `password <new_password>`](cli_commands.md#change-this-nodes-admin-password) | `get` requires a local connection; setter requires administrator | Yes | Yes | Yes | Yes | Yes |
|
||||
| System | [`get/set guest.password`](cli_commands.md#view-or-change-this-nodes-guest-password) | Role with guest administration | Yes | Yes | Yes | Yes | Yes |
|
||||
| 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 |
|
||||
@@ -331,7 +342,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
|
||||
| Routing | [`get/set outpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | Yes | Yes |
|
||||
| Routing | [`get/set altpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | Yes | Yes |
|
||||
| ACL | [`setperm <pubkey> <permissions>`](cli_commands.md#add-update-or-remove-permissions-for-a-companion) | Repeater, room server, or sensor | Yes | Yes | Yes | Yes | Yes |
|
||||
| ACL | [`get acl`](cli_commands.md#view-the-current-acl) | Local serial | Serial | Serial | Serial | Serial | Serial |
|
||||
| ACL | [`get acl`](cli_commands.md#view-the-current-acl) | Local connection | Local | Local | Local | Local | Local |
|
||||
| ACL | [`get/set allow.read.only`](cli_commands.md#view-or-change-this-room-servers-read-only-flag) | Room server | Yes | Yes | No | Yes | Yes |
|
||||
| Regions | [`region load`](cli_commands.md#bulk-load-region-lists); [`region save`](cli_commands.md#save-any-changes-to-regions-made-since-reboot) | Role with region storage | Yes | Yes | Yes | Yes | Yes |
|
||||
| Regions | [`region allowf`](cli_commands.md#allow-a-region); [`region denyf`](cli_commands.md#block-a-region) | Role with region storage | Yes | Yes | Yes | Yes | Yes |
|
||||
@@ -399,7 +410,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
|
||||
| MQTT | [`get mqtt.ntp.diag`](cli_commands.md#diagnose-ntp-server-connectivity-mqtt-observer-only) | MQTT observer | No | No | No | Yes | No |
|
||||
| MQTT | [`get/set timezone`; `get/set timezone.offset`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#timezone-commands) | MQTT observer | No | No | No | Yes | No |
|
||||
| MQTT | [`get/set mqtt.analyzer.us`; `get/set mqtt.analyzer.eu`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#migration-from-old-configuration) | Legacy MQTT aliases | No | No | No | Yes | No |
|
||||
| MQTT | [`get/set mqtt.owner`; `get/set mqtt.email`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer; `get` is local serial only | No | No | No | Yes | No |
|
||||
| MQTT | [`get/set mqtt.owner`; `get/set mqtt.email`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer; `get` requires a local connection | No | No | No | Yes | No |
|
||||
| MQTT | [`get mqtt.config.valid`](#mqtt-config-valid) | MQTT observer | No | No | No | Yes | No |
|
||||
| SNMP | [`get/set snmp`; `get/set snmp.community`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_SNMP.md#cli-commands) | MQTT target compiled with SNMP | No | No | No | Feature | No |
|
||||
| Alerts | [`get/set alert`; `get/set alert.psk`; `get/set alert.hashtag`; `get/set alert.region`; `get/set alert.wifi`; `get/set alert.mqtt`; `get/set alert.interval`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/ALERTS.md#cli) | MQTT observer | No | No | No | Yes | No |
|
||||
|
||||
+45
-21
@@ -80,7 +80,8 @@ command aliases are listed there explicitly.
|
||||
**Usage:**
|
||||
- `uf2reset`
|
||||
|
||||
**Serial Only:** Yes
|
||||
**Local connection only:** USB, direct network CLI, or Companion binary
|
||||
command `0x42` where the role supports the command; unavailable over LoRa.
|
||||
|
||||
**Note:** Reboots directly into the UF2 bootloader on supported nRF52 boards.
|
||||
This includes the Repeater, Room Server, Sensor, Companion, and Terminal Chat
|
||||
@@ -213,6 +214,7 @@ folder transport.
|
||||
- `set webui off`
|
||||
- `get webui`
|
||||
- `get wifi.ssid`
|
||||
- `get wifi.pwd`
|
||||
- `get wifi.status`
|
||||
- `get wifi.powersave`
|
||||
- `get wifi.cli`
|
||||
@@ -252,8 +254,8 @@ their profile's idle timeout.
|
||||
Every ESP32 build with WebConfig supports the browser command terminal,
|
||||
including WiFi Companion and Full Companion. Companions use their complete
|
||||
USB/TCP terminal on the trusted LAN, including contact import, chat, recipient
|
||||
selection, and delayed replies. Repeater and Room Server use the authenticated
|
||||
admin parser.
|
||||
selection, and delayed replies. Repeater and Room Server use their local CLI
|
||||
after admin authentication.
|
||||
|
||||
To add a Companion contact, run `import meshcore://<full-contact-card-data>`,
|
||||
then `list` to confirm it appears. Get the complete card with `card` on the
|
||||
@@ -266,8 +268,12 @@ the **CLI** tab.
|
||||
`get wifi.cli` reports `off`, `on, waiting for WiFi client`, or `on, active`.
|
||||
The saved setting becomes active only in station/LAN mode while the WiFi client
|
||||
is connected. It is deliberately unavailable on the open setup access point.
|
||||
The tab displays each reply in the browser. Commands explicitly restricted to
|
||||
a physical serial connection remain unavailable.
|
||||
The tab displays each reply in the browser, including local diagnostics,
|
||||
`get password`, private-key and WiFi/MQTT credential reads, `erase`, and
|
||||
`set freq`. Infrastructure `get acl` and raw `log` stream to the requesting
|
||||
browser, with backpressure for slow clients. LoRa callers retain their
|
||||
restrictions. The bounded `/api/cli` endpoint accepts local maintenance
|
||||
commands; use `/api/terminal` for complete ACL and log listings.
|
||||
Select **Command block** to paste up to 100 commands with one command per line.
|
||||
Blank lines are ignored, and every nonblank line must fit the normal 159-byte
|
||||
CLI command limit. The browser sends the lines sequentially and waits for each
|
||||
@@ -302,8 +308,9 @@ ordinary passphrases remain limited to 63 characters. Other 64-character values
|
||||
and all longer values are rejected. MQTT observer WiFi passwords retain their
|
||||
fixed 63-character limit.
|
||||
Power-save changes are applied immediately when WiFi is running and otherwise
|
||||
take effect on the next connection. `get wifi.pwd` is intentionally unavailable
|
||||
so the standalone password is never returned by the CLI.
|
||||
take effect on the next connection. `get wifi.pwd` returns the saved password
|
||||
on a local connection. It remains unavailable or masked over LoRa, and the
|
||||
configuration form continues to mask stored secrets.
|
||||
|
||||
ESP32 WiFi Companion WebConfig exposes the same `wifi.powersave` values in its
|
||||
WiFi card. Every ESP32 WiFi Companion with WebConfig exposes the standalone
|
||||
@@ -314,7 +321,9 @@ 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
|
||||
USB, BLE, or TCP port 5000 without the terminal-start token: send command
|
||||
`0x42` (`CMD_RUN_CLI_COMMAND`) followed by the same CLI text, such as
|
||||
`get wifi.powersave` or `set wifi.powersave min`. WiFi-only Companions accept
|
||||
`get wifi.powersave`, `get wifi.pwd`, `stats-core`, or `set wifi.powersave min`.
|
||||
See [local maintenance commands](terminal_chat_cli.md#local-maintenance-commands)
|
||||
for the complete access rules. WiFi-only Companions accept
|
||||
all three modes. A Full Companion that runs BLE and infrastructure WiFi
|
||||
simultaneously rejects `none` because coexistence requires modem sleep.
|
||||
Companion device power saving and LoRa `radio.rxps` remain independent. On an
|
||||
@@ -378,7 +387,8 @@ remain available.
|
||||
**Usage:**
|
||||
- `erase`
|
||||
|
||||
**Serial Only:** Yes
|
||||
**Local connection only:** USB, direct network CLI, or Companion binary
|
||||
command `0x42` where the role supports the command; unavailable over LoRa.
|
||||
|
||||
**Warning:** _**This is destructive!**_
|
||||
|
||||
@@ -446,7 +456,8 @@ Elsewhere it replies `Err - neighbors not enabled in this build`. If a
|
||||
**Usage:**
|
||||
- `stats-core`
|
||||
|
||||
**Serial Only:** Yes
|
||||
**Local connection only:** USB, direct network CLI, or Companion binary
|
||||
command `0x42` where the role supports the command; unavailable over LoRa.
|
||||
|
||||
---
|
||||
|
||||
@@ -457,7 +468,8 @@ Elsewhere it replies `Err - neighbors not enabled in this build`. If a
|
||||
|
||||
**Usage:** `stats-radio`
|
||||
|
||||
**Serial Only:** Yes
|
||||
**Local connection only:** USB, direct network CLI, or Companion binary
|
||||
command `0x42` where the role supports the command; unavailable over LoRa.
|
||||
|
||||
---
|
||||
|
||||
@@ -465,7 +477,8 @@ Elsewhere it replies `Err - neighbors not enabled in this build`. If a
|
||||
### Packet stats - Packet counters: Received, Sent
|
||||
**Usage:** `stats-packets`
|
||||
|
||||
**Serial Only:** Yes
|
||||
**Local connection only:** USB, direct network CLI, or Companion binary
|
||||
command `0x42` where the role supports the command; unavailable over LoRa.
|
||||
|
||||
---
|
||||
|
||||
@@ -855,10 +868,12 @@ only `get mqtt.enabled`. WiFi modem power saving is a separate setting.
|
||||
|
||||
---
|
||||
|
||||
### Print the captured log to the serial terminal
|
||||
<a id="print-the-captured-log-to-the-serial-terminal"></a>
|
||||
### Print the captured log to the requesting terminal
|
||||
**Usage:** `log`
|
||||
|
||||
**Serial Only:** Yes
|
||||
**Local connection only:** USB, direct network CLI, or Companion binary
|
||||
command `0x42` where the role supports the command; unavailable over LoRa.
|
||||
|
||||
---
|
||||
|
||||
@@ -1052,7 +1067,8 @@ automation treats that version as the wire-format capability boundary.
|
||||
**Default:** `869.525`
|
||||
|
||||
**Note:** Requires reboot to apply
|
||||
**Serial Only:** `set freq <frequency>`
|
||||
**Local connection only:** `set freq <frequency>`. USB, browser/TCP/Ethernet
|
||||
CLI, and Companion binary command `0x42` can set it; LoRa cannot.
|
||||
|
||||
---
|
||||
|
||||
@@ -1315,16 +1331,18 @@ for the tested XIAO handoff and host-cache caveats.
|
||||
**Parameters:**
|
||||
- `private_key`: Private key in hex format (64 hex characters)
|
||||
|
||||
**Serial Only:**
|
||||
- `get prv.key`: Yes
|
||||
- `set prv.key`: No
|
||||
**Local connection only:** `get prv.key`. Companion also requires
|
||||
`ENABLE_PRIVATE_KEY_EXPORT=1`; use binary command `0x42` or the text terminal.
|
||||
Infrastructure `set prv.key` retains its administrator access rules.
|
||||
|
||||
**Note:** Requires reboot to take effect after setting
|
||||
|
||||
---
|
||||
|
||||
#### Change this node's admin password
|
||||
<a id="change-this-nodes-admin-password"></a>
|
||||
#### View or change this node's admin password
|
||||
**Usage:**
|
||||
- `get password`
|
||||
- `password <new_password>`
|
||||
|
||||
**Parameters:**
|
||||
@@ -1334,7 +1352,12 @@ for the tested XIAO handoff and host-cache caveats.
|
||||
|
||||
**Default:** `password`
|
||||
|
||||
**Note:** Command reply echoes the updated password for confirmation.
|
||||
`get password` returns the current admin password only on a local connection:
|
||||
USB, Ethernet, or the authenticated LAN browser CLI. It is rejected over LoRa.
|
||||
Companion has no local admin password and reports that explicitly, including
|
||||
through binary command `0x42`; this command does not report the Bluetooth PIN.
|
||||
|
||||
**Note:** The password setter replies with confirmation.
|
||||
|
||||
**Note:** Any node using this password will be added to the admin ACL list.
|
||||
|
||||
@@ -2804,7 +2827,8 @@ eviction like administrators.
|
||||
**Usage:**
|
||||
- `get acl`
|
||||
|
||||
**Serial Only:** Yes
|
||||
**Local connection only:** USB, direct network CLI, or Companion binary
|
||||
command `0x42` where the role supports the command; unavailable over LoRa.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -251,6 +251,16 @@ as the normal CLI reply text `Unknown command`, not as an error frame. Clients
|
||||
may prefix the CLI text with any two-character correlation tag and `|` (for
|
||||
example, `A7|get radio.rxgain`); the reply preserves that prefix.
|
||||
|
||||
Direct USB, BLE, WiFi TCP, and Ethernet clients can also use this command for
|
||||
`stats-core`, `stats-radio`, `stats-radio-diag`, `stats-packets`, `erase`,
|
||||
`set freq <MHz>`, and `get prv.key` (when private key export is enabled).
|
||||
WiFi/MQTT builds accept `get wifi.pwd`, `get mqttN.password`, and
|
||||
`get mqttN.token`. `get password` reports that Companion has no admin password;
|
||||
infrastructure roles return their own admin password on a local CLI connection.
|
||||
These privileges do not apply to CLI commands relayed over LoRa. See
|
||||
[local maintenance commands](terminal_chat_cli.md#local-maintenance-commands)
|
||||
for role differences and the text-terminal equivalents.
|
||||
|
||||
Full Companion clients can send `version` through this command to receive the
|
||||
untruncated build identity, for example `Companion 1.17.1.5-... (protocol 14,
|
||||
build 31-Aug-2026)`. This deliberately supplements rather than changes the
|
||||
|
||||
@@ -245,7 +245,8 @@ persisted and requires a reboot, unlike a WiFi power-save change.
|
||||
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. It may
|
||||
Password entry is masked over USB. `get wifi.pwd` reads the saved password from
|
||||
any local connection, including binary command `0x42`; LoRa cannot read it. It may
|
||||
be empty for an open network, an ordinary passphrase of up to 63 characters, or
|
||||
an exact 64-character hexadecimal WPA/WPA2 PSK. Other 64-character values and
|
||||
all longer values are rejected.
|
||||
|
||||
@@ -41,6 +41,41 @@ available buffer while unread, the browser reports the missing output.
|
||||
USB protocol-switch and USB MOTA ownership commands still apply to the USB
|
||||
connection. Use the WiFi MOTA seeder on port 5001 for a host folder over WiFi.
|
||||
|
||||
## Local maintenance commands
|
||||
|
||||
Local means a connection directly to the node: USB, BLE or WiFi/Ethernet
|
||||
Companion protocol, a TCP terminal, or the browser CLI on the LAN. These
|
||||
connections can use the following commands when the role and build include
|
||||
the corresponding feature:
|
||||
|
||||
| Command | Result |
|
||||
|---|---|
|
||||
| `stats-core`, `stats-radio`, `stats-radio-diag`, `stats-packets` | Runtime diagnostics |
|
||||
| `get prv.key` | Node identity private key; Companion requires private key export enabled in the build |
|
||||
| `get password` | Infrastructure admin password; Companion reports that it has no admin password |
|
||||
| `set freq <MHz>` | Save the frequency; reboot to apply |
|
||||
| `erase` | Erase stored identity and settings; reboot for a fresh node |
|
||||
| `get wifi.pwd` | Stored WiFi password on WiFi builds |
|
||||
| `get mqttN.password`, `get mqttN.token` | Stored credentials for MQTT slot N |
|
||||
| `get acl`, `log` | Infrastructure ACL or captured packet log; these stores do not exist on Companion |
|
||||
|
||||
The command text is the same for direct connections. A binary Companion app
|
||||
can send `0x42` (`CMD_RUN_CLI_COMMAND`) followed by the ASCII command, without
|
||||
switching into terminal mode. The response is `0x1D` (`RESP_CODE_CLI_REPLY`)
|
||||
followed by the reply text. For example, payload `42 67 65 74 20 70 61 73 73 77
|
||||
6f 72 64` runs `get password`. USB framing remains the normal Companion frame;
|
||||
BLE and TCP use their existing Companion transport framing.
|
||||
|
||||
Commands relayed to another node using `cmd` travel over LoRa and retain the
|
||||
remote restrictions. These local permissions do not add an ACL or packet-log
|
||||
store to Companion. Use `list` or the binary contacts operations for Companion
|
||||
contacts. Interactive chat commands still use the text terminal or their
|
||||
corresponding binary protocol operations.
|
||||
|
||||
The browser CLI requires station/LAN mode and `wifi.cli on`. Infrastructure
|
||||
uses its admin login; Companion uses the trusted LAN. Configuration forms keep
|
||||
password fields masked; explicit local CLI getters return their values.
|
||||
|
||||
## Companion USB mode
|
||||
|
||||
An ordinary Companion USB build starts in the normal binary Companion protocol
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <helpers/IdentityGeneration.h>
|
||||
#include <helpers/LazyPersistence.h>
|
||||
#include <helpers/StorageLayout.h>
|
||||
#include <helpers/StatsFormatHelper.h>
|
||||
#include <helpers/UsbAsciiBinarySwitch.h>
|
||||
#include <helpers/UsbLogging.h>
|
||||
#include "helpers/radiolib/RXPowerSaving.h"
|
||||
@@ -54,8 +55,10 @@
|
||||
#include <helpers/MQTTDefaults.h>
|
||||
#endif
|
||||
|
||||
#ifdef WITH_WEBCONFIG
|
||||
#if defined(ESP32) && defined(WIFI_SSID)
|
||||
#include <helpers/WiFiSetupPortal.h>
|
||||
#endif
|
||||
#ifdef WITH_WEBCONFIG
|
||||
#include <WiFi.h>
|
||||
#include <esp_wifi.h>
|
||||
#endif
|
||||
@@ -1071,7 +1074,8 @@ void MyMesh::onCLICommandRecv(const ContactInfo &from, mesh::Packet *pkt, uint32
|
||||
const char *text, char* reply) {
|
||||
markConnectionActive(from); // in case this is from a server, and we have a connection
|
||||
if (from.isRemoteCLIAllowed()) {
|
||||
if (!handleCommand(text, sender_timestamp, reply)) {
|
||||
// A timestamp received over the radio must never grant local-console access.
|
||||
if (!handleCommand(text, sender_timestamp ? sender_timestamp : 1, reply)) {
|
||||
strcat(reply, "Unknown command"); // reply may have cmd prefix from 'text'
|
||||
}
|
||||
} else {
|
||||
@@ -3378,7 +3382,7 @@ void MyMesh::execAdminCommand(char* cmd, char* reply) {
|
||||
strcpy(reply, "Error: enter a plain CLI command");
|
||||
return;
|
||||
}
|
||||
if (handleCommand(cmd, 1, reply)) return;
|
||||
if (handleCommand(cmd, 0, reply)) return;
|
||||
if (strcmp(cmd, "get radio.rxps") == 0 || strcmp(cmd, "get radio.rxps.config") == 0) {
|
||||
if (!radio_driver.supportsRxPowerSaving()) {
|
||||
strcpy(reply, "Error: RX power saving is unsupported on this radio");
|
||||
@@ -7410,6 +7414,10 @@ void MyMesh::handleTerminalCommand(char* command) {
|
||||
#endif
|
||||
|
||||
char local_reply[160];
|
||||
if (handleDirectCommand(command, local_reply, sizeof(local_reply))) {
|
||||
terminalOutput().printf(" %s\r\n", local_reply);
|
||||
return;
|
||||
}
|
||||
#if COMPANION_FEATURE_USB_MOTA_SOURCE
|
||||
const bool usb_mota_owner_transition =
|
||||
mesh::isUsbMotaOwnerTransitionCommand(command);
|
||||
@@ -7880,6 +7888,10 @@ void MyMesh::handleTerminalCommand(char* command) {
|
||||
FIRMWARE_VERSION, (unsigned)FIRMWARE_VER_CODE, FIRMWARE_BUILD_DATE);
|
||||
} else if (strcmp(command, "help") == 0) {
|
||||
terminalOutput().print("Commands:\r\n");
|
||||
terminalOutput().print(" stats-core / stats-radio / stats-radio-diag / stats-packets\r\n");
|
||||
terminalOutput().print(" get prv.key (when private key export is enabled)\r\n");
|
||||
terminalOutput().print(" get password (this role has no admin password)\r\n");
|
||||
terminalOutput().print(" erase (erase stored settings and identity)\r\n");
|
||||
terminalOutput().print(" board\r\n");
|
||||
terminalOutput().print(" version\r\n");
|
||||
terminalOutput().print(" get storage.layout\r\n");
|
||||
@@ -7920,7 +7932,7 @@ void MyMesh::handleTerminalCommand(char* command) {
|
||||
terminalOutput().print(" get wifi.powersave\r\n");
|
||||
terminalOutput().print(" set wifi.powersave <none|min|max>\r\n");
|
||||
#ifdef WITH_WEBCONFIG
|
||||
terminalOutput().print(" get wifi.{ssid|status}\r\n");
|
||||
terminalOutput().print(" get wifi.{ssid|pwd|status}\r\n");
|
||||
terminalOutput().print(" set wifi.ssid <network name>\r\n");
|
||||
terminalOutput().print(" set wifi.pwd <password>\r\n");
|
||||
terminalOutput().print(" get webui\r\n");
|
||||
@@ -8023,6 +8035,108 @@ static bool isCompanionRadioPrefsCommand(const char* command) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Called only for directly attached USB/BLE/TCP/browser clients. Commands
|
||||
// received over LoRa use handleCommand() with a nonzero sender timestamp.
|
||||
bool MyMesh::handleDirectCommand(const char* command, char* reply, size_t reply_size) {
|
||||
if (strcmp(command, "get password") == 0) {
|
||||
snprintf(reply, reply_size, "> (no admin password on Companion)");
|
||||
return true;
|
||||
}
|
||||
if (strcmp(command, "get prv.key") == 0) {
|
||||
#if ENABLE_PRIVATE_KEY_EXPORT
|
||||
uint8_t key[PRV_KEY_SIZE];
|
||||
char hex[PRV_KEY_SIZE * 2 + 1];
|
||||
const int length = self_id.writeTo(key, sizeof(key));
|
||||
mesh::Utils::toHex(hex, key, length);
|
||||
snprintf(reply, reply_size, "> %s", hex);
|
||||
memset(key, 0, sizeof(key));
|
||||
memset(hex, 0, sizeof(hex));
|
||||
#else
|
||||
snprintf(reply, reply_size, "Error: private key export disabled in this build");
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
if (strcmp(command, "erase") == 0) {
|
||||
if (_store->formatFileSystem()) {
|
||||
stopContactsIterator();
|
||||
resetContacts();
|
||||
mesh::resetLazyPersistenceAfterSuccess(dirty_contacts_expiry, dirty_contacts_failures);
|
||||
snprintf(reply, reply_size, "File system erase: OK");
|
||||
} else {
|
||||
snprintf(reply, reply_size, "File system erase: Err");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (strncmp(command, "set freq ", 9) == 0) {
|
||||
float frequency;
|
||||
if (!mesh::cli::parseDecimalStrict(command + 9, frequency)
|
||||
|| frequency < 150.0f || frequency > 2500.0f) {
|
||||
snprintf(reply, reply_size, "Error: frequency must be 150-2500 MHz");
|
||||
} else {
|
||||
const float previous = _prefs.freq;
|
||||
_prefs.freq = frequency;
|
||||
if (savePrefs()) {
|
||||
snprintf(reply, reply_size, "OK - reboot to apply");
|
||||
} else {
|
||||
_prefs.freq = previous;
|
||||
snprintf(reply, reply_size, "Error: frequency could not be saved");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (strcmp(command, "stats-core") == 0) {
|
||||
StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr);
|
||||
return true;
|
||||
}
|
||||
if (strcmp(command, "stats-radio") == 0) {
|
||||
StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver,
|
||||
getTotalAirTime(), getReceiveAirTime());
|
||||
return true;
|
||||
}
|
||||
if (strcmp(command, "stats-radio-diag") == 0) {
|
||||
StatsFormatHelper::formatRadioDiag(reply, _radio, radio_driver, *_ms,
|
||||
_err_flags, hasOutbound());
|
||||
return true;
|
||||
}
|
||||
if (strcmp(command, "stats-packets") == 0) {
|
||||
StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(),
|
||||
getNumSentDirect(), getNumRecvFlood(), getNumRecvDirect());
|
||||
return true;
|
||||
}
|
||||
#if defined(ESP32) && defined(WIFI_SSID)
|
||||
if (strcmp(command, "get wifi.pwd") == 0) {
|
||||
#ifdef WITH_WEBCONFIG
|
||||
WebConfigServer::formatWiFiPassword(reply, reply_size);
|
||||
#else
|
||||
char ssid[32] = {}, password[65] = {};
|
||||
if (!WiFiSetupPortal::loadStoredCredentials(ssid, sizeof(ssid), password, sizeof(password))) {
|
||||
#ifdef WIFI_PWD
|
||||
snprintf(password, sizeof(password), "%s", WIFI_PWD);
|
||||
#endif
|
||||
}
|
||||
snprintf(reply, reply_size, "> %s", password);
|
||||
memset(password, 0, sizeof(password));
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
if (strncmp(command, "get mqtt", 8) == 0 && command[8] >= '1'
|
||||
&& command[8] <= '0' + MAX_MQTT_SLOTS && command[9] == '.') {
|
||||
const int slot = command[8] - '1';
|
||||
const char* field = command + 10;
|
||||
const char* value = nullptr;
|
||||
if (strcmp(field, "password") == 0) value = _mqtt_prefs.mqtt_slot_password[slot];
|
||||
if (strcmp(field, "token") == 0) value = _mqtt_prefs.mqtt_slot_token[slot];
|
||||
if (value) {
|
||||
snprintf(reply, reply_size, "> %s", value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
|
||||
char* reply) {
|
||||
if (command == NULL || reply == NULL) return false;
|
||||
@@ -8039,6 +8153,8 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
|
||||
while (*command == ' ' || *command == '\t') command++;
|
||||
}
|
||||
|
||||
if (sender_timestamp == 0 && handleDirectCommand(command, reply, reply_capacity)) return true;
|
||||
|
||||
#if COMPANION_FEATURE_USB_MOTA_SOURCE
|
||||
if (mesh::isUsbMotaOwnerTransitionCommand(command)) {
|
||||
snprintf(reply, reply_capacity,
|
||||
|
||||
@@ -132,6 +132,10 @@ class MyMesh : public BaseChatMesh, public DataStoreHost, public UIShutdownGuard
|
||||
public:
|
||||
MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui=NULL);
|
||||
|
||||
bool handleLocalCommand(const char* command, char* reply) {
|
||||
return handleCommand(command, 0, reply);
|
||||
}
|
||||
|
||||
void begin(bool has_display, bool radio_available = true);
|
||||
void activateRadio();
|
||||
bool isRadioReady() const { return _radio_available; }
|
||||
@@ -370,6 +374,7 @@ private:
|
||||
|
||||
void checkCLIRescueCmd();
|
||||
bool handleCommand(const char* text, uint32_t sender_timestamp, char* reply);
|
||||
bool handleDirectCommand(const char* command, char* reply, size_t reply_size);
|
||||
void checkSerialInterface();
|
||||
bool applyAndSaveFemRxGain(bool enabled);
|
||||
bool applyAndSaveFemTxGain(bool enabled);
|
||||
|
||||
@@ -1674,7 +1674,7 @@ void halt() {
|
||||
/* 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. */
|
||||
builds expose local maintenance commands and `ota ...` commands. */
|
||||
#if defined(ESP32) && defined(WIFI_SSID) && defined(ENABLE_OTA)
|
||||
#include <helpers/ota/OtaCli.h> // mesh::ota::handle_ota_command(line, reply, board)
|
||||
#include <helpers/esp32/WiFiOtaSeeder.h>
|
||||
@@ -1701,7 +1701,7 @@ void halt() {
|
||||
WIFI_DEBUG_PRINTLN("Full Companion terminal listening on :%d (nc <ip> %d)",
|
||||
OTA_CONSOLE_TCP_PORT, OTA_CONSOLE_TCP_PORT);
|
||||
#else
|
||||
WIFI_DEBUG_PRINTLN("OTA console listening on :%d (nc <ip> %d, type `ota ...`)",
|
||||
WIFI_DEBUG_PRINTLN("Local CLI and OTA console listening on :%d (nc <ip> %d)",
|
||||
OTA_CONSOLE_TCP_PORT, OTA_CONSOLE_TCP_PORT);
|
||||
#endif
|
||||
}
|
||||
@@ -1767,7 +1767,7 @@ void halt() {
|
||||
ota_console_client.stop();
|
||||
}
|
||||
#else
|
||||
ota_console_client.print("OTA console - type `ota ...`\r\n> ");
|
||||
ota_console_client.print("Local CLI and OTA console - type `version` or `ota status`\r\n> ");
|
||||
#endif
|
||||
}
|
||||
return;
|
||||
@@ -1810,8 +1810,9 @@ void halt() {
|
||||
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");
|
||||
if (!the_mesh.handleLocalCommand(ota_console_line, reply)
|
||||
&& !mesh::ota::handle_ota_command(ota_console_line, reply, board))
|
||||
strcpy(reply, "Unknown local or OTA command");
|
||||
ota_console_client.print(" -> "); ota_console_client.print(reply); ota_console_client.print("\r\n> ");
|
||||
#endif
|
||||
ota_console_clear_line();
|
||||
@@ -2830,7 +2831,7 @@ void loop() {
|
||||
serviceCompanionWiFiState();
|
||||
if (companion_wifi_requested && companion_wifi_active) {
|
||||
#ifdef ENABLE_OTA
|
||||
ota_console_loop(); // service the OTA text console (port 5002)
|
||||
ota_console_loop(); // service the local text console (port 5002)
|
||||
#endif
|
||||
const unsigned long wifi_now = millis();
|
||||
const bool station_channel_ok = mesh::wifi::enforceStationChannel();
|
||||
|
||||
@@ -2396,6 +2396,22 @@ void MyMesh::formatRecentRepeatersReply(char *reply, int page,
|
||||
}
|
||||
|
||||
void MyMesh::printRecentRepeatersSerial() {
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
if (_command_output) {
|
||||
_local_cli_output.startRows(*_command_output,
|
||||
[](void* context, size_t& row, char* out, size_t capacity) -> size_t {
|
||||
auto& owner = *static_cast<MyMesh*>(context);
|
||||
const auto* tables = static_cast<const SimpleMeshTables*>(owner.getTables());
|
||||
const auto* info = tables ? tables->getRecentRepeaterBySortedIdx(row++) : nullptr;
|
||||
if (!info) return 0;
|
||||
char prefix[MAX_ROUTE_HASH_BYTES * 2 + 1], snr[12];
|
||||
mesh::Utils::toHex(prefix, info->prefix, info->prefix_len);
|
||||
formatLocalSnrX4(snr, sizeof(snr), info->snr_x4);
|
||||
return snprintf(out, capacity, "%s,%s%s\r\n", prefix, snr[0] == '-' ? "" : " ", snr);
|
||||
}, this, "Recent repeaters:\r\n");
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
const SimpleMeshTables* tables = static_cast<const SimpleMeshTables*>(getTables());
|
||||
if (tables == NULL) {
|
||||
mesh::usbConsolePort().printf("Error: unsupported\r\n");
|
||||
@@ -4795,6 +4811,9 @@ bool MyMesh::scheduleNormalRadio() {
|
||||
}
|
||||
|
||||
bool MyMesh::formatFileSystem() {
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
_local_cli_output.cancel();
|
||||
#endif
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
return InternalFS.format();
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
@@ -4845,6 +4864,13 @@ void MyMesh::updateFloodAdvertTimer() {
|
||||
}
|
||||
|
||||
void MyMesh::dumpLogFile() {
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
if (_command_output) {
|
||||
_local_cli_output.startFile(*_command_output,
|
||||
mesh::openFileRead(_fs, PACKET_LOG_FILE));
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
|
||||
if (hasPendingSerialOutput()) {
|
||||
mesh::usbConsolePort().printf("Err - USB output busy\r\n");
|
||||
@@ -11100,6 +11126,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
_gpio_reply_tracker.beginCommand(gpio_client_index, gpio_path_hash_size,
|
||||
sender == NULL ? NULL : sender->id.pub_key);
|
||||
#endif
|
||||
if (sender != nullptr && sender_timestamp == 0) sender_timestamp = 1;
|
||||
char* reply_start = reply;
|
||||
// Parse the original wire text exactly once, also before region-load mode.
|
||||
// Parsing again after stripping a prefix would let nested prefixes bypass
|
||||
@@ -11537,6 +11564,9 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
// Compatibility path for manually defined legacy portable builds. Current
|
||||
// release builds use FULL and do not enter this branch.
|
||||
_cli.handleCommand(sender_timestamp, command, reply);
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
if (_command_output && _local_cli_output.owns(*_command_output)) reply[0] = 0;
|
||||
#endif
|
||||
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
|
||||
if (sender_timestamp == 0 && serial_log_eof_pending
|
||||
&& strcmp(reply, " EOF") == 0) reply[0] = 0;
|
||||
@@ -11780,6 +11810,24 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
printRecentRepeatersSerial();
|
||||
reply_start[0] = 0;
|
||||
} else if (sender_timestamp == 0 && strcmp(command, "get acl") == 0) {
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
if (_command_output) {
|
||||
_local_cli_output.startRows(*_command_output,
|
||||
[](void* context, size_t& row, char* out, size_t capacity) -> size_t {
|
||||
auto& owner = *static_cast<MyMesh*>(context);
|
||||
while (row < static_cast<size_t>(owner.acl.getNumClients())) {
|
||||
auto* client = owner.acl.getClientByIdx(row++);
|
||||
if (!client->permissions) continue;
|
||||
char key[PUB_KEY_SIZE * 2 + 1];
|
||||
mesh::Utils::toHex(key, client->id.pub_key, PUB_KEY_SIZE);
|
||||
return snprintf(out, capacity, "%02X %s\r\n", client->permissions, key);
|
||||
}
|
||||
return 0;
|
||||
}, this, "ACL:\r\n");
|
||||
reply[0] = 0;
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
mesh::usbConsolePort().printf("ACL:\r\n");
|
||||
for (int i = 0; i < acl.getNumClients(); i++) {
|
||||
auto c = acl.getClientByIdx(i);
|
||||
@@ -11942,7 +11990,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
strcpy(reply, "Err - neighbors not enabled in this build");
|
||||
#endif
|
||||
} else{
|
||||
_cli.handleCommand(sender_timestamp, command, reply); // common CLI commands
|
||||
_cli.handleCommand(sender_timestamp, command, reply);
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
if (_command_output && _local_cli_output.owns(*_command_output)) reply[0] = 0;
|
||||
#endif
|
||||
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
|
||||
if (sender_timestamp == 0 && serial_log_eof_pending
|
||||
&& strcmp(reply, " EOF") == 0) reply[0] = 0;
|
||||
@@ -11951,6 +12002,9 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
}
|
||||
|
||||
void MyMesh::loop() {
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
_local_cli_output.service();
|
||||
#endif
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
if (mqtt_bridge) mqtt_bridge->servicePendingClockCorrection();
|
||||
#endif
|
||||
@@ -12937,6 +12991,9 @@ bool MyMesh::startNeighborDiscover(char* reply) {
|
||||
// To check if there is pending work
|
||||
bool MyMesh::hasPendingWork() const {
|
||||
if (hasPendingOtaApply()) return true;
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
if (_local_cli_output.busy()) return true;
|
||||
#endif
|
||||
if (deferred_cli_command.pending || pending_self_advert || _cli.hasActiveUserGpioTimer()) return true;
|
||||
#if defined(WITH_BRIDGE)
|
||||
const AbstractBridge* active_bridge = activeBridge();
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
#include <helpers/SimpleMeshTables.h>
|
||||
#include <helpers/StaticPoolPacketManager.h>
|
||||
#include <helpers/StatsFormatHelper.h>
|
||||
#include <helpers/LocalCliOutput.h>
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
#include <helpers/ExternalVoltageHistory.h>
|
||||
#include <helpers/TelemetryHistory.h>
|
||||
@@ -298,6 +299,16 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
};
|
||||
|
||||
FILESYSTEM* _fs;
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
#ifdef NRF52_PLATFORM
|
||||
mesh::LocalCliOutput<File> _local_cli_output{File(InternalFS)};
|
||||
#else
|
||||
mesh::LocalCliOutput<File> _local_cli_output;
|
||||
#endif
|
||||
Stream* _command_output = nullptr;
|
||||
Stream* _web_terminal = nullptr;
|
||||
#endif
|
||||
|
||||
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
|
||||
File serial_log_dump;
|
||||
size_t serial_log_remaining = 0;
|
||||
@@ -1023,6 +1034,22 @@ public:
|
||||
void saveIdentity(const mesh::LocalIdentity& new_id) override;
|
||||
void clearStats() override;
|
||||
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
void handleLocalCommand(char* command, char* reply, Stream& output) {
|
||||
if (_local_cli_output.busy()) {
|
||||
strcpy(reply, "Err - local listing in progress");
|
||||
return;
|
||||
}
|
||||
_command_output = &output;
|
||||
handleCommand(0, command, reply);
|
||||
_command_output = nullptr;
|
||||
}
|
||||
bool hasPendingLocalOutput() const { return _local_cli_output.busy(); }
|
||||
void cancelLocalOutput(Stream& output) {
|
||||
if (_local_cli_output.owns(output)) _local_cli_output.cancel();
|
||||
}
|
||||
#endif
|
||||
|
||||
void handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char* command,
|
||||
char* reply, int gpio_client_index = -1,
|
||||
uint8_t gpio_path_hash_size = 1, bool usb_origin = false);
|
||||
@@ -1351,6 +1378,9 @@ public:
|
||||
bool setWebUIEnabled(bool enabled, char* reply) override;
|
||||
bool getWebUIStatus(char* reply) const override;
|
||||
bool getWiFiSSID(char* reply) const override;
|
||||
bool getWiFiPassword(char* reply) const override {
|
||||
return WebConfigServer::formatWiFiPassword(reply, 160);
|
||||
}
|
||||
bool getWiFiStatus(char* reply) const override;
|
||||
bool getWiFiPowerSave(char* reply) const override;
|
||||
bool getWiFiCLI(char* reply) const override;
|
||||
@@ -1365,7 +1395,35 @@ public:
|
||||
void execCommand(char* cmd, char* reply) override { handleCommand(0, cmd, reply); }
|
||||
bool supportsCliTerminal() const override { return true; }
|
||||
void execAdminCommand(char* cmd, char* reply) override {
|
||||
handleCommand(1, cmd, reply);
|
||||
if (strcmp(cmd, "get acl") == 0 || strcmp(cmd, "log") == 0
|
||||
|| strcmp(cmd, "get recent.repeater") == 0
|
||||
|| strcmp(cmd, "get recent.repeaters") == 0) {
|
||||
strcpy(reply, "Error: use /api/terminal for streamed listings");
|
||||
return;
|
||||
}
|
||||
handleCommand(0, cmd, reply);
|
||||
}
|
||||
bool supportsStreamTerminal() const override { return true; }
|
||||
bool beginStreamTerminal(Stream& output) override {
|
||||
if (_web_terminal || _local_cli_output.busy()) return false;
|
||||
_web_terminal = &output;
|
||||
return true;
|
||||
}
|
||||
bool ownsStreamTerminal(const Stream& output) const override {
|
||||
return _web_terminal == &output;
|
||||
}
|
||||
void runStreamTerminal(char* command) override {
|
||||
if (!_web_terminal) return;
|
||||
char reply[160] = {};
|
||||
handleLocalCommand(command, reply, *_web_terminal);
|
||||
if (reply[0]) _web_terminal->println(reply);
|
||||
}
|
||||
bool streamTerminalBusy() const override {
|
||||
return _web_terminal && _local_cli_output.owns(*_web_terminal);
|
||||
}
|
||||
void endStreamTerminal(Stream& output) override {
|
||||
cancelLocalOutput(output);
|
||||
if (_web_terminal == &output) _web_terminal = nullptr;
|
||||
}
|
||||
void rebootNow() override { _cli.getBoard()->reboot(); }
|
||||
void onConfigBatchStart() override {
|
||||
|
||||
@@ -313,11 +313,15 @@ static void __attribute__((noinline)) serviceCommandInterfaces() {
|
||||
|
||||
#ifdef ETHERNET_ENABLED
|
||||
ethernet_loop_maintain();
|
||||
if (ethernet_read_line(ethernet_command, sizeof(ethernet_command))) {
|
||||
if (ethernet_take_session_reset() || !ethernet_client.connected()) {
|
||||
the_mesh.cancelLocalOutput(ethernet_client);
|
||||
ethernet_command[0] = 0;
|
||||
}
|
||||
if (!the_mesh.hasPendingLocalOutput() && ethernet_read_line(ethernet_command, sizeof(ethernet_command))) {
|
||||
char reply[160];
|
||||
reply[0] = 0;
|
||||
if (!ethernet_handle_command(ethernet_command, reply)) {
|
||||
the_mesh.handleCommand(0, ethernet_command, reply);
|
||||
the_mesh.handleLocalCommand(ethernet_command, reply, ethernet_client);
|
||||
}
|
||||
ethernet_send_reply(reply);
|
||||
ethernet_command[0] = 0;
|
||||
|
||||
@@ -1536,6 +1536,9 @@ bool MyMesh::scheduleNormalRadio() {
|
||||
}
|
||||
|
||||
bool MyMesh::formatFileSystem() {
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
_local_cli_output.cancel();
|
||||
#endif
|
||||
#if defined(NRF52_PLATFORM)
|
||||
return InternalFS.format();
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
@@ -1577,6 +1580,13 @@ void MyMesh::updateFloodAdvertTimer() {
|
||||
}
|
||||
|
||||
void MyMesh::dumpLogFile() {
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
if (_command_output) {
|
||||
_local_cli_output.startFile(*_command_output,
|
||||
mesh::openFileRead(_fs, PACKET_LOG_FILE));
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
|
||||
if (hasPendingSerialOutput()) {
|
||||
mesh::usbConsolePort().printf("Err - USB output busy\r\n");
|
||||
@@ -2254,6 +2264,7 @@ void MyMesh::sampleTelemetryHistory() {
|
||||
void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply,
|
||||
int gpio_client_index,
|
||||
uint8_t gpio_path_hash_size) {
|
||||
if (gpio_client_index >= 0 && sender_timestamp == 0) sender_timestamp = 1;
|
||||
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
|
||||
const uint8_t* gpio_client_key = gpio_client_index >= 0 &&
|
||||
gpio_client_index < acl.getNumClients()
|
||||
@@ -2346,6 +2357,24 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
|
||||
}
|
||||
}
|
||||
} else if (sender_timestamp == 0 && strcmp(command, "get acl") == 0) {
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
if (_command_output) {
|
||||
_local_cli_output.startRows(*_command_output,
|
||||
[](void* context, size_t& row, char* out, size_t capacity) -> size_t {
|
||||
auto& owner = *static_cast<MyMesh*>(context);
|
||||
while (row < static_cast<size_t>(owner.acl.getNumClients())) {
|
||||
auto* client = owner.acl.getClientByIdx(row++);
|
||||
if (!client->permissions) continue;
|
||||
char key[PUB_KEY_SIZE * 2 + 1];
|
||||
mesh::Utils::toHex(key, client->id.pub_key, PUB_KEY_SIZE);
|
||||
return snprintf(out, capacity, "%02X %s\r\n", client->permissions, key);
|
||||
}
|
||||
return 0;
|
||||
}, this, "ACL:\r\n");
|
||||
reply[0] = 0;
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
mesh::usbConsolePort().printf("ACL:\r\n");
|
||||
for (int i = 0; i < acl.getNumClients(); i++) {
|
||||
auto c = acl.getClientByIdx(i);
|
||||
@@ -2415,7 +2444,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
|
||||
else if (_clock_sync.handleCommand(command, reply)) {
|
||||
// handled by the role-independent mesh clock synchronizer
|
||||
} else {
|
||||
_cli.handleCommand(sender_timestamp, command, reply); // common CLI commands
|
||||
_cli.handleCommand(sender_timestamp, command, reply);
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
if (_command_output && _local_cli_output.owns(*_command_output)) reply[0] = 0;
|
||||
#endif
|
||||
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
|
||||
if (sender_timestamp == 0 && serial_log_eof_pending
|
||||
&& strcmp(reply, " EOF") == 0) reply[0] = 0;
|
||||
@@ -2428,6 +2460,9 @@ bool MyMesh::saveFilter(ClientInfo* client) {
|
||||
}
|
||||
|
||||
void MyMesh::loop() {
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
_local_cli_output.service();
|
||||
#endif
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
if (bridge) bridge->servicePendingClockCorrection();
|
||||
#endif
|
||||
@@ -2793,6 +2828,9 @@ uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const {
|
||||
// To check if there is pending work
|
||||
bool MyMesh::hasPendingWork() const {
|
||||
if (hasPendingOtaApply()) return true;
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
if (_local_cli_output.busy()) return true;
|
||||
#endif
|
||||
#if defined(WITH_BRIDGE)
|
||||
if (bridge && bridge->isRunning()) return true; // bridge needs WiFi radio, can't sleep
|
||||
#endif
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
#include <helpers/UserGpioReplyTracker.h>
|
||||
#endif
|
||||
#include <helpers/StatsFormatHelper.h>
|
||||
#include <helpers/LocalCliOutput.h>
|
||||
#include <helpers/ClientACL.h>
|
||||
#include <helpers/LogicalMessageCache.h>
|
||||
#include <helpers/RemoteCliReplyCache.h>
|
||||
@@ -158,6 +159,16 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks,
|
||||
#endif
|
||||
{
|
||||
FILESYSTEM* _fs;
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
#ifdef NRF52_PLATFORM
|
||||
mesh::LocalCliOutput<File> _local_cli_output{File(InternalFS)};
|
||||
#else
|
||||
mesh::LocalCliOutput<File> _local_cli_output;
|
||||
#endif
|
||||
Stream* _command_output = nullptr;
|
||||
Stream* _web_terminal = nullptr;
|
||||
#endif
|
||||
|
||||
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
|
||||
File serial_log_dump;
|
||||
size_t serial_log_remaining = 0;
|
||||
@@ -491,6 +502,22 @@ public:
|
||||
|
||||
void saveIdentity(const mesh::LocalIdentity& new_id) override;
|
||||
void clearStats() override;
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
void handleLocalCommand(char* command, char* reply, Stream& output) {
|
||||
if (_local_cli_output.busy()) {
|
||||
strcpy(reply, "Err - local listing in progress");
|
||||
return;
|
||||
}
|
||||
_command_output = &output;
|
||||
handleCommand(0, command, reply);
|
||||
_command_output = nullptr;
|
||||
}
|
||||
bool hasPendingLocalOutput() const { return _local_cli_output.busy(); }
|
||||
void cancelLocalOutput(Stream& output) {
|
||||
if (_local_cli_output.owns(output)) _local_cli_output.cancel();
|
||||
}
|
||||
#endif
|
||||
|
||||
void handleCommand(uint32_t sender_timestamp, char* command, char* reply,
|
||||
int gpio_client_index = -1,
|
||||
uint8_t gpio_path_hash_size = 1);
|
||||
@@ -645,6 +672,9 @@ public:
|
||||
bool setWebUIEnabled(bool enabled, char* reply) override;
|
||||
bool getWebUIStatus(char* reply) const override;
|
||||
bool getWiFiSSID(char* reply) const override;
|
||||
bool getWiFiPassword(char* reply) const override {
|
||||
return WebConfigServer::formatWiFiPassword(reply, 160);
|
||||
}
|
||||
bool getWiFiStatus(char* reply) const override;
|
||||
bool getWiFiPowerSave(char* reply) const override;
|
||||
bool getWiFiCLI(char* reply) const override;
|
||||
@@ -659,7 +689,35 @@ public:
|
||||
void execCommand(char* cmd, char* reply) override { handleCommand(0, cmd, reply); }
|
||||
bool supportsCliTerminal() const override { return true; }
|
||||
void execAdminCommand(char* cmd, char* reply) override {
|
||||
handleCommand(1, cmd, reply);
|
||||
if (strcmp(cmd, "get acl") == 0 || strcmp(cmd, "log") == 0
|
||||
|| strcmp(cmd, "get recent.repeater") == 0
|
||||
|| strcmp(cmd, "get recent.repeaters") == 0) {
|
||||
strcpy(reply, "Error: use /api/terminal for streamed listings");
|
||||
return;
|
||||
}
|
||||
handleCommand(0, cmd, reply);
|
||||
}
|
||||
bool supportsStreamTerminal() const override { return true; }
|
||||
bool beginStreamTerminal(Stream& output) override {
|
||||
if (_web_terminal || _local_cli_output.busy()) return false;
|
||||
_web_terminal = &output;
|
||||
return true;
|
||||
}
|
||||
bool ownsStreamTerminal(const Stream& output) const override {
|
||||
return _web_terminal == &output;
|
||||
}
|
||||
void runStreamTerminal(char* command) override {
|
||||
if (!_web_terminal) return;
|
||||
char reply[160] = {};
|
||||
handleLocalCommand(command, reply, *_web_terminal);
|
||||
if (reply[0]) _web_terminal->println(reply);
|
||||
}
|
||||
bool streamTerminalBusy() const override {
|
||||
return _web_terminal && _local_cli_output.owns(*_web_terminal);
|
||||
}
|
||||
void endStreamTerminal(Stream& output) override {
|
||||
cancelLocalOutput(output);
|
||||
if (_web_terminal == &output) _web_terminal = nullptr;
|
||||
}
|
||||
void rebootNow() override { _cli.getBoard()->reboot(); }
|
||||
void onConfigBatchStart() override {
|
||||
|
||||
@@ -203,11 +203,15 @@ void loop() {
|
||||
|
||||
#ifdef ETHERNET_ENABLED
|
||||
ethernet_loop_maintain();
|
||||
if (ethernet_read_line(ethernet_command, sizeof(ethernet_command))) {
|
||||
if (ethernet_take_session_reset() || !ethernet_client.connected()) {
|
||||
the_mesh.cancelLocalOutput(ethernet_client);
|
||||
ethernet_command[0] = 0;
|
||||
}
|
||||
if (!the_mesh.hasPendingLocalOutput() && ethernet_read_line(ethernet_command, sizeof(ethernet_command))) {
|
||||
char reply[160];
|
||||
reply[0] = 0;
|
||||
if (!ethernet_handle_command(ethernet_command, reply)) {
|
||||
the_mesh.handleCommand(0, ethernet_command, reply);
|
||||
the_mesh.handleLocalCommand(ethernet_command, reply, ethernet_client);
|
||||
}
|
||||
ethernet_send_reply(reply);
|
||||
ethernet_command[0] = 0;
|
||||
|
||||
@@ -574,6 +574,7 @@ void SensorMesh::onUserGpioTimerCompleted(uint8_t pin, uint8_t state,
|
||||
void SensorMesh::handleCommand(uint32_t sender_timestamp, char* command, char* reply,
|
||||
int gpio_client_index,
|
||||
uint8_t gpio_path_hash_size) {
|
||||
if (gpio_client_index >= 0 && sender_timestamp == 0) sender_timestamp = 1;
|
||||
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
|
||||
const uint8_t* gpio_client_key = gpio_client_index >= 0 &&
|
||||
gpio_client_index < acl.getNumClients()
|
||||
|
||||
@@ -75,8 +75,9 @@ def requirements(platform, defines, target):
|
||||
parts["mqtt_connections_buffers"] = 24576
|
||||
if "ENABLE_OTA" in defines:
|
||||
parts["ota_source_scratch"] = 8192
|
||||
if (companion and "WIFI_SSID" in defines
|
||||
and "WEBCONFIG_DISABLED" not in defines):
|
||||
if ("WEBCONFIG_DISABLED" not in defines
|
||||
and ((companion and "WIFI_SSID" in defines)
|
||||
or (not companion and "ADMIN_PASSWORD" in defines))):
|
||||
parts["browser_terminal_session"] = 2048
|
||||
if "BOARD_HAS_PSRAM" not in defines:
|
||||
# Larger replies grow on demand only while preserving 32 KiB
|
||||
|
||||
@@ -41,7 +41,8 @@ EXPECTED_FAILURES = {
|
||||
|
||||
# Commands that change the node out from under the audit.
|
||||
SKIP = {"reboot", "clkreboot", "poweroff", "shutdown", "erase", "start ota",
|
||||
"stop webconfig", "ota update", "start webconfig", "start webconfig ap"}
|
||||
"stop webconfig", "ota update", "start webconfig", "start webconfig ap",
|
||||
"log", "get acl"} # Listings use /api/terminal, not this bounded API audit.
|
||||
|
||||
|
||||
def table():
|
||||
@@ -80,8 +81,6 @@ NOT_OFFERED = {
|
||||
"tls.bundletest", # TLS debugging, not an operator command
|
||||
"start ota", # binds port 80, which the portal is already using
|
||||
"clock sync", # takes its time from the caller; a web request has none
|
||||
"log", # streams to Serial and stalls the radio ("log start" is offered)
|
||||
"get acl", # streams to Serial, returns nothing
|
||||
}
|
||||
|
||||
|
||||
@@ -121,10 +120,9 @@ ROUND_TRIPS = [
|
||||
("set path.hash.mode 2", "get path.hash.mode", "2"),
|
||||
("set mqtt.iata den", "get mqtt.iata", "DEN"),
|
||||
("set radio.rxps 70000 60000", "get radio.rxps", "on,70000,60000"),
|
||||
# Secret reads are masked back down for an HTTP caller, in CommonCLI's own
|
||||
# words for a non-serial one (wcIsSecretReadCommand).
|
||||
("set guest.password hunter2", "get guest.password", "******** (serial only)"),
|
||||
("set wifi.pwd hunter2", "get wifi.pwd", "******** (serial only)"),
|
||||
# Explicit authenticated LAN getters have local-connection privileges.
|
||||
("set guest.password hunter2", "get guest.password", "hunter2"),
|
||||
("set wifi.pwd hunter2", "get wifi.pwd", "hunter2"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -358,9 +358,9 @@ def apply_set(cfg, key, val):
|
||||
return True, "OK"
|
||||
|
||||
if key == "prv.key":
|
||||
# write-only by design: the identity goes in, nothing reads it back
|
||||
if not _hex64(val):
|
||||
return False, "Error: private key must be 64 hex characters"
|
||||
if len(val) != 128 or not all(c in "0123456789abcdefABCDEF" for c in val):
|
||||
return False, "Error: private key must be 128 hex characters"
|
||||
cfg["identity_private_key"] = val
|
||||
return True, "OK - identity restored, reboot to apply"
|
||||
|
||||
if key == "mqtt.owner":
|
||||
@@ -518,9 +518,8 @@ CLI_UNAVAILABLE = [
|
||||
"Run it from the serial console, or use `ota update`."),
|
||||
("clock sync", True, "clock sync takes its time from the caller, which a web request "
|
||||
"has no way to supply. Use `time <epoch-seconds>` instead."),
|
||||
("log", False, "log writes the packet log to the serial console, not here, and "
|
||||
"blocks the radio while it does. Use `log start` / `log stop`."),
|
||||
("get acl", False, "get acl writes to the serial console, not here."),
|
||||
("log", False, "Use /api/terminal for streamed listings."),
|
||||
("get acl", False, "Use /api/terminal for streamed listings."),
|
||||
]
|
||||
|
||||
|
||||
@@ -534,17 +533,6 @@ def cli_unavailable(cmd):
|
||||
# Failure replies CommonCLI emits that do NOT start with "Err" -- the shapes that
|
||||
# made a naive prefix test call them success. Mirrors
|
||||
# WebConfigBatch::cliReplyIsFailure.
|
||||
def cli_reads_secret(cmd):
|
||||
"""Commands that READ a secret. CommonCLI gates these on the caller being
|
||||
the serial console; the portal is not, so the value is masked here the way
|
||||
CommonCLI masks it for remote callers. Mirrors wcCliReadsSecret()."""
|
||||
if not cmd.startswith("get "):
|
||||
return False
|
||||
key = cmd[4:].strip()
|
||||
return key in ("prv.key", "guest.password", "alert.psk", "bridge.secret") \
|
||||
or is_secret_key(key)
|
||||
|
||||
|
||||
def cli_reply_is_failure(reply):
|
||||
if not reply:
|
||||
return False
|
||||
@@ -560,6 +548,7 @@ GETTERS = {
|
||||
"sf": lambda c: str(c["radio"]["sf"]),
|
||||
"cr": lambda c: str(c["radio"]["cr"]),
|
||||
"public.key": lambda c: "a1b2c3d4" * 8,
|
||||
"prv.key": lambda c: c.get("identity_private_key", "a5" * 64),
|
||||
"wifi.status": lambda c: (
|
||||
"SSID: %s\nIP: 192.168.1.42\nRSSI: -58 dBm\nUptime: %dm"
|
||||
% (c["wifi"]["ssid"] or "(not set)", int(time.time() - ST.start) // 60)),
|
||||
@@ -613,10 +602,11 @@ def _cli_get_value(cfg, key):
|
||||
if key in GETTERS:
|
||||
val = GETTERS[key](cfg)
|
||||
return (True, val) if val is not None else (False, "Error: unsupported")
|
||||
if key == "password":
|
||||
return True, ADMIN_PASSWORD
|
||||
if is_secret_key(key):
|
||||
# The serial console prints these; the portal is reachable over the LAN,
|
||||
# so it masks them the same way /api/config does.
|
||||
return True, SENTINEL if cli_read_key(cfg, key) else "(not set)"
|
||||
# Explicit LAN CLI getters return values; /api/config remains masked.
|
||||
return True, cli_read_key(cfg, key) or "(not set)"
|
||||
val = cli_read_key(cfg, key)
|
||||
if val is None:
|
||||
return False, "??: %s" % key # CommonCLI::handleGetCmd fallthrough
|
||||
@@ -1035,10 +1025,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if cmd.startswith("password "):
|
||||
reply = "OK" # never echo the new password back
|
||||
ST.admin_pwd_set = True
|
||||
elif cli_reads_secret(cmd):
|
||||
val = reply[2:] if reply.startswith("> ") else reply
|
||||
reply = ("> (not set)" if val in ("", "(not set)")
|
||||
else "> ******** (serial only)")
|
||||
ok = not cli_reply_is_failure(reply)
|
||||
# Only writes gate the reboot, and only on the "OK" convention every
|
||||
# setter keeps (WebConfigBatch::cliReplyGatesReboot).
|
||||
|
||||
@@ -4301,6 +4301,24 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
|
||||
|
||||
void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* reply) {
|
||||
const char* config = &command[4];
|
||||
if (strcmp(config, "password") == 0) {
|
||||
if (sender_timestamp == 0) {
|
||||
snprintf(reply, 160, "> %s", _prefs->password);
|
||||
} else {
|
||||
strcpy(reply, "Error: get password requires a local connection");
|
||||
}
|
||||
return;
|
||||
}
|
||||
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED) && !defined(WITH_MQTT_BRIDGE)
|
||||
if (strcmp(config, "wifi.pwd") == 0) {
|
||||
if (sender_timestamp != 0) {
|
||||
strcpy(reply, "> ******** (local connection only)");
|
||||
} else if (!_callbacks->getWiFiPassword(reply)) {
|
||||
strcpy(reply, "Error: WiFi password unavailable on this build");
|
||||
}
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (strcmp(config, "powersaving") == 0) {
|
||||
snprintf(reply, 160, "> %s", _prefs->powersaving_enabled ? "on" : "off");
|
||||
return;
|
||||
@@ -4444,7 +4462,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
|
||||
sprintf(reply, "> %d", ((uint32_t) _prefs->advert_interval) * 2);
|
||||
} else if (configKeyEquals(config, "guest.password")) {
|
||||
sprintf(reply, "> %s", _prefs->guest_password);
|
||||
} else if (sender_timestamp == 0 && configKeyEquals(config, "prv.key")) { // from serial command line only
|
||||
} else if (sender_timestamp == 0 && configKeyEquals(config, "prv.key")) { // local connections only
|
||||
uint8_t prv_key[PRV_KEY_SIZE];
|
||||
int len = _callbacks->getSelfId().writeTo(prv_key, PRV_KEY_SIZE);
|
||||
mesh::Utils::toHex(tmp, prv_key, len);
|
||||
|
||||
@@ -598,6 +598,10 @@ public:
|
||||
(void)reply;
|
||||
return false;
|
||||
};
|
||||
virtual bool getWiFiPassword(char* reply) const {
|
||||
(void)reply;
|
||||
return false;
|
||||
};
|
||||
virtual bool getWiFiStatus(char* reply) const {
|
||||
(void)reply;
|
||||
return false;
|
||||
|
||||
@@ -988,20 +988,20 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
|
||||
} else if (memcmp(subcmd, "username", 8) == 0) {
|
||||
sprintf(reply, "> %s", _mqtt_prefs.mqtt_slot_username[slot]);
|
||||
} else if (memcmp(subcmd, "password", 8) == 0) {
|
||||
// Serial only; remote sees set/unset.
|
||||
// Local connections only; remote sees set/unset.
|
||||
if (sender_timestamp == 0) {
|
||||
sprintf(reply, "> %s", _mqtt_prefs.mqtt_slot_password[slot]);
|
||||
} else {
|
||||
strcpy(reply, _mqtt_prefs.mqtt_slot_password[slot][0] ? "> ******** (serial only)" : "> (not set)");
|
||||
strcpy(reply, _mqtt_prefs.mqtt_slot_password[slot][0] ? "> ******** (local connection only)" : "> (not set)");
|
||||
}
|
||||
} else if (memcmp(subcmd, "token", 5) == 0) {
|
||||
// Serial only; remote sees set/unset.
|
||||
// Local connections only; remote sees set/unset.
|
||||
if (_mqtt_prefs.mqtt_slot_token[slot][0] == '\0') {
|
||||
strcpy(reply, "> (not set)");
|
||||
} else if (sender_timestamp == 0) {
|
||||
sprintf(reply, "> %s", _mqtt_prefs.mqtt_slot_token[slot]);
|
||||
} else {
|
||||
strcpy(reply, "> ******** (serial only)");
|
||||
strcpy(reply, "> ******** (local connection only)");
|
||||
}
|
||||
} else if (memcmp(subcmd, "topic", 5) == 0) {
|
||||
if (_mqtt_prefs.mqtt_slot_topic[slot][0] != '\0') {
|
||||
@@ -1031,11 +1031,11 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
|
||||
} else if (memcmp(config, "wifi.ssid", 9) == 0) {
|
||||
sprintf(reply, "> %s", _mqtt_prefs.wifi_ssid);
|
||||
} else if (memcmp(config, "wifi.pwd", 8) == 0) {
|
||||
// Serial only (WiFi creds grant LAN access); remote sees set/unset.
|
||||
// Local connections only (WiFi creds grant LAN access); remote sees set/unset.
|
||||
if (sender_timestamp == 0) {
|
||||
sprintf(reply, "> %s", _mqtt_prefs.wifi_password);
|
||||
} else {
|
||||
strcpy(reply, _mqtt_prefs.wifi_password[0] ? "> ******** (serial only)" : "> (not set)");
|
||||
strcpy(reply, _mqtt_prefs.wifi_password[0] ? "> ******** (local connection only)" : "> (not set)");
|
||||
}
|
||||
} else if (memcmp(config, "wifi.status", 11) == 0) {
|
||||
wl_status_t status = WiFi.status();
|
||||
@@ -1135,7 +1135,7 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
|
||||
#endif
|
||||
} else if (memcmp(config, "alert.hashtag", 13) == 0) {
|
||||
sprintf(reply, "> %s", _mqtt_prefs.alert_hashtag[0] ? _mqtt_prefs.alert_hashtag : "(unset)");
|
||||
} else if (sender_timestamp == 0 && memcmp(config, "alert.psk", 9) == 0) { // from serial command line only
|
||||
} else if (sender_timestamp == 0 && memcmp(config, "alert.psk", 9) == 0) { // local connections only
|
||||
sprintf(reply, "> %s", _mqtt_prefs.alert_psk_hex[0] ? _mqtt_prefs.alert_psk_hex : "(unset)");
|
||||
} else if (memcmp(config, "alert.region", 12) == 0) {
|
||||
sprintf(reply, "> %s", _mqtt_prefs.alert_region[0] ? _mqtt_prefs.alert_region : "(unset, using default scope)");
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
// A local listing belongs to its requesting connection. Advance one bounded
|
||||
// chunk per mesh loop and retain unwritten bytes when the client is slow.
|
||||
template<class FileHandle> class LocalCliOutput {
|
||||
public:
|
||||
using RowReader = size_t (*)(void*, size_t&, char*, size_t);
|
||||
private:
|
||||
Stream* _output = nullptr;
|
||||
FileHandle _file;
|
||||
size_t _remaining = 0;
|
||||
RowReader _rows = nullptr;
|
||||
void* _context = nullptr;
|
||||
size_t _row = 0;
|
||||
char _pending[160] = {};
|
||||
size_t _size = 0, _offset = 0;
|
||||
bool _ending = false;
|
||||
|
||||
public:
|
||||
LocalCliOutput() = default;
|
||||
explicit LocalCliOutput(FileHandle file) : _file(file) {}
|
||||
bool busy() const { return _output != nullptr; }
|
||||
bool owns(const Stream& output) const { return _output == &output; }
|
||||
void cancel() {
|
||||
if (_file) _file.close();
|
||||
_output = nullptr;
|
||||
_rows = nullptr;
|
||||
_context = nullptr;
|
||||
_remaining = _size = _offset = _row = 0;
|
||||
_ending = false;
|
||||
memset(_pending, 0, sizeof(_pending));
|
||||
}
|
||||
bool startFile(Stream& output, FileHandle file) {
|
||||
if (busy()) { if (file) file.close(); return false; }
|
||||
_output = &output;
|
||||
_file = file;
|
||||
// Snapshot the length: live logging cannot extend this dump indefinitely.
|
||||
_remaining = _file ? _file.size() : 0;
|
||||
return true;
|
||||
}
|
||||
bool startRows(Stream& output, RowReader rows, void* context,
|
||||
const char* header) {
|
||||
if (busy()) return false;
|
||||
_output = &output;
|
||||
_rows = rows;
|
||||
_context = context;
|
||||
_size = snprintf(_pending, sizeof(_pending), "%s", header);
|
||||
if (_size >= sizeof(_pending)) _size = sizeof(_pending) - 1;
|
||||
return true;
|
||||
}
|
||||
void service() {
|
||||
if (!busy()) return;
|
||||
if (_offset == _size) {
|
||||
_size = _offset = 0;
|
||||
if (_ending) { cancel(); return; }
|
||||
if (_rows) {
|
||||
_size = _rows(_context, _row, _pending, sizeof(_pending));
|
||||
if (_size == 0) { cancel(); return; }
|
||||
if (_size >= sizeof(_pending)) { cancel(); return; }
|
||||
} else if (_remaining) {
|
||||
const size_t count = _remaining < sizeof(_pending)
|
||||
? _remaining : sizeof(_pending);
|
||||
const int got = _file.read(reinterpret_cast<uint8_t*>(_pending), count);
|
||||
if (got <= 0) {
|
||||
_size = snprintf(_pending, sizeof(_pending), "\r\nError: log read failed\r\n");
|
||||
_ending = true;
|
||||
} else {
|
||||
_size = static_cast<size_t>(got);
|
||||
_remaining -= _size;
|
||||
}
|
||||
} else {
|
||||
_size = snprintf(_pending, sizeof(_pending), "\r\n EOF\r\n");
|
||||
_ending = true;
|
||||
}
|
||||
}
|
||||
const int room = _output->availableForWrite();
|
||||
if (room <= 0) return;
|
||||
const size_t count = _size - _offset < static_cast<size_t>(room)
|
||||
? _size - _offset : static_cast<size_t>(room);
|
||||
_offset += _output->write(
|
||||
reinterpret_cast<const uint8_t*>(_pending + _offset), count);
|
||||
}
|
||||
};
|
||||
} // namespace mesh
|
||||
@@ -98,22 +98,14 @@ static inline bool wcIsSecretKey(const char* key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// CommonCLI answers a secret getter in plaintext only for the serial console
|
||||
// (sender_timestamp 0) and masks it for remote callers. The web CLI executes
|
||||
// with sender_timestamp 0 -- that is what makes `erase`, `stats-*` and `set freq`
|
||||
// reachable -- so it would otherwise inherit the serial console's plaintext
|
||||
// answers for an HTTP request. This says which `get` commands must be masked
|
||||
// back down, restoring the distinction for a caller not at the serial port.
|
||||
//
|
||||
// Writing these has always been possible from the portal; reading them never
|
||||
// was, because handleConfigGet masks them (wcIsSecretKey). The two are different
|
||||
// capabilities: replacing a WiFi password does not reveal the current one, and
|
||||
// replacing an identity does not reveal the existing private key.
|
||||
// Identify explicit secret getters separately from config-form fields. Local
|
||||
// CLI connections may read these values; configuration snapshots remain masked.
|
||||
static inline bool wcIsSecretReadCommand(const char* cmd) {
|
||||
if (strncmp(cmd, "get ", 4) != 0) return false;
|
||||
const char* key = cmd + 4;
|
||||
while (*key == ' ') key++;
|
||||
if (strcmp(key, "prv.key") == 0) return true; // this node's identity
|
||||
if (strcmp(key, "password") == 0) return true;
|
||||
if (strcmp(key, "guest.password") == 0) return true;
|
||||
if (strcmp(key, "alert.psk") == 0) return true;
|
||||
if (strcmp(key, "bridge.secret") == 0) return true;
|
||||
|
||||
+2137
-2129
File diff suppressed because it is too large.
Load diff
@@ -239,7 +239,7 @@ static inline bool wcIsDeferredReboot(const char* cmd) {
|
||||
// Commands the CLI reaches but the portal cannot honestly serve. Rejected at
|
||||
// POST so nothing in the sequence runs, rather than failing halfway with a
|
||||
// reply that does not explain itself. Returns the reason, or NULL if fine.
|
||||
static const char* wcCliUnavailable(const char* cmd) {
|
||||
static const char* wcCliUnavailable(const char* cmd, bool streamed = false) {
|
||||
// ESP32Board::startOTAUpdate() does `new AsyncWebServer(80)` with no bind
|
||||
// check and answers "Started" regardless. The portal already holds port 80,
|
||||
// so from here it can only leak the allocation, inhibit sleep, and lie.
|
||||
@@ -254,15 +254,10 @@ static const char* wcCliUnavailable(const char* cmd) {
|
||||
return "clock sync takes its time from the caller, which a web request has "
|
||||
"no way to supply. Use `time <epoch-seconds>` instead.";
|
||||
}
|
||||
// Both write their real output to Serial and hand back a stub the terminal
|
||||
// would render as success. Bare `log` also streams a whole file from the loop
|
||||
// task, stalling the mesh and the radio while it does.
|
||||
if (strcmp(cmd, "log") == 0) {
|
||||
return "log writes the packet log to the serial console, not here, and "
|
||||
"blocks the radio while it does. Use `log start` / `log stop`.";
|
||||
}
|
||||
if (strcmp(cmd, "get acl") == 0) {
|
||||
return "get acl writes to the serial console, not here.";
|
||||
// The bounded batch reply cannot hold these listings. The browser terminal
|
||||
// uses /api/terminal, which streams them back to the requesting connection.
|
||||
if (!streamed && (strcmp(cmd, "log") == 0 || strcmp(cmd, "get acl") == 0)) {
|
||||
return "Use /api/terminal for streamed listings.";
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
@@ -273,33 +268,9 @@ static inline bool wcCliEchoesSecret(const char* cmd) {
|
||||
return strncmp(cmd, "password ", 9) == 0;
|
||||
}
|
||||
|
||||
// CommonCLI splits its surface by CALLER, not by command: a serial caller
|
||||
// (sender_timestamp 0, physical access) reads secrets in plaintext, while a
|
||||
// remote one gets "******** (serial only)". Its own comments say so -- "Serial
|
||||
// only (WiFi creds grant LAN access); remote sees set/unset".
|
||||
//
|
||||
// execCommand passes 0, which is what makes `erase`, `stats-*` and `set freq`
|
||||
// reachable from the terminal at all. Left alone, that also claims
|
||||
// physical-access trust for an HTTP request: `get prv.key` would hand this
|
||||
// node's identity to anyone associated with the open setup AP, and `get
|
||||
// wifi.pwd` would hand over the operator's network. Reading a secret and
|
||||
// writing one are not the same capability -- the wizard has always been able to
|
||||
// REPLACE these; nothing in the portal could ever READ them, because
|
||||
// /api/config masks them (wcIsSecretKey).
|
||||
//
|
||||
// So the command surface stays whole and only the READ is masked, restoring the
|
||||
// distinction CommonCLI intended for a caller who is not at the serial port.
|
||||
// Which commands those are lives in WebConfigKeys.h (wcIsSecretReadCommand),
|
||||
// beside the rest of the secret classification and host-tested with it.
|
||||
|
||||
// Keep the set/unset signal, which is the useful part and what CommonCLI itself
|
||||
// reports remotely; only the value goes. A getter answers "> value".
|
||||
static void wcMaskSecretReply(char* reply) {
|
||||
const char* val = reply;
|
||||
if (val[0] == '>' && val[1] == ' ') val += 2;
|
||||
const bool unset = (val[0] == 0 || strcmp(val, "(not set)") == 0);
|
||||
strcpy(reply, unset ? "> (not set)" : "> ******** (serial only)");
|
||||
}
|
||||
// CLI requests require LAN mode and authentication. Explicit getters have
|
||||
// local-connection privileges, including secret reads. The configuration API
|
||||
// still masks secrets, and the open setup AP has no CLI endpoint.
|
||||
// Reply classification lives in WebConfigBatch.h with the rest of the decisions
|
||||
// (WebConfigBatch::cliReplyIsFailure / cliReplyGatesReboot / cliWriteSucceeded),
|
||||
// so the shapes CommonCLI actually emits are enumerated in one host-tested place.
|
||||
@@ -620,6 +591,22 @@ bool WebConfigServer::formatWiFiSSID(char* reply, size_t reply_len) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WebConfigServer::formatWiFiPassword(char* reply, size_t reply_len) {
|
||||
if (!reply || !reply_len) return false;
|
||||
char ssid[33] = {}, password[65] = {};
|
||||
if (!loadStandaloneWiFi(ssid, sizeof(ssid), password, sizeof(password))) {
|
||||
if (_active) {
|
||||
strlcpy(password, _active->_wifi_password, sizeof(password));
|
||||
}
|
||||
#ifdef WIFI_PWD
|
||||
else strlcpy(password, WIFI_PWD, sizeof(password));
|
||||
#endif
|
||||
}
|
||||
snprintf(reply, reply_len, "> %s", password);
|
||||
memset(password, 0, sizeof(password));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WebConfigServer::formatWiFiPowerSave(char* reply, size_t reply_len) {
|
||||
if (!reply || reply_len == 0) return false;
|
||||
|
||||
@@ -1314,7 +1301,6 @@ void WebConfigServer::drainBatch(uint32_t now) {
|
||||
strcpy(e.reply, "OK");
|
||||
_admin_pwd_set = true;
|
||||
}
|
||||
if (wcIsSecretReadCommand(e.cmd)) wcMaskSecretReply(e.reply);
|
||||
if (WebConfigBatch::cliReplyGatesReboot(e.cmd)) {
|
||||
_batch_all_ok = WebConfigBatch::nextAllOk(
|
||||
_batch_all_ok, WebConfigBatch::cliWriteSucceeded(e.reply));
|
||||
@@ -2166,7 +2152,7 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
|
||||
// commands go into the shared deferred slot and tick() drains them.
|
||||
//
|
||||
// Unlike a save this is not allowlisted. It is restricted to authenticated LAN
|
||||
// mode and uses the remote-admin callback, so serial-only secrets stay hidden.
|
||||
// mode. Commands use local-connection privileges, just like the USB terminal.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Commands whose CLI handler never returns would take the node down mid-drain,
|
||||
@@ -2199,6 +2185,13 @@ void WebConfigServer::serviceTerminal(uint32_t now) {
|
||||
_terminal->queue.finish();
|
||||
return;
|
||||
}
|
||||
if (_terminal->running) {
|
||||
if (_cb->streamTerminalBusy()) return;
|
||||
if (_terminal->availableForWrite() < 2) return;
|
||||
_terminal->print("> ");
|
||||
_terminal->queue.finish();
|
||||
_terminal->running = false;
|
||||
}
|
||||
if (!_terminal->queue.pending || _batch_state == BATCH_PENDING) return;
|
||||
if (!_terminal->attached) {
|
||||
if (!_cb->beginStreamTerminal(*_terminal)) {
|
||||
@@ -2218,7 +2211,17 @@ void WebConfigServer::serviceTerminal(uint32_t now) {
|
||||
_terminal->attached = false;
|
||||
_terminal->closed = true;
|
||||
} else {
|
||||
_cb->runStreamTerminal(command);
|
||||
const char* unavailable = wcCliUnavailable(command, true);
|
||||
if (unavailable) {
|
||||
_terminal->print("Error: ");
|
||||
_terminal->println(unavailable);
|
||||
} else {
|
||||
_cb->runStreamTerminal(command);
|
||||
}
|
||||
if (_cb->streamTerminalBusy()) {
|
||||
_terminal->running = true;
|
||||
return;
|
||||
}
|
||||
_terminal->print("> ");
|
||||
}
|
||||
_terminal->queue.finish();
|
||||
|
||||
@@ -135,8 +135,8 @@ public:
|
||||
virtual void getNodeSnapshot(NodeSnapshot& snapshot) = 0;
|
||||
// Run one CLI command. Called from tick() only (loop task); reply is 160 bytes.
|
||||
virtual void execCommand(char* cmd, char* reply) = 0;
|
||||
// Browser terminal commands use remote-admin semantics so serial-only
|
||||
// secrets and destructive maintenance commands remain unavailable.
|
||||
// Authenticated LAN commands have local-console access, like a direct
|
||||
// USB/Ethernet connection. LoRa access is handled separately by each role.
|
||||
virtual bool supportsCliTerminal() const { return false; }
|
||||
virtual void execAdminCommand(char* cmd, char* reply) {
|
||||
execCommand(cmd, reply);
|
||||
@@ -147,6 +147,7 @@ public:
|
||||
virtual bool beginStreamTerminal(Stream& output) { return false; }
|
||||
virtual bool ownsStreamTerminal(const Stream& output) const { return false; }
|
||||
virtual void runStreamTerminal(char* command) {}
|
||||
virtual bool streamTerminalBusy() const { return false; }
|
||||
virtual void endStreamTerminal(Stream& output) {}
|
||||
virtual void rebootNow() = 0;
|
||||
// Bracket a config batch so bridge restarts triggered by individual
|
||||
@@ -198,6 +199,7 @@ public:
|
||||
static bool setWiFiCliEnabled(const char* value, char* reply,
|
||||
size_t reply_len);
|
||||
static bool formatWiFiSSID(char* reply, size_t reply_len);
|
||||
static bool formatWiFiPassword(char* reply, size_t reply_len);
|
||||
static bool formatWiFiStatus(
|
||||
char* reply, size_t reply_len,
|
||||
const mesh::wifi::CompanionWiFiRuntimeState* companion_runtime = NULL);
|
||||
|
||||
@@ -40,6 +40,7 @@ public:
|
||||
char session[24] = {0};
|
||||
mesh::TerminalCommandQueue queue;
|
||||
bool attached = false;
|
||||
bool running = false;
|
||||
bool closed = false;
|
||||
uint32_t last_seen = 0;
|
||||
|
||||
@@ -75,6 +76,14 @@ public:
|
||||
xSemaphoreGive(_lock);
|
||||
return n;
|
||||
}
|
||||
int availableForWrite() override {
|
||||
if (!ready()) return 0;
|
||||
xSemaphoreTake(_lock, portMAX_DELAY);
|
||||
const uint64_t unread = _output.end() - _acknowledged;
|
||||
const int room = unread < _capacity ? static_cast<int>(_capacity - unread) : 0;
|
||||
xSemaphoreGive(_lock);
|
||||
return room;
|
||||
}
|
||||
int available() override { return 0; }
|
||||
int read() override { return -1; }
|
||||
int peek() override { return -1; }
|
||||
|
||||
@@ -30,6 +30,12 @@ static SPIClass ETHERNET_SPI_PORT(NRF_SPIM1, PIN_SPI1_MISO, PIN_SPI1_SCK, PIN_SP
|
||||
static EthernetServer ethernet_server(ETHERNET_TCP_PORT);
|
||||
static EthernetClient ethernet_client;
|
||||
static volatile bool ethernet_running = false;
|
||||
static bool ethernet_session_reset = false;
|
||||
static bool ethernet_take_session_reset() {
|
||||
const bool changed = ethernet_session_reset;
|
||||
ethernet_session_reset = false;
|
||||
return changed;
|
||||
}
|
||||
|
||||
// FreeRTOS task: handles hw init, DHCP, and retries in the background
|
||||
static void ethernet_task(void* param) {
|
||||
@@ -107,6 +113,7 @@ static void ethernet_check_client() {
|
||||
if (newClient) {
|
||||
if (ethernet_client) ethernet_client.stop();
|
||||
ethernet_client = newClient;
|
||||
ethernet_session_reset = true;
|
||||
IPAddress ip = ethernet_client.remoteIP();
|
||||
Serial.printf("ETH: Client connected from %u.%u.%u.%u\n", ip[0], ip[1], ip[2], ip[3]);
|
||||
ethernet_client.println(ETHERNET_CLI_BANNER);
|
||||
|
||||
@@ -78,6 +78,10 @@ class FirmwareRamTest(unittest.TestCase):
|
||||
self.assertEqual(ram.requirements("ESP32_PLATFORM", wifi_only, "v4_companion"), plain)
|
||||
repeater = ram.requirements("ESP32_PLATFORM", defines, "v4_repeater")
|
||||
self.assertNotIn("browser_terminal_session", repeater["components"])
|
||||
for role in ("v4_repeater", "v4_room_server"):
|
||||
local = ram.requirements("ESP32_PLATFORM", {**defines, "ADMIN_PASSWORD": "test"}, role)
|
||||
self.assertEqual(local["components"]["browser_terminal_session"], 2048)
|
||||
self.assertEqual(local["components"]["browser_terminal_scrollback"], 4096)
|
||||
|
||||
def test_longer_display_previews_reserve_heap_and_contiguous_history(self):
|
||||
defines = {"DISPLAY_CLASS": "SSD1306Display", "UI_SMALL_MESSAGE_FONT": 0}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Compile real Companion command dispatch with local and LoRa callers."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
PREAMBLE = r'''
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cstdint>
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#define ESP32 1
|
||||
#define WIFI_SSID "test"
|
||||
#define WITH_MQTT_BRIDGE 1
|
||||
#define PRV_KEY_SIZE 64
|
||||
#define MAX_MQTT_SLOTS 2
|
||||
#define MAX_LORA_TX_POWER 22
|
||||
#define TXT_TYPE_CLI_COMMAND 1
|
||||
namespace mesh {
|
||||
struct Packet {};
|
||||
void resetLazyPersistenceAfterSuccess(unsigned& when, uint8_t& failures) { when=0; failures=0; }
|
||||
struct Utils {
|
||||
static void toHex(char* out,const uint8_t* data,size_t len) {
|
||||
for(size_t i=0;i<len;++i) sprintf(out+2*i,"%02X",data[i]);
|
||||
}
|
||||
};
|
||||
}
|
||||
struct ContactInfo { bool isRemoteCLIAllowed() const { return true; } };
|
||||
struct WiFiSetupPortal {
|
||||
static bool loadStoredCredentials(char*,size_t,char* password,size_t size) {
|
||||
snprintf(password,size,"wifi-secret");return true;
|
||||
}
|
||||
};
|
||||
struct Identity {
|
||||
int writeTo(uint8_t* out,size_t len) { memset(out,0xA5,len);return len; }
|
||||
};
|
||||
struct Store {
|
||||
int erased=0; bool success=true;
|
||||
bool formatFileSystem(){++erased;return success;}
|
||||
};
|
||||
struct Prefs {
|
||||
float freq=910, bw=125, airtime_factor=1, rx_delay_base=1;
|
||||
uint8_t sf=7, cr=5, path_hash_mode=0, multi_acks=0, rx_ps_level=0, rx_ps_preamble=16;
|
||||
uint32_t rx_ps_rx_us=1, rx_ps_sleep_us=1;
|
||||
int8_t tx_power_dbm=10;
|
||||
char node_name[32]="test";
|
||||
Prefs* getRadioPrefs(){return this;}
|
||||
bool handleCommand(const char*,uint32_t,char*){return false;}
|
||||
bool isDirty() const {return false;}
|
||||
void clearDirty(){}
|
||||
};
|
||||
struct Board {
|
||||
bool rebootToUf2Bootloader(){return false;}
|
||||
bool handleCommand(const char*,uint32_t,char*){return false;}
|
||||
} board;
|
||||
struct Radio {bool setTxPower(int){return true;}} radio_driver;
|
||||
struct AdvertDataParser {static bool isValidName(const char*){return true;}};
|
||||
struct StrHelper {static void strncpy(char* out,const char* in,size_t len){::strncpy(out,in,len);}};
|
||||
void recalcRxPowerSavingFromLevel(uint8_t,uint8_t,float,uint8_t,uint32_t*,uint32_t*){}
|
||||
struct StatsFormatHelper {
|
||||
template<class... T> static void formatCoreStats(char* out,T&&...){strcpy(out,"core");}
|
||||
template<class... T> static void formatRadioStats(char* out,T&&...){strcpy(out,"radio");}
|
||||
template<class... T> static void formatRadioDiag(char* out,T&&...){strcpy(out,"diag");}
|
||||
template<class... T> static void formatPacketStats(char* out,T&&...){strcpy(out,"packets");}
|
||||
};
|
||||
struct WebConfigServer {
|
||||
static bool formatWiFiPassword(char* out,size_t len){snprintf(out,len,"> wifi-secret");return true;}
|
||||
};
|
||||
const char* FIRMWARE_VERSION="test";
|
||||
const char* FIRMWARE_BUILD_DATE="test";
|
||||
struct MyMesh {
|
||||
Identity self_id;
|
||||
Store store; Store* _store=&store;
|
||||
Prefs _prefs;
|
||||
struct {char mqtt_slot_password[2][64]={"mqtt-secret","other"};
|
||||
char mqtt_slot_token[2][64]={"mqtt-token","other-token"};} _mqtt_prefs;
|
||||
unsigned dirty_contacts_expiry=1; uint8_t dirty_contacts_failures=1;
|
||||
int value=0; int* _ms=&value; int* _radio=&value; int* _mgr=&value;
|
||||
uint16_t _err_flags=0;
|
||||
bool _radio_available=false, save_ok=true;
|
||||
int resets=0;
|
||||
bool handleCommand(const char*,uint32_t,char*);
|
||||
bool handleDirectCommand(const char*,char*,size_t);
|
||||
void onCLICommandRecv(const ContactInfo&,mesh::Packet*,uint32_t,const char*,char*);
|
||||
bool handleLocalControlCommand(const char* text,char* reply,size_t) {
|
||||
if(!strcmp(text,"get mqtt1.password")||!strcmp(text,"get mqtt1.token")) {
|
||||
strcpy(reply,"> ********");return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
void stopContactsIterator(){}
|
||||
void resetContacts(){++resets;}
|
||||
bool savePrefs(){return save_ok;}
|
||||
int getTotalAirTime(){return 0;}
|
||||
int getReceiveAirTime(){return 0;}
|
||||
int getNumSentFlood(){return 0;}
|
||||
int getNumSentDirect(){return 0;}
|
||||
int getNumRecvFlood(){return 0;}
|
||||
int getNumRecvDirect(){return 0;}
|
||||
bool hasOutbound(){return false;}
|
||||
void markConnectionActive(const ContactInfo&){}
|
||||
void queueMessage(const ContactInfo&,int,mesh::Packet*,uint32_t,const void*,int,const char*){}
|
||||
};
|
||||
'''
|
||||
|
||||
SCENARIOS = r'''
|
||||
int main() {
|
||||
MyMesh node;
|
||||
char reply[160] = {};
|
||||
const char* local[] = {"get password", "get prv.key", "get wifi.pwd", "get mqtt1.password",
|
||||
"get mqtt1.token", "stats-core", "stats-radio", "stats-radio-diag", "stats-packets"};
|
||||
for(const char* command:local) {
|
||||
memset(reply,0,sizeof(reply));
|
||||
assert(node.handleCommand(command,0,reply));
|
||||
assert(strlen(reply)>0);
|
||||
if(!strcmp(command,"get password")) assert(strstr(reply,"no admin password"));
|
||||
if(!strcmp(command,"get prv.key")) {
|
||||
#if ENABLE_PRIVATE_KEY_EXPORT
|
||||
assert(strlen(reply)==130 && !strncmp(reply,"> A5A5",6));
|
||||
#else
|
||||
assert(strstr(reply,"disabled"));
|
||||
#endif
|
||||
}
|
||||
if(!strcmp(command,"get wifi.pwd")) assert(!strcmp(reply,"> wifi-secret"));
|
||||
if(!strcmp(command,"get mqtt1.password")) assert(!strcmp(reply,"> mqtt-secret"));
|
||||
if(!strcmp(command,"get mqtt1.token")) assert(!strcmp(reply,"> mqtt-token"));
|
||||
memset(reply,0,sizeof(reply));
|
||||
node.handleCommand(command,100,reply);
|
||||
assert(!strstr(reply,"secret") && !strstr(reply,"token") && !strstr(reply,"A5A5"));
|
||||
// In particular, an authenticated LoRa packet carrying timestamp zero
|
||||
// still enters the production receiver with remote restrictions.
|
||||
memset(reply,0,sizeof(reply));
|
||||
node.onCLICommandRecv(ContactInfo{},nullptr,0,command,reply);
|
||||
assert(!strstr(reply,"secret") && !strstr(reply,"token") && !strstr(reply,"A5A5"));
|
||||
}
|
||||
assert(node.handleCommand("set freq 915.25",0,reply));
|
||||
assert(node._prefs.freq==915.25f);
|
||||
assert(!node.handleCommand("set freq 920",100,reply));
|
||||
node.onCLICommandRecv(ContactInfo{},nullptr,0,"set freq 920",reply);
|
||||
assert(node._prefs.freq==915.25f);
|
||||
node.save_ok=false;
|
||||
assert(node.handleCommand("set freq 920",0,reply));
|
||||
assert(node._prefs.freq==915.25f && strstr(reply,"Error"));
|
||||
assert(node.handleCommand("set freq 920oops",0,reply));
|
||||
assert(node._prefs.freq==915.25f && strstr(reply,"Error"));
|
||||
assert(!node.handleCommand("erase",100,reply));
|
||||
node.onCLICommandRecv(ContactInfo{},nullptr,0,"erase",reply);
|
||||
assert(node.store.erased==0);
|
||||
assert(node.handleCommand("erase",0,reply));
|
||||
assert(node.store.erased==1 && node.resets==1 && node.dirty_contacts_expiry==0);
|
||||
assert(strstr(reply,"OK"));
|
||||
node.store.success=false;
|
||||
assert(node.handleCommand("erase",0,reply));
|
||||
assert(node.resets==1 && strstr(reply,"Err"));
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class LocalCliAccessTest(unittest.TestCase):
|
||||
def test_infrastructure_password_read_and_browser_transport_limits(self):
|
||||
source = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
||||
getter = extract_braced(source, "void CommonCLI::handleGetCmd(")
|
||||
password = extract_braced(getter, 'if (strcmp(config, "password") == 0)')
|
||||
web = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
|
||||
limits = extract_braced(web, "static const char* wcCliUnavailable(")
|
||||
code = r'''
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cstdint>
|
||||
struct Prefs { char password[16] = "admin-secret"; } prefs;
|
||||
Prefs* _prefs = &prefs;
|
||||
void get(const char* config, uint32_t sender_timestamp, char* reply) {
|
||||
''' + password + "\n}\n" + limits + r'''
|
||||
int main() {
|
||||
char reply[160] = {};
|
||||
get("password",0,reply);
|
||||
assert(!strcmp(reply,"> admin-secret"));
|
||||
for(uint32_t stamp:{1u,100u,0xFFFFFFFFu}) {
|
||||
get("password",stamp,reply);
|
||||
assert(strstr(reply,"local connection") && !strstr(reply,"admin-secret"));
|
||||
}
|
||||
reply[0]=0;get("passwordx",0,reply);assert(!reply[0]);
|
||||
assert(wcCliUnavailable("get password",false)==nullptr);
|
||||
assert(wcCliUnavailable("get password",true)==nullptr);
|
||||
assert(wcCliUnavailable("log",false)!=nullptr);
|
||||
assert(wcCliUnavailable("log",true)==nullptr);
|
||||
assert(wcCliUnavailable("get acl",true)==nullptr);
|
||||
assert(wcCliUnavailable("start ota",true)!=nullptr);
|
||||
assert(wcCliUnavailable("clock sync",true)!=nullptr);
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
binary = Path(temp) / "test"
|
||||
result = subprocess.run([
|
||||
"c++", "-std=c++17", "-include", "initializer_list", "-x", "c++", "-",
|
||||
"-fsanitize=address,undefined", "-fno-pie", "-no-pie", "-o", str(binary),
|
||||
], input=code, text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
result = subprocess.run([str(binary)], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout+result.stderr)
|
||||
|
||||
def test_real_companion_dispatch_local_binary_and_radio(self):
|
||||
source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
|
||||
implementation = "\n".join(extract_braced(source, signature) for signature in (
|
||||
"static bool isCompanionRadioPrefsCommand(",
|
||||
"bool MyMesh::handleDirectCommand(", "bool MyMesh::handleCommand(",
|
||||
"void MyMesh::onCLICommandRecv("))
|
||||
for export, webconfig in ((0, False), (1, False), (0, True), (1, True)):
|
||||
with self.subTest(export=export, webconfig=webconfig), tempfile.TemporaryDirectory() as temp:
|
||||
binary = Path(temp) / "test"
|
||||
result = subprocess.run([
|
||||
"c++", "-std=c++17", "-x", "c++", "-", "-I"+str(ROOT / "src"),
|
||||
"-DENABLE_PRIVATE_KEY_EXPORT="+str(export),
|
||||
*(["-DWITH_WEBCONFIG=1"] if webconfig else []),
|
||||
"-fsanitize=address,undefined", "-fno-pie", "-no-pie", "-o", str(binary),
|
||||
], input=PREAMBLE+implementation+SCENARIOS, text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
result = subprocess.run([str(binary)], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout+result.stderr)
|
||||
|
||||
def test_each_host_transport_uses_local_dispatch(self):
|
||||
for role in ("simple_repeater", "simple_room_server"):
|
||||
header = (ROOT / "examples" / role / "MyMesh.h").read_text()
|
||||
web = extract_braced(header, "void execAdminCommand(")
|
||||
self.assertIn("handleCommand(0, cmd, reply)", web)
|
||||
local = extract_braced(header, "void handleLocalCommand(")
|
||||
self.assertIn("_command_output = &output", local)
|
||||
self.assertIn("handleCommand(0, command, reply)", local)
|
||||
self.assertIn("_command_output = nullptr", local)
|
||||
stream = extract_braced(header, "void runStreamTerminal(")
|
||||
self.assertIn("handleLocalCommand(command, reply, *_web_terminal)", stream)
|
||||
main = (ROOT / "examples" / role / "main.cpp").read_text()
|
||||
self.assertIn("handleLocalCommand(ethernet_command, reply, ethernet_client)", main)
|
||||
self.assertIn("cancelLocalOutput(ethernet_client)", main)
|
||||
source = (ROOT / "examples" / role / "MyMesh.cpp").read_text()
|
||||
guard = ("sender != nullptr" if role == "simple_repeater"
|
||||
else "gpio_client_index >= 0")
|
||||
self.assertIn("if ("+guard+" && sender_timestamp == 0) sender_timestamp = 1;", source)
|
||||
companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
|
||||
framed = extract_braced(companion, "else if (mesh::companion::isRunCliFrame(")
|
||||
self.assertIn("handleCommand(text, 0, reply_buf)", framed)
|
||||
sensor = (ROOT / "examples/simple_sensor/SensorMesh.cpp").read_text()
|
||||
self.assertIn("if (gpio_client_index >= 0 && sender_timestamp == 0) sender_timestamp = 1;", sensor)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,124 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <helpers/LocalCliOutput.h>
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
struct FileState { std::string text; size_t reads = 0; bool closed = false, fail = false; };
|
||||
struct File {
|
||||
FileState* state = nullptr;
|
||||
size_t offset = 0;
|
||||
explicit operator bool() const { return state && !state->closed; }
|
||||
size_t size() const { return state->text.size(); }
|
||||
void close() { if (state) state->closed = true; }
|
||||
int read(uint8_t* out, size_t count) {
|
||||
++state->reads;
|
||||
if (state->fail) return -1;
|
||||
count = std::min(count, state->text.size() - offset);
|
||||
memcpy(out, state->text.data() + offset, count);
|
||||
offset += count;
|
||||
return count;
|
||||
}
|
||||
};
|
||||
struct Output : Stream {
|
||||
std::string text;
|
||||
int room = 40;
|
||||
size_t max_write = 7;
|
||||
int availableForWrite() override { return room; }
|
||||
size_t write(const uint8_t* data, size_t count) override {
|
||||
count = std::min(count, max_write);
|
||||
text.append(reinterpret_cast<const char*>(data), count);
|
||||
return count;
|
||||
}
|
||||
};
|
||||
void drain(mesh::LocalCliOutput<File>& job) {
|
||||
for (size_t n = 0; job.busy() && n < 100000; ++n) job.service();
|
||||
ASSERT_FALSE(job.busy());
|
||||
}
|
||||
|
||||
TEST(LocalCliOutput, LargeFileIsLosslessWithPartialWritesAndLengthSnapshot) {
|
||||
FileState file{std::string(90000, 'x')};
|
||||
const auto expected = file.text;
|
||||
mesh::LocalCliOutput<File> job;
|
||||
Output output;
|
||||
ASSERT_TRUE(job.startFile(output, File{&file}));
|
||||
file.text += "new live log entries";
|
||||
drain(job);
|
||||
EXPECT_EQ(output.text, expected + "\r\n EOF\r\n");
|
||||
EXPECT_TRUE(file.closed);
|
||||
EXPECT_EQ(file.reads, (expected.size() + 159) / 160);
|
||||
}
|
||||
|
||||
TEST(LocalCliOutput, SlowClientDoesNotReadUnboundedDataOrStealOutput) {
|
||||
FileState file{std::string(4096, 'a')};
|
||||
mesh::LocalCliOutput<File> job;
|
||||
Output first, second;
|
||||
first.room = 0;
|
||||
ASSERT_TRUE(job.startFile(first, File{&file}));
|
||||
for (int i = 0; i < 1000; ++i) job.service();
|
||||
EXPECT_EQ(file.reads, 1u);
|
||||
EXPECT_TRUE(first.text.empty());
|
||||
EXPECT_TRUE(job.owns(first));
|
||||
EXPECT_FALSE(job.owns(second));
|
||||
FileState other{"different"};
|
||||
EXPECT_FALSE(job.startFile(second, File{&other}));
|
||||
EXPECT_TRUE(other.closed);
|
||||
first.room = 40;
|
||||
drain(job);
|
||||
EXPECT_EQ(first.text, file.text + "\r\n EOF\r\n");
|
||||
EXPECT_TRUE(second.text.empty());
|
||||
}
|
||||
|
||||
TEST(LocalCliOutput, CancelStopsOldOutputBeforeAReplacementConnection) {
|
||||
FileState old{"old connection secret"};
|
||||
mesh::LocalCliOutput<File> job;
|
||||
Output output;
|
||||
output.room = 0;
|
||||
ASSERT_TRUE(job.startFile(output, File{&old}));
|
||||
job.service();
|
||||
job.cancel();
|
||||
EXPECT_TRUE(old.closed);
|
||||
FileState replacement{"new connection"};
|
||||
ASSERT_TRUE(job.startFile(output, File{&replacement}));
|
||||
output.room = 40;
|
||||
drain(job);
|
||||
EXPECT_EQ(output.text, "new connection\r\n EOF\r\n");
|
||||
}
|
||||
|
||||
TEST(LocalCliOutput, AclRowsDrainCompletelyWithoutATableSizedBuffer) {
|
||||
mesh::LocalCliOutput<File> job;
|
||||
Output output;
|
||||
ASSERT_TRUE(job.startRows(output,
|
||||
[](void*, size_t& row, char* out, size_t capacity) -> size_t {
|
||||
if (row == 254) return 0;
|
||||
return snprintf(out, capacity, "03 %064u\r\n", static_cast<unsigned>(row++));
|
||||
}, nullptr, "ACL:\r\n"));
|
||||
drain(job);
|
||||
std::string expected = "ACL:\r\n";
|
||||
for (unsigned n = 0; n < 254; ++n) {
|
||||
char line[80];
|
||||
snprintf(line, sizeof(line), "03 %064u\r\n", n);
|
||||
expected += line;
|
||||
}
|
||||
EXPECT_EQ(output.text, expected);
|
||||
}
|
||||
|
||||
TEST(LocalCliOutput, MissingAndFailedFilesEndWithoutBlockingTheNextCommand) {
|
||||
mesh::LocalCliOutput<File> job;
|
||||
Output output;
|
||||
ASSERT_TRUE(job.startFile(output, File{}));
|
||||
drain(job);
|
||||
EXPECT_EQ(output.text, "\r\n EOF\r\n");
|
||||
output.text.clear();
|
||||
FileState file{"log"}; file.fail = true;
|
||||
ASSERT_TRUE(job.startFile(output, File{&file}));
|
||||
drain(job);
|
||||
EXPECT_EQ(output.text, "\r\nError: log read failed\r\n");
|
||||
EXPECT_TRUE(file.closed);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -37,6 +37,7 @@ class Print { public: virtual ~Print() {};
|
||||
class Stream: public Print { public:
|
||||
virtual int available()=0; virtual int read()=0; virtual int peek()=0;
|
||||
virtual void flush()=0;
|
||||
virtual int availableForWrite(){return 0;}
|
||||
};
|
||||
""",
|
||||
"esp_heap_caps.h": """#pragma once
|
||||
@@ -91,6 +92,19 @@ int main(){
|
||||
assert(result==data && bytes()==4096);
|
||||
}
|
||||
assert(allocated.empty());
|
||||
{
|
||||
WebTerminalStream t;
|
||||
assert(t.availableForWrite()==4096);
|
||||
std::string data(4096,'b');
|
||||
t.write(reinterpret_cast<const uint8_t*>(data.data()),data.size());
|
||||
assert(t.availableForWrite()==0);
|
||||
uint64_t cursor=0;char chunk[1025];bool lost=false;
|
||||
assert(t.readOutput(cursor,chunk,sizeof(chunk),lost)==1024 && !lost);
|
||||
assert(t.availableForWrite()==0); // A read response alone is not an ACK.
|
||||
assert(t.readOutput(cursor,chunk,sizeof(chunk),lost)==1024 && !lost);
|
||||
assert(t.availableForWrite()==1024); // The next cursor acknowledges it.
|
||||
}
|
||||
assert(allocated.empty());
|
||||
psram=false;internal_free=8192;
|
||||
{WebTerminalStream t;assert(!t.ready());assert(t.write('x')==0);}
|
||||
assert(allocated.empty());
|
||||
|
||||
@@ -150,8 +150,9 @@ TEST(WebConfigKeys, EverySecretKeyIsAlsoAllowed) {
|
||||
// recognises the serial console and answers secret getters in plaintext. These
|
||||
// are the reads that must be masked back down for an HTTP caller.
|
||||
|
||||
TEST(WebConfigKeys, MasksEverySecretReadTheCliCanReach) {
|
||||
TEST(WebConfigKeys, ClassifiesEverySecretReadTheCliCanReach) {
|
||||
const char* masked[] = {
|
||||
"get password",
|
||||
"get prv.key", // this node's identity -- the worst one to leak
|
||||
"get wifi.pwd", // grants the operator's LAN, not just the node
|
||||
"get guest.password",
|
||||
@@ -164,7 +165,7 @@ TEST(WebConfigKeys, MasksEverySecretReadTheCliCanReach) {
|
||||
for (const char* c : masked) EXPECT_TRUE(wcIsSecretReadCommand(c)) << c;
|
||||
}
|
||||
|
||||
TEST(WebConfigKeys, DoesNotMaskReadsThatCarryNoSecret) {
|
||||
TEST(WebConfigKeys, DoesNotClassifyPublicReadsAsSecrets) {
|
||||
const char* plain[] = {
|
||||
"get wifi.ssid", "get mqtt1.username", "get mqtt1.server", "get tx",
|
||||
"get public.key", // public half, safe to read
|
||||
|
||||
@@ -116,6 +116,21 @@ class WebConfigSecretMaskingTest(unittest.TestCase):
|
||||
)
|
||||
self.assertIn('strcmp(key, "wifi.pwd") == 0', secret_classifier)
|
||||
|
||||
def test_explicit_local_cli_reads_are_not_config_snapshots(self):
|
||||
# Both terminal APIs require the authenticated LAN, and neither applies
|
||||
# the config form's secret masking to an explicit command reply.
|
||||
batch = function_body(self.server, "void WebConfigServer::drainBatch(",
|
||||
"void WebConfigServer::attachRoutes(")
|
||||
self.assertIn("execAdminCommand(e.cmd, e.reply)", batch)
|
||||
self.assertNotIn("wcMaskSecretReply", batch)
|
||||
for start, end in [
|
||||
("void WebConfigServer::handleCliPost(", "void WebConfigServer::handleCliResult("),
|
||||
("bool WebConfigServer::checkTerminalAccess(", "void WebConfigServer::handleTerminalPost("),
|
||||
]:
|
||||
handler = function_body(self.server, start, end)
|
||||
self.assertIn("_mode != MODE_LAN", handler)
|
||||
self.assertIn("checkAuth(req)", handler)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -185,10 +185,12 @@ class WebConfigUiRuntimeTest(unittest.TestCase):
|
||||
suggestions = json.loads(html.unescape(re.search(
|
||||
r'data-test-suggestions="([^"]*)"', dom).group(1)))
|
||||
for command in ("set powersaving off", "get usb.logging", "set wifi.cli ",
|
||||
"set mqtt1.preset ", "get name", "get radio"):
|
||||
"set mqtt1.preset ", "get name", "get radio", "erase",
|
||||
"get prv.key", "get mqtt1.password", "get wifi.pwd",
|
||||
"stats-core", "stats-radio", "stats-packets", "set freq "):
|
||||
self.assertIn(command, suggestions)
|
||||
for command in ("erase", "password ", "setperm ", "get prv.key",
|
||||
"set bridge.enabled ", "get mqtt1.password", "set mqtt2.preset "):
|
||||
for command in ("password ", "setperm ", "get acl",
|
||||
"set bridge.enabled ", "set mqtt2.preset "):
|
||||
self.assertNotIn(command, suggestions)
|
||||
|
||||
def test_stream_terminal_imports_long_cards_pages_lists_and_receives_late_replies(self):
|
||||
@@ -250,6 +252,54 @@ class WebConfigUiRuntimeTest(unittest.TestCase):
|
||||
self.assertIn("firmware help: import, list, to, send", output)
|
||||
self.assertIn('data-test-stream-busy="false"', dom)
|
||||
|
||||
def test_streamed_raw_log_keeps_entire_file_and_allows_slow_progress(self):
|
||||
status, config = self.setup_values()
|
||||
status.update(mode="lan", role="Repeater", cli=True, terminal_stream=True,
|
||||
terminal_max_command=159, password_supported=False)
|
||||
prelude = """
|
||||
<script>
|
||||
(function(){
|
||||
var status=%s,config=%s,sequence=0,output="",offset=0;
|
||||
var dump="log-start\\n"+"packet-row\\n".repeat(10000)+"log-end\\n EOF\\n";
|
||||
var now=Date.now;Date.now=function(){return now()+offset};
|
||||
function response(value){return Promise.resolve({ok:true,status:200,json:function(){return Promise.resolve(value)}})}
|
||||
window.fetch=function(path,options){
|
||||
if(path==="/api/status")return response(status);
|
||||
if(path==="/api/config")return response(config);
|
||||
if(path==="/api/presets")return response({presets:[]});
|
||||
if(path==="/api/terminal"){
|
||||
sequence=JSON.parse(options.body).seq;output=dump;
|
||||
return response({accepted:true});
|
||||
}
|
||||
if(path.indexOf("/api/terminal?")===0){
|
||||
var from=Number(new URLSearchParams(path.split("?")[1]).get("after"));
|
||||
var chunk=output.slice(from,from+3072),cursor=from+chunk.length;
|
||||
offset+=2000; // A complete dump takes over 30 seconds, but keeps progressing.
|
||||
return response({output:chunk,cursor:cursor,seq:sequence,done:cursor>=dump.length,
|
||||
closed:false,lost:false,more:false});
|
||||
}
|
||||
return response({});
|
||||
};
|
||||
window.addEventListener("load",function(){
|
||||
setTimeout(function(){cliStreamRun(["log"])},75);
|
||||
setTimeout(function(){output+="later-output\\n"},6000);
|
||||
setTimeout(function(){
|
||||
var text=document.getElementById("term-out").textContent;
|
||||
document.body.setAttribute("data-test-complete-log",String(text.includes(dump)));
|
||||
document.body.setAttribute("data-test-later-output",String(text.includes("later-output")));
|
||||
document.body.setAttribute("data-test-log-busy",String(cli.busy));
|
||||
document.body.setAttribute("data-test-log-timeout",String(text.includes("could not be confirmed")));
|
||||
},6800);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
""" % (json.dumps(status), json.dumps(config))
|
||||
dom = self.run_page(prelude, virtual_time=7000)
|
||||
self.assertIn('data-test-complete-log="true"', dom)
|
||||
self.assertIn('data-test-later-output="true"', dom)
|
||||
self.assertIn('data-test-log-busy="false"', dom)
|
||||
self.assertIn('data-test-log-timeout="false"', dom)
|
||||
|
||||
def test_console_stays_hidden_when_disabled_or_on_setup_ap(self):
|
||||
for mode in ("lan", "setup"):
|
||||
with self.subTest(mode=mode):
|
||||
|
||||
+23
-11
@@ -1820,8 +1820,7 @@ var CLI_VERBS=[
|
||||
["stats-packets","Per-packet-type counters"],
|
||||
["stats-radio","Radio counters (RSSI, SNR, noise)"],
|
||||
["stats-radio-diag","Extended radio diagnostics"],
|
||||
// Bare `log` is absent: it streams the file to the serial console and stalls
|
||||
// the radio doing it, and hands back only "EOF".
|
||||
["log","Dump stored packet logs to this connection"],
|
||||
["log start","Start packet logging to the filesystem"],
|
||||
["log stop","Stop packet logging"],
|
||||
["log erase","Delete the stored packet logs"],
|
||||
@@ -1843,6 +1842,7 @@ var CLI_VERBS=[
|
||||
["get bluetooth.stealth","Show the independent Bluetooth stealth flag"],
|
||||
["set bluetooth.stealth ","Set stealth {on|off}, keeping the address policy"],
|
||||
["password ","Change the admin password {new-password}"],
|
||||
["get password","Read this node's admin password (local connection)"],
|
||||
["alert test","Send a test alert on the configured channel"],
|
||||
["ota check","Check for a newer build (does not flash)"],
|
||||
["ota update","Download and flash the newer build, then reboot"],
|
||||
@@ -1864,8 +1864,8 @@ var CLI_KEYS=[
|
||||
["lat","Advert latitude",0],
|
||||
["lon","Advert longitude",0],
|
||||
["public.key","This node's public key",1],
|
||||
// `get acl` is absent: it prints to the serial console and returns nothing.
|
||||
["prv.key","Restore an identity {64-hex-char-key}",2],
|
||||
["acl","List client permissions and public keys",1],
|
||||
["prv.key","Read or restore an identity {128-hex-char-key}",0],
|
||||
["role","Node role",1],
|
||||
["radio","Radio parameters {freq,bw,sf,cr}",0],
|
||||
["freq","Frequency in MHz",0],
|
||||
@@ -1961,6 +1961,11 @@ var CLI_TYPES="req,response,txt_msg,ack,advert,grp_txt,grp_data,anon_req,path,tr
|
||||
// Companion uses its USB/TCP command parser, including its chat session.
|
||||
// Repeater ACL and rescue-only filesystem commands belong to other parsers.
|
||||
var CLI_COMPANION_VERBS=[
|
||||
["erase","Erase stored settings and identity"],
|
||||
["stats-core","Core health counters"],
|
||||
["stats-radio","Radio counters"],
|
||||
["stats-radio-diag","Extended radio diagnostics"],
|
||||
["stats-packets","Packet counters"],
|
||||
["help","List commands supported by this firmware"],
|
||||
["card","Export this node's meshcore:// contact card"],
|
||||
["import ","Add a contact: import meshcore:// followed by the full card"],
|
||||
@@ -1995,9 +2000,10 @@ var CLI_COMPANION_VERBS=[
|
||||
["reboot","Restart the node"]
|
||||
];
|
||||
var CLI_COMPANION_KEYS=[
|
||||
["prv.key","Read this node's private identity key",1],
|
||||
["name","Node name",0], ["role","Node role",1],
|
||||
["lat","Advert latitude",0], ["lon","Advert longitude",0],
|
||||
["radio","Radio parameters {freq,bw,sf,cr}",0], ["freq","Frequency in MHz",1],
|
||||
["radio","Radio parameters {freq,bw,sf,cr}",0], ["freq","Frequency in MHz",0],
|
||||
["tx","TX power in dBm",0], ["af","Airtime factor",0],
|
||||
["dutycycle","Duty cycle percent",0], ["rxdelay","RX delay base",0],
|
||||
["cad","Listen before transmit",0,"on|off"],
|
||||
@@ -2012,7 +2018,7 @@ var CLI_COMPANION_KEYS=[
|
||||
["bluetooth.mac","Bluetooth address policy",0,"default|random|random-every-boot|random-after-connect"],
|
||||
["bluetooth.stealth","Bluetooth stealth",0,"on|off"],
|
||||
["path.hash.mode","Path hash mode",0,"0|1|2"], ["multi.acks","Extra ACKs",0],
|
||||
["wifi.ssid","WiFi network name",0], ["wifi.pwd","WiFi password",2],
|
||||
["wifi.ssid","WiFi network name",0], ["wifi.pwd","WiFi password",0],
|
||||
["wifi.status","WiFi connection, address and signal",1],
|
||||
["wifi.powersave","WiFi power saving",0,"min|max|none"],
|
||||
["wifi.cli","Browser console",0,"on|off"],
|
||||
@@ -2053,7 +2059,7 @@ function cliTable(){
|
||||
for(var n=1;n<=st.nslots;n++){
|
||||
CLI_SLOT.forEach(function(f){
|
||||
if(companion&&f[0]==="filter")return;
|
||||
if(!companion||!(/^(password|token)$/.test(f[0])))t.push(["get mqtt"+n+"."+f[0],"Slot "+n+" "+f[1]]);
|
||||
t.push(["get mqtt"+n+"."+f[0],"Slot "+n+" "+f[1]]);
|
||||
t.push(["set mqtt"+n+"."+f[0]+" ","Slot "+n+" "+f[1]]);
|
||||
});
|
||||
}
|
||||
@@ -2107,7 +2113,7 @@ function cliBanner(){
|
||||
cliEcho("sys","Type help for a summary, or a prefix and Tab to complete.");
|
||||
}
|
||||
function cliHelp(){
|
||||
if(st.streamTerminal){if(!cli.busy)cliRun(["help"]);return}
|
||||
if(st.streamTerminal&&cliIsCompanion()){if(!cli.busy)cliRun(["help"]);return}
|
||||
cliGap();
|
||||
cliEcho("cmd","help");
|
||||
cliEcho("rep",
|
||||
@@ -2437,8 +2443,8 @@ function cliRun(cmds){
|
||||
});
|
||||
}
|
||||
|
||||
// A real Companion terminal: one session owns recipient/login state, and
|
||||
// cursor reads continue after a command finishes to show ACKs and RF replies.
|
||||
// Stream replies for every role. Companion sessions also own recipient/login
|
||||
// state and keep reading after completion to show ACKs and RF replies.
|
||||
var cliStream={session:"",seq:0,cursor:0,timer:0,polling:false,out:null,job:null,errors:0};
|
||||
function cliStreamReset(){
|
||||
clearTimeout(cliStream.timer);
|
||||
@@ -2450,7 +2456,11 @@ function cliStreamText(text){
|
||||
if(!text)return;
|
||||
var out=$("#term-out"),atEnd=out.scrollTop+out.clientHeight>=out.scrollHeight-24;
|
||||
if(!cliStream.out||!cliStream.out.parentNode)cliStream.out=cliEcho("rep","");
|
||||
cliStream.out.textContent=(cliStream.out.textContent+text.replace(/\r/g,"")).slice(-65536);
|
||||
var combined=cliStream.out.textContent+text.replace(/\r/g,"");
|
||||
// Preserve an explicitly requested raw dump, including files over 64 KiB.
|
||||
// Idle Companion traffic keeps its bounded scrollback.
|
||||
var job=cliStream.job,rawLog=job&&job.cmds[job.index].trim().toLowerCase()==="log";
|
||||
cliStream.out.textContent=rawLog?combined:combined.slice(-65536);
|
||||
if(atEnd)out.scrollTop=out.scrollHeight;
|
||||
}
|
||||
function cliStreamRun(cmds){
|
||||
@@ -2495,11 +2505,13 @@ function cliStreamPoll(){
|
||||
cliStream.errors=0;
|
||||
if(r.lost)cliEcho("err","Some terminal output expired while disconnected.");
|
||||
cliStreamText(r.output||"");cliStream.cursor=r.cursor;
|
||||
if(r.output&&cliStream.job)cliStream.job.started=Date.now();
|
||||
if(r.more){cliStream.timer=setTimeout(cliStreamPoll,0);return}
|
||||
var job=cliStream.job;
|
||||
if(r.closed){cliStreamReset();return}
|
||||
if(job&&r.seq===job.seq&&r.done){
|
||||
cliAfter([job.cmds[job.index]],{});
|
||||
cliStream.out=null;
|
||||
if(++job.index<job.cmds.length){cliStreamSend();return}
|
||||
cliStream.job=null;cliBusy(false);
|
||||
}else if(job&&Date.now()-job.started>30000){
|
||||
|
||||
Reference in new issue
Block a user