diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 6c915e2b0..db9950249 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -585,6 +585,7 @@ jobs: - name: Verify signed private fleet management run: | + python3 -B test/test_hardware_commands.py -v python3 -B test/test_fleet_command_crypto.py -v python3 -B test/test_companion_fleet_control.py -v python3 -B test/test_fleet_channel_runtime.py -v diff --git a/docs/cli_commands.md b/docs/cli_commands.md index b9e52e4a4..6c18a01aa 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -4,6 +4,13 @@ # CLI Commands +For hardware-specific app templates, use `get hardware`, then prefix a command +with `@board/role`, for example `@Heltec_tower_v2/r ota pull A1B2C3D4 flash`. +`/r` means repeater, `/s` room server, `/c` Companion and `/n` sensor. Other +hardware replies `OK - skipped (hardware/role)`. A short `@#/r` +form is also available. See [hardware and role conditions](fleet_management.md#hardware-and-role-conditions) +for signed fleet use, availability and OTA prerequisites. + For SolarXiao Base/Advanced/MPPT automatic detection, manual overrides, live battery/MCU readings and charger controls, see [SolarXiao carrier profiles](solarxiao_power.md) (`get solar`, `get solar readings`, `set solar.board auto`, `get mppt`, `set mppt`). diff --git a/docs/fleet_management.md b/docs/fleet_management.md index 6a5f1e8fc..b1d8e7bdf 100644 --- a/docs/fleet_management.md +++ b/docs/fleet_management.md @@ -237,6 +237,83 @@ sequence; the operation should be chosen to be idempotent. ## Supported controls +### Hardware and role conditions + +Use `get hardware` on a node to find its hardware name, role and short ID: + +```text +> hw=heltec_tower_v2 role=repeater id=536D28C9 +``` + +Prefix a command with `@board/role` to run it only on matching hardware: + +```text +@Heltec_tower_v2/r ota pull A1B2C3D4 flash +@#536D28C9/r ota pull A1B2C3D4 flash +``` + +The second line is the shorter spelling of the first. `/r` selects repeaters, +`/s` room servers, `/c` Companions and `/n` sensors. Omit `/role` to match any +role on that board; `@*/r` selects any repeater. Hardware names ignore case and +accept underscores for spaces. Use the reported name or ID: board names can +differ between builds with different storage or radio hardware. + +These prefixes work in ordinary authenticated admin CLI queues as well as signed +fleet commands. A mismatched node replies `OK - skipped (hardware/role)` and +does not run the inner command, so an app can continue its template queue. +Sensor and Companion guards work in their ordinary CLI; signed fleet reception +remains limited to repeaters and room servers. +For example, after the existing fleet enrollment: + +```text +fleet send 3 get hardware +fleet send 3 @Heltec_tower_v2/r get radio2 +fleet send 3 @#536D28C9/r ota pull A1B2C3D4 flash +fleet send 3 @*/s get radio2 +``` + +Fleet OTA pulls require a specific hardware name or short ID, `/r` or `/s`, +the exact eight-hex-digit manifest ID from `ota ls`, and the `flash` destination. +The normal [LoRa OTA setup](lora_ota_automation.md), temporary-radio requirement, +image compatibility checks and signer policy still apply. Explicit installation, +rescue and bootloader commands are not added to fleet control. An existing +autoinstall setting retains its normal effect. + +#### SD-backed receivers + +The same `flash` destination selects the receiver's configured OTA store, +including microSD on the SD-enabled MeshTower V2 and adaptive RAK4631/RAK15002 +receiver builds. It does not force internal-flash staging. Use the hardware +identity reported by `get hardware`, not the SD module's name: + +```text +fleet send 3 @Heltec_tower_v2/r ota pull A1B2C3D4 flash +fleet send 3 @RAK4631/r ota pull 11223344 flash +``` + +Each ID must identify that board's update. SD installation requires a usable +card, the matching SD-capable bootloader and a signed package from a trusted +signer. Check `ota self` and `ota storage` through individual admin CLI before +starting. An SD socket or an archive-only firmware build does not enable +installation; SD Companions and the legacy RAK15002 seeder remain sources only. +SD partial downloads remain on the card for resume. Keep the card inserted +through download and installation; the receiver does not move an active +transfer to internal storage if the card is removed. See +[MeshTower SD storage selection](ota_meshtower_v2_sdcard.md#automatic-storage-selection) +and [RAK4631 storage options](ota_nrf52_qspi.md). + +The prefix is covered by the existing command signature and consumes the normal +command-byte budget; no extra packet format is needed. Its short ID is a +32-bit FNV-1a hash of the hardware name after lowercasing and replacing spaces +with underscores. It is a selector, not an authentication or compatibility key. +Ordinary unguarded commands retain their existing behavior. Older firmware +rejects guarded commands; both endpoints need this support for signed fleet use. +Fixed-size STM32 builds omit hardware guards with fleet control. Run password, +private-key backup, replay-recovery and interactive `region load` commands +without a guard so their existing special handling remains in charge. + +### Packet budget + The Companion automatically uses **one or two LoRa packets**, depending on the signed command's size. Whole-fleet commands fit **87 ASCII bytes** in one packet or **230 bytes** across two. Targets consume some of that allowance: @@ -301,6 +378,9 @@ Reboot or changing enrollment discards the partial transfers. | Scheduled primary profile | `get/set/del radioat`, `get/set/del tempradioat` | | Scheduled second profile | `get/set/del radioat2`, `get/set/del tempradioat2` | | UTC clock | `clock`, `clock sync`, `time ` | +| Hardware identity | `get hardware` | +| OTA download | `@board/r ota pull flash` (`/s` for rooms) | +| OTA progress | `ota status`, `ota stats`, `ota self` | | Profile diagnostics | `get radio2.status`, `get radio2.timing` | | Crossover | `get radio2.cross`, `set radio2.cross ` | | Repeater guest login | `get guest`, `set guest on|off` | diff --git a/docs/ota_meshtower_v2_sdcard.md b/docs/ota_meshtower_v2_sdcard.md index 778544728..2bd0d77cd 100644 --- a/docs/ota_meshtower_v2_sdcard.md +++ b/docs/ota_meshtower_v2_sdcard.md @@ -18,6 +18,20 @@ hardware ID `Heltec_tower_v2`, application layout, and artifact names remain unchanged. The required Full and Reduced sensor choices use that same target. Existing SD users need no configuration change. +Firmware with [hardware-aware fleet commands](fleet_management.md#hardware-and-role-conditions) +can start a download on this receiver with: + +```text +fleet send 3 @Heltec_tower_v2/r ota pull A1B2C3D4 flash +``` + +Replace the example ID with the signed update's eight-digit ID from `ota ls`. +`flash` selects this build's OTA store, including SD when a usable card is +selected. The private fleet enrollment, temporary-radio window, trusted signer +and matching SD-aware bootloader are still required. This command starts the +download; explicit installation remains an individual admin operation, and an +already-enabled trusted autoinstall policy retains its normal effect. + The build aliases `Heltec_tower_v2_repeater` and `Heltec_tower_v2_repeater_lora_ota_no_external_sensors` resolve to this primary target. An installed internal-only image has a different OTA identity and diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index d75d2dc5d..f16266773 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #if defined(USE_LR2021) @@ -11251,6 +11252,18 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, while (*command == ' ' || *command == '\t') command++; } +#if MESH_ENABLE_FLEET_CONTROL + if (mesh::hardwareCommand::apply(command, + mesh::hardwareCommand::hardware(board.getManufacturerName()), + "companion", reply, reply_capacity)) return true; + if (!strcmp(command, "get hardware")) { + mesh::hardwareCommand::format( + mesh::hardwareCommand::hardware(board.getManufacturerName()), + "companion", reply, reply_capacity); + return true; + } +#endif + #if defined(ESP32_PLATFORM) // Catch remote requests before the direct-transport router below. That // router is also used by USB/BLE/browser commands without a timestamp. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 106bc6337..1681c0083 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1,5 +1,6 @@ #include #include "MyMesh.h" +#include #include #if FLOOD_ADVERT_RTC_FAST_SLOTS @@ -12592,6 +12593,13 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * command += 3; } +#if MESH_ENABLE_FLEET_CONTROL + const char* hardware_command = command; + if (mesh::hardwareCommand::apply(hardware_command, + mesh::hardwareCommand::hardware(_cli.getBoard()->getManufacturerName()), + FIRMWARE_ROLE, reply, 157)) return; + command += hardware_command - command; +#endif mesh::cli::normalizeCommandVerb(command); const mesh::cli::NoArgCommandMatch discover_neighbors_match = mesh::cli::matchNoArgCommand(command, "discover.neighbors"); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 183e481b3..cd4f31eca 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1,4 +1,5 @@ #include "MyMesh.h" +#include #include #if defined(USE_LR2021) #include @@ -4093,6 +4094,13 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply command += 3; } +#if MESH_ENABLE_FLEET_CONTROL + const char* hardware_command = command; + if (mesh::hardwareCommand::apply(hardware_command, + mesh::hardwareCommand::hardware(_cli.getBoard()->getManufacturerName()), + FIRMWARE_ROLE, reply, 157)) return; + command += hardware_command - command; +#endif mesh::cli::normalizeCommandVerb(command); if (mesh::room_mail_protocol_detail::prefix(command, "mail")) { diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index e099df08e..7e616b575 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -1,4 +1,5 @@ #include "SensorMesh.h" +#include #include #if defined(USE_LR2021) #include @@ -761,6 +762,13 @@ void SensorMesh::handleCommand(uint32_t sender_timestamp, char* command, char* r command += 3; } +#if MESH_ENABLE_FLEET_CONTROL + const char* hardware_command = command; + if (mesh::hardwareCommand::apply(hardware_command, + mesh::hardwareCommand::hardware(_cli.getBoard()->getManufacturerName()), + FIRMWARE_ROLE, reply, 157)) return; + command += hardware_command - command; +#endif mesh::cli::normalizeCommandVerb(command); #if MESH_ENABLE_SENSOR_TRACKER diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 97478a842..b29270f9a 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1,5 +1,6 @@ #include "CLIReplyText.h" #include "ConfigCapabilities.h" +#include "HardwareCommand.h" #include #if defined(USE_LR2021) #include @@ -6583,6 +6584,12 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep mesh::Utils::toHex(&reply[2], _callbacks->getSelfId().pub_key, PUB_KEY_SIZE); } else if (configKeyEquals(config, "role")) { sprintf(reply, "> %s", _callbacks->getRole()); +#if MESH_ENABLE_FLEET_CONTROL + } else if (configKeyEquals(config, "hardware")) { + mesh::hardwareCommand::format( + mesh::hardwareCommand::hardware(_board->getManufacturerName()), + _callbacks->getRole(), reply, 157); +#endif } else if (configKeyEquals(config, "bridge.type")) { sprintf(reply, "> %s", #if defined(WITH_UDP_BRIDGE) && !defined(WITH_MQTT_BRIDGE) && !defined(WITH_RS232_BRIDGE) && !defined(WITH_ESPNOW_BRIDGE) diff --git a/src/helpers/FleetCommand.cpp b/src/helpers/FleetCommand.cpp index d15fc0b6f..cd5921410 100644 --- a/src/helpers/FleetCommand.cpp +++ b/src/helpers/FleetCommand.cpp @@ -1,5 +1,6 @@ #include "FleetCommand.h" #include "RadioProfilePresets.h" +#include "HardwareCommand.h" #include #include #include @@ -554,6 +555,26 @@ bool FleetCommand::commandAllowed(const char* command) { pattern_characters = pattern_characters || byte == '|' || byte == '\\'; } if (!length || command[0] == ' ' || command[length - 1] == ' ') return false; + hardwareCommand::Selector selector; + const auto guard = hardwareCommand::parse(command, selector); + if (guard == hardwareCommand::Parse::Invalid) return false; + if (guard == hardwareCommand::Parse::Valid) { + length -= size_t(selector.command - command); + command = selector.command; + } + if (!strcmp(command, "get hardware")) return true; + // Fleet can stage a specific advertised update for an explicit board/role. + // Installation, rescue, catalog-index aliases and policy writes stay outside + // fleet capability; ordinary OTA compatibility/signature checks still run. + if (!strncmp(command, "ota pull ", 9)) { + if (guard != hardwareCommand::Parse::Valid || selector.any + || (selector.role != 'r' && selector.role != 's') + || length != 23 || strcmp(command + 17, " flash")) return false; + for (size_t i = 9; i < 17; ++i) if (hexDigit(command[i]) < 0) return false; + return true; + } + if (!strcmp(command, "ota status") || !strcmp(command, "ota stats") + || !strcmp(command, "ota self")) return true; if (!strcmp(command, "clock") || !strcmp(command, "clock sync")) return true; if (!strncmp(command, "time ", 5)) { uint32_t value; diff --git a/src/helpers/HardwareCommand.h b/src/helpers/HardwareCommand.h new file mode 100644 index 000000000..90391f6f4 --- /dev/null +++ b/src/helpers/HardwareCommand.h @@ -0,0 +1,162 @@ +#pragma once + +#include +#include +#include +#include + +namespace mesh { +namespace hardwareCommand { + +constexpr size_t MaxNameLength = 63; + +struct Selector { + const char* name = nullptr; + const char* command = nullptr; + size_t length = 0; + uint32_t id = 0; + char role = 0; + bool compact = false; + bool any = false; +}; + +enum class Parse { None, Valid, Invalid }; + +inline char canonical(char c) { + if (c >= 'A' && c <= 'Z') return char(c + ('a' - 'A')); + return c == ' ' ? '_' : c; +} + +inline int hex(char c) { + c = canonical(c); + return c >= '0' && c <= '9' ? c - '0' + : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1; +} + +inline bool nameCharacter(char c) { + c = canonical(c); + return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') + || c == '_' || c == '-' || c == '.'; +} + +inline char roleCode(const char* role) { + if (!role) return 0; + return !strcmp(role, "repeater") ? 'r' : !strcmp(role, "room_server") ? 's' + : !strcmp(role, "companion") ? 'c' : !strcmp(role, "sensor") ? 'n' : 0; +} + +inline const char* hardware(const char* fallback) { +#ifdef MOTA_HW_ID + (void)fallback; + return MOTA_HW_ID; +#else + return fallback; +#endif +} + +// FNV-1a is a compact selector, not an authorization or image compatibility check. +inline uint32_t identifier(const char* name) { + uint32_t hash = 2166136261UL; + if (name) while (*name) hash = (hash ^ uint8_t(canonical(*name++))) * 16777619UL; + return hash; +} + +inline Parse parse(const char* text, Selector& out) { + out = Selector{}; + if (!text || *text != '@') return Parse::None; + const char* p = text + 1; + out.name = p; + while (*p && *p != '/' && *p != ' ' && *p != '\t') { + if (size_t(p - out.name) >= MaxNameLength) return Parse::Invalid; + ++p; + } + out.length = size_t(p - out.name); + if (!out.length) return Parse::Invalid; + out.any = out.length == 1 && *out.name == '*'; + out.compact = *out.name == '#'; + if (out.compact) { + if (out.length != 9) return Parse::Invalid; + for (size_t i = 1; i < out.length; ++i) { + const int digit = hex(out.name[i]); + if (digit < 0) return Parse::Invalid; + out.id = (out.id << 4) | uint32_t(digit); + } + } else if (!out.any) { + for (size_t i = 0; i < out.length; ++i) + if (!nameCharacter(out.name[i])) return Parse::Invalid; + } + if (*p == '/') { + out.role = canonical(*++p); + if (out.role != 'r' && out.role != 's' && out.role != 'c' && out.role != 'n') + return Parse::Invalid; + ++p; + } + if (*p != ' ' && *p != '\t') return Parse::Invalid; + do { ++p; } while (*p == ' ' || *p == '\t'); + if (!*p || *p == '@') return Parse::Invalid; + out.command = p; + return Parse::Valid; +} + +inline bool matches(const Selector& selector, const char* name, const char* role) { + if (selector.role && selector.role != roleCode(role)) return false; + if (selector.any) return true; + if (!name || !*name) return false; + if (selector.compact) return selector.id == identifier(name); + size_t i = 0; + while (i < selector.length && name[i] + && canonical(selector.name[i]) == canonical(name[i])) ++i; + return i == selector.length && name[i] == 0; +} + +inline bool word(const char* text, const char* expected) { + while (*expected && canonical(*text) == *expected) { ++text; ++expected; } + return !*expected && (!*text || *text == ' ' || *text == '\t'); +} + +// Return true when the guard consumed the request (skip/error); otherwise the +// caller dispatches the unchanged command or matching guard's inner command. +inline bool apply(const char*& command, const char* name, const char* role, + char* reply, size_t capacity) { + Selector selector; + const Parse parsed = parse(command, selector); + if (parsed == Parse::None) return false; + if (parsed == Parse::Invalid) { + snprintf(reply, capacity, "Err - use @board[/r|s|c|n] command"); + return true; + } + if (!matches(selector, name, role)) { + snprintf(reply, capacity, "OK - skipped (hardware/role)"); + return true; + } + // These commands have wire-text-specific recovery, secret-erasure or + // interactive-input handling outside the ordinary CLI dispatcher. + const char* inner = selector.command; + const char* region_action = inner; + if (word(inner, "region")) { + region_action += 6; + while (*region_action == ' ' || *region_action == '\t') ++region_action; + } + if (word(inner, "backup") || word(inner, "password") || word(inner, "replay") + || (word(inner, "region") && word(region_action, "load"))) { + snprintf(reply, capacity, "Err - run this command without a hardware guard"); + return true; + } + command = selector.command; + return false; +} + +inline void format(const char* name, const char* role, char* reply, size_t capacity) { + char display[MaxNameLength + 1]; + size_t i = 0; + if (name) while (name[i] && i < MaxNameLength) { + display[i] = canonical(name[i]); + ++i; + } + display[i] = 0; + snprintf(reply, capacity, "> hw=%s role=%s id=%08lX", i ? display : "unknown", + role ? role : "unknown", (unsigned long)identifier(name)); +} + +} // namespace hardwareCommand +} // namespace mesh diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json index befc10b13..3f0662e39 100644 --- a/test/fixtures/cli_reply_text.json +++ b/test/fixtures/cli_reply_text.json @@ -1481,15 +1481,15 @@ } ], "noncompact_tokens_sha256": { - "src/helpers/CommonCLI.cpp": "9c9466eb48af7b8d606d21c553ebbf89a49345bb0c43279ecfeee041f0211c00", - "examples/simple_repeater/MyMesh.cpp": "1980c36e1474e7738385cf6ed237b019b238ea3e71eac7a7273b62bbb2302899", + "src/helpers/CommonCLI.cpp": "e060c15118b94db76a41391b1fee872759ddcf308576b60eb1ae6ddec9444b8a", + "examples/simple_repeater/MyMesh.cpp": "bf614d2a4c672d33cbfbf2d4338f522c21e877abcfa7566f0e39a9fb6eecbbd9", "src/helpers/RadioProfileCLI.cpp": "8e3d293cb522717b13901c27d7fc471f5c370710919fb3d74d35449bc497552d", "src/helpers/WirelessControl.h": "0944bfdefb7003b47af52104b4c7447d7e5e65d54b3396790878c5b49e472913", "src/helpers/PowerSavingDiagnostics.h": "18b78f5780046ea8c772c4ce6c337741ae0c027039d9399c94c5f4205d114000", "src/helpers/FloodAdvertCLI.h": "0f378e4d0f6c3b9b79991068a4b4208139484d2144ee1d7c5094d39043463be4", "src/helpers/LedSettings.cpp": "df0681234ca1337148ad59caa935857bc692f98bf18603ca6d099c0382028400", "src/helpers/StorageLayout.cpp": "5dd1c8bcbe2902e1f22d14f9ab425465bdc38926fae23989987d850b484cbb18", - "examples/companion_radio/MyMesh.cpp": "197dd2074a9186151cc0d17f3b763ff6934c05d35b0e90a50ae39b3a5ca5083c", + "examples/companion_radio/MyMesh.cpp": "ed67a09f5d07badc61b8182652207bda627a4fbee4c98209742613f7bd12df6a", "src/helpers/CommonRadioPrefs.cpp": "16b1fb499426abde83566b6481df89b80302c72e00f0f4aa3843b6c9b9de4218", "examples/companion_radio/CompanionRetry.h": "e0ae10c7b2f778346f4645c4e147d87e1fbc7f8b99484752a8eed914bb21380f", "examples/companion_radio/Notifications.cpp": "173fb0d452fdd383585dd88ab35fbb079a90e068adca2db9bc44d604557d3873", diff --git a/test/fixtures/companion_fleet_dispatch.h b/test/fixtures/companion_fleet_dispatch.h index 519f0b9b5..6bedeb349 100644 --- a/test/fixtures/companion_fleet_dispatch.h +++ b/test/fixtures/companion_fleet_dispatch.h @@ -7,6 +7,7 @@ #include #include #include "regulator_cli_stub.h" +#include // These narrower dispatch fixtures retain the complete production fleet branch // but have no channel peripheral. The separate companion fleet suite compiles diff --git a/test/test_cli_settings_contract.py b/test/test_cli_settings_contract.py index 9bac69c6f..a74acc8a4 100644 --- a/test/test_cli_settings_contract.py +++ b/test/test_cli_settings_contract.py @@ -53,6 +53,7 @@ COMMON_QUERY_EXCEPTIONS = { 'rs232.running': 'Live UART bridge state; set rs232.enabled controls intent', 'rs232.status': 'Live heartbeat, negotiated standby and UART owner diagnostics', 'bridge.type': 'Build-selected bridge transport', + 'hardware': 'Build-selected hardware identity and firmware role', 'password': 'Written by the password command, not set password', 'public.key': 'Derived from identity; written through set prv.key', 'pwrmgt.bootmv': 'Boot-time voltage measurement', @@ -269,6 +270,7 @@ namespace mesh { struct Utils { 'wifi.tcp', # Read-only app listener/traffic diagnostic 'dm.held', # Read-only pending-DM count 'flash.health', # Read-only flash health diagnostics + 'hardware', # Build-selected hardware identity and role 'key.backup.transport', # Identity-backup transport capability 'mqtt', 'mqtt.running', # MQTT connection status 'ota.direct', # Direct USB OTA session diagnostics diff --git a/test/test_companion_fleet_control.py b/test/test_companion_fleet_control.py index e83a02fea..a038c280d 100644 --- a/test/test_companion_fleet_control.py +++ b/test/test_companion_fleet_control.py @@ -27,6 +27,7 @@ HARNESS = r''' #include #include #include +#include #include #include #include "regulator_cli_stub.h" @@ -43,6 +44,7 @@ struct RescueStream : Stream { int peek() override { ++peeks; return available() ? uint8_t(input[position]) : -1; } size_t write(uint8_t value) override { output += char(value); return 1; } }; +struct HardwareBoard { const char* getManufacturerName() const { return "test_board"; } } board; static RescueStream rescue_input, rescue_output; namespace mesh { @@ -1279,6 +1281,20 @@ int main(int argc, char** argv) { "clock sync extra", "time", "time -1", "time +1800000000", "time NaN", "time 4294967296", "time 1735689599", "time 1800000000 extra"}) rejected(node, ("fleet send 1 all " + std::string(clock_command)).c_str()); + } else if(scenario=="hardware_guard") { + for(const char* guarded:{"@Heltec_tower_v2/r ota pull A1B2C3D4 flash", + "@#536D28C9/s get hardware","@*/r get radio2"}) { + const std::string cli=std::string("fleet send 1 ")+guarded; + rejected(node,cli.c_str(),1); + assert(node.handleCommand(cli.c_str(),0,reply)); + compactBroadcast(node,guarded); + ++node.clock.now; + } + for(const char* unsafe:{"@*/r ota pull A1B2C3D4 flash", + "@Heltec_tower_v2/c ota pull A1B2C3D4 flash", + "@Heltec_tower_v2/r ota install A1B2C3D4", + "@Heltec_tower_v2/r @other/s get radio2"}) + rejected(node,(std::string("fleet send 1 ")+unsafe).c_str()); } else assert(false); printf("Companion fleet scenario %s passed\n", argv[1]); } @@ -1482,6 +1498,9 @@ class CompanionFleetControlTests(unittest.TestCase): def test_invalid_syntax_targets_and_forbidden_commands(self): self.scenario("syntax") + def test_hardware_guards_remain_signed_with_local_only_sender_authority(self): + self.scenario("hardware_guard") + def test_private_128_bit_key_required_even_if_channel_is_renamed(self): self.scenario("private_channel") diff --git a/test/test_companion_primary_radio_persistence.py b/test/test_companion_primary_radio_persistence.py index 5568c91d6..7987ac79f 100644 --- a/test/test_companion_primary_radio_persistence.py +++ b/test/test_companion_primary_radio_persistence.py @@ -71,7 +71,7 @@ struct Radio : mesh::Radio { bool isInRecvMode() const override { return true; } }; struct Sensors { double node_lat=0,node_lon=0; } sensors; -struct BatteryBoard {} board; +struct BatteryBoard { const char* getManufacturerName() const {return "test_board";} } board; // Morse dispatch is independently executable in its own shared-handler suite; // this legacy fixture keeps that optional branch inactive for tuple rollback. namespace mesh { diff --git a/test/test_fleet_channel_runtime.py b/test/test_fleet_channel_runtime.py index 0cf65a564..e3097d374 100644 --- a/test/test_fleet_channel_runtime.py +++ b/test/test_fleet_channel_runtime.py @@ -280,6 +280,7 @@ class RadioProfileCLI {public: ''' HARNESS = r''' +#include #include #include #include @@ -961,8 +962,39 @@ static int ackDelayScenario(){ jitter_pick_boundary=0;puts("fleet runtime ack_delay_ceiling passed");return 0; } +static int hardwareScenario(){ + struct Case{const char* board;const char* role;bool download;}; + for(const auto& c:{Case{"Heltec_tower_v2","repeater",true}, + Case{"Heltec_tower_v2","room_server",false}, + Case{"Heltec_v3","repeater",false}, + Case{"Heltec_tower_v2","companion",false}}){ + fs::FS disk;Mesh mesh;RadioProfileCLI profiles;FleetChannel fleet(&disk);LocalIdentity publisher; + enroll(fleet,publisher);bind(fleet,mesh,profiles); + const uint32_t seq=mesh.clock.now; + Packet packet=targetsCommand(publisher,seq,"all","@Heltec_tower_v2/r ota pull A1B2C3D4 flash"); + unsigned dispatches=0,downloads=0;char response[160]={}; + auto handler=[&](uint32_t sequence,const char* command,char* reply){ + ++dispatches;assert(storage::readLE32(disk.files.at(Path).data()+56)==sequence); + if(!mesh::hardwareCommand::apply(command,c.board,c.role,reply,157)){ + assert(!strcmp(command,"ota pull A1B2C3D4 flash"));++downloads;strcpy(reply,"OK - pull started"); + } + strcpy(response,reply); + }; + fleet.receive(&packet,mesh);fleet.service(mesh,profiles,"node",handler); + assert(dispatches==1&&downloads==unsigned(c.download)&&mesh.queued==1); + assert(!strcmp(response,c.download?"OK - pull started":"OK - skipped (hardware/role)")); + fake_ms+=1000;fleet.receive(&packet,mesh);fleet.service(mesh,profiles,"node",handler); + assert(dispatches==1&&downloads==unsigned(c.download)); + FleetChannel rebooted(&disk);fake_ms+=1000; + rebooted.receive(&packet,mesh);rebooted.service(mesh,profiles,"node",handler); + assert(dispatches==1&&downloads==unsigned(c.download)); + } + return 0; +} + int main(int argc,char** argv){ assert(argc==2);const std::string scenario=argv[1]; + if(scenario=="hardware_guard")return hardwareScenario(); if(scenario=="ack_delay_ceiling")return ackDelayScenario(); if(scenario.rfind("gps_",0)==0)return gpsScenario(scenario); if(scenario.rfind("regions_",0)==0)return regionScenario(scenario); @@ -1494,6 +1526,9 @@ class SHA256 : public SHA256Base {{ public: def test_gps_center_radius_targets_inside_seattle_and_excludes_portland(self): self.scenario("gps_inside_outside") + def test_hardware_guard_stages_only_matching_role_and_never_replays_after_reboot(self): + self.scenario("hardware_guard") + def test_gps_unknown_invalid_out_of_range_and_absent_location_providers_fail_closed(self): self.scenario("gps_unknown_and_bad_fixes") diff --git a/test/test_fleet_command_crypto.py b/test/test_fleet_command_crypto.py index 4503cfe0f..cfe3ce407 100644 --- a/test/test_fleet_command_crypto.py +++ b/test/test_fleet_command_crypto.py @@ -377,6 +377,45 @@ class FleetCommandCryptoTests(unittest.TestCase): with self.subTest(command=command): self.assertEqual(self.decode(envelope(command)), "reject") + def test_hardware_guard_and_role_remain_inside_the_signed_command(self): + for command in ("@Heltec_tower_v2/r get radio2", "@#536D28C9/s get hardware", + "@*/r clock", "@Heltec_tower_v2/r ota pull A1B2C3D4 flash", + "ota status", "ota stats", "ota self"): + with self.subTest(command=command): + expected = envelope2(command=command) + encoded = self.run_tool("encode2", CHANNEL.hex(), "all", NOW, NOW + 120, command) + self.assertEqual(encoded, expected.hex()) + self.assertEqual(self.decode(expected), f"{NOW}\n{NOW + 120}\n{command}") + self.assertLessEqual(len(expected), 165) + original = envelope2(command="@Heltec_tower_v2/r ota pull A1B2C3D4 flash") + for command in ("@Heltec_tower_v2/s ota pull A1B2C3D4 flash", + "@Heltec_tower_v3/r ota pull A1B2C3D4 flash", + "@Heltec_tower_v2/r ota pull A1B2C3D5 flash"): + forged = envelope2(command=command)[:-64] + original[-64:] + self.assertEqual(self.decode(forged), "reject") + + def test_fleet_pull_requires_specific_hardware_role_and_exact_manifest_id(self): + forbidden = ( + "ota pull A1B2C3D4 flash", "@*/r ota pull A1B2C3D4 flash", + "@Heltec_tower_v2 ota pull A1B2C3D4 flash", + "@Heltec_tower_v2/c ota pull A1B2C3D4 flash", + "@Heltec_tower_v2/n ota pull A1B2C3D4 flash", + "@Heltec_tower_v2/r ota pull 1 flash", + "@Heltec_tower_v2/r ota pull A1B2C3D4", + "@Heltec_tower_v2/r ota pull A1B2C3D4 folder", + "@Heltec_tower_v2/r ota pull A1B2C3D4 flash rescue", + "@Heltec_tower_v2/r ota pull A1B2C3DZ flash", + "@Heltec_tower_v2/r ota pull A1B2C3D4 flash extra", + "@Heltec_tower_v2/r ota install A1B2C3D4", + "@Heltec_tower_v2/r reboot", "@Heltec_tower_v2/r get prv.key", + "@Heltec_tower_v2/r @*/s get radio2", "@Heltec_tower_v2/r get radio2;reboot", + ) + for command in forbidden: + with self.subTest(command=command): + self.assertEqual(self.decode(envelope2(command=command)), "reject") + self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), "all", NOW, + NOW + 120, command), "reject") + def test_filter_shorthand_uses_the_same_signed_management_envelope(self): for command in ["get fr", "get fr.2", "get fr.2 n", "get flood.rule.2 text", "set fr.2 any c=public q=10 s", diff --git a/test/test_hardware_commands.py b/test/test_hardware_commands.py new file mode 100644 index 000000000..e6015fdb2 --- /dev/null +++ b/test/test_hardware_commands.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Exercise hardware guards, bounded replies and production role dispatch hooks.""" + +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +from cpp_source import body + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +using namespace mesh::hardwareCommand; + +static void check(const char* text, const char* board, const char* role, + const char* forwarded, const char* response) { + const char* command = text; + char reply[164]; memset(reply, 0x5a, sizeof(reply)); + const bool consumed = apply(command, board, role, reply, 157); + if (forwarded) { + assert(!consumed && !strcmp(command, forwarded)); + assert(reply[0] == 0x5a); + } else { + assert(consumed && !strcmp(reply, response)); + } + for (size_t i=157; i hw=heltec_tower_v2 role=repeater id=536D28C9")); + format(nullptr,nullptr,reply,sizeof(reply)); + assert(strstr(reply,"hw=unknown role=unknown")); + } else if(scenario=="role_dispatch") { + char command[160],reply[160]={};ClientInfo admin;admin.admin=true; + strcpy(command,"@Heltec_tower_v2/r ota pull A1B2C3D4 flash"); + repeater(&admin,command,reply);assert(!strcmp(reply,"ota pull A1B2C3D4 flash")); + ClientInfo manager;manager.filter=true; + strcpy(command,"@Heltec_tower_v2/r set flood.max 4"); + repeater(&manager,command,reply);assert(!strcmp(reply,"set flood.max 4")); + strcpy(command,"@Heltec_tower_v2/r ota pull A1B2C3D4 flash"); + repeater(&manager,command,reply);assert(!strcmp(reply,"Err - not permitted")); + strcpy(command,"@Heltec_tower_v2/r get radio2"); + room(command,reply);assert(!strcmp(reply,"OK - skipped (hardware/role)")); + strcpy(command,"@Heltec_tower_v2/s get radio2"); + room(command,reply);assert(!strcmp(reply,"get radio2")); + assert(companion("@Heltec_tower_v2/r ota pull A1B2C3D4 flash",reply)); + assert(!strcmp(reply,"OK - skipped (hardware/role)")); + assert(companion("@Heltec_tower_v2/c get hardware",reply)); + assert(!strcmp(reply,"> hw=heltec_tower_v2 role=companion id=536D28C9")); + strcpy(command,"@Heltec_tower_v2/r reboot"); + sensor(command,reply);assert(!strcmp(reply,"OK - skipped (hardware/role)")); + strcpy(command,"@Heltec_tower_v2/n get hardware"); + sensor(command,reply);assert(!strcmp(reply,"get hardware")); + } else return 2; +} +''' + + +class HardwareCommandTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = shutil.which("g++") or shutil.which("clang++") + if compiler is None: + raise RuntimeError("A host C++ compiler is required") + cls.work = tempfile.TemporaryDirectory(prefix="meshcore-hardware-command-") + cls.addClassCleanup(cls.work.cleanup) + repeater = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text() + room = (ROOT / "examples/simple_room_server/MyMesh.cpp").read_text() + companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + sensor = (ROOT / "examples/simple_sensor/SensorMesh.cpp").read_text() + helper_signatures = ( + "static bool commandFamilyMatches(", "static bool commandListMatches(", + "static bool isCommonManagerReadOnlyAllowed(", "static bool isRegionMgrAllowed(", + "static bool isFilterMgrAllowed(", + ) + helpers = '\n'.join(body(repeater, sig) for sig in helper_signatures) + handler = body(repeater, "void MyMesh::handleCommand(uint32_t sender_timestamp,") + auth_start = handler.rindex("if (sender && !sender->isAdmin())", 0, + handler.index("mesh::cli::handleACLGet(")) + auth = body(handler[auth_start:], "if (sender && !sender->isAdmin())") + fixture = HARNESS.replace("@AUTHORIZATION_HELPERS@", helpers) + fixture = fixture.replace("@AUTHORIZATION_GUARD@", auth) + for source, label, role in ((repeater, "REPEATER", "repeater"), + (room, "ROOM", "room_server"), + (sensor, "SENSOR", "sensor")): + start = source.index(" const char* hardware_command = command;") + end = source.index("#endif", start) + snippet = source[start:end].replace("FIRMWARE_ROLE", '"' + role + '"') + fixture = fixture.replace("@" + label + "_GUARD@", snippet) + start = companion.index(" if (mesh::hardwareCommand::apply(command,") + end = companion.index("#endif", start) + fixture = fixture.replace("@COMPANION_GUARD@", companion[start:end]) + fixture = '#include \n' + fixture + work = Path(cls.work.name) + source = work / "fixture.cpp" + source.write_text(fixture) + cls.binary = work / "hardware-command" + sanitizers = (["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie"] + if os.environ.get("MESHCORE_FLEET_SANITIZERS") == "1" else []) + built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-DMESH_ENABLE_FLOOD_RULE_ENGINE=1", *sanitizers, + "-I", str(ROOT / "src"), str(source), "-o", str(cls.binary)], + capture_output=True, text=True, timeout=60) + if built.returncode: + raise AssertionError(built.stdout + built.stderr) + + def test_guard_matching_and_compact_identity(self): + self.scenario("matching") + + def test_wrong_board_and_wrong_role_skip_without_dispatch(self): + self.scenario("skips") + + def test_malformed_nested_and_overlong_guards_fail_closed(self): + self.scenario("malformed") + + def test_sensitive_and_recovery_commands_keep_their_original_handlers(self): + self.scenario("special_commands") + + def test_machine_readable_hardware_identity(self): + self.scenario("identity") + + def test_production_role_hooks_preserve_manager_permissions(self): + self.scenario("role_dispatch") + + def scenario(self, name): + result = subprocess.run([str(self.binary), name], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_local_cli_access.py b/test/test_local_cli_access.py index 90408114f..50855b8d3 100644 --- a/test/test_local_cli_access.py +++ b/test/test_local_cli_access.py @@ -115,6 +115,7 @@ struct Prefs { void clearDirty(){} }; struct Board { + const char* getManufacturerName() const {return "test_board";} int power_offs=0; uint16_t charge_target=4200; unsigned charge_writes=0;