diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml
index 81270ac91..fc10faf0f 100644
--- a/.github/workflows/run-unit-tests.yml
+++ b/.github/workflows/run-unit-tests.yml
@@ -101,6 +101,13 @@ jobs:
- name: Run Unit Tests
run: pio test -e native -e native_kiss_modem -e native_ota_channel -vv
+ - name: Verify signed private fleet management
+ run: |
+ 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
+ python3 -B test/test_fleet_role_wiring.py -v
+
- name: Verify OTA channel persistence and published image tags
run: |
python3 -B test/test_ota_channel_persistence.py -v
diff --git a/README.md b/README.md
index 433de4370..b26169e1c 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,7 @@
* **[Two LoRa profiles on one device](https://mikecarper.github.io/MeshCore/radio_profiles/).** Time-share one transceiver between two frequencies and/or modulation settings. Choose transmission profiles per contact or channel, enable or isolate crossover traffic, schedule profiles, and use a temporary update profile alongside the normal network. Includes timing calibration and separate R2 screen pages.
* **[Firmware updates over LoRa](https://mikecarper.github.io/MeshCore/lora_ota_automation/).** Update repeaters, room servers, and sensors over LoRa on supported firmware and storage layouts.
* **[Repeater forwarding policies](https://mikecarper.github.io/MeshCore/flood_filtering/).** Actions include dropping, rate limiting, assigning scopes, selecting retries, and controlling forwarding priority. Separate policies cover ordinary radio forwarding, bridges, and profile crossover.
+* **[Private fleet management](https://mikecarper.github.io/MeshCore/fleet_management/).** Deploy filtering and secondary-radio commands to a whole fleet or one node. The publisher signs commands; receivers hold its public verification key, with replay protection and acknowledgements before radio handoff.
* **[Flood retries that account for network topology](https://mikecarper.github.io/MeshCore/halo_keymind_settings/#flood-and-advert-settings).** Configure retry counts, path limits, packet-type limits, target prefixes, ignored repeaters, and channel-specific eligibility. Bridge buckets keep retrying until traffic is heard on the intended sides of a relay or the retry budget expires. Qualifying echoes cancel unnecessary attempts.
* **[Enhanced direct messages and direct-route retries](https://mikecarper.github.io/MeshCore/halo_keymind_settings/#direct-retry-settings).** Adapt the coding rate across bounded retry attempts and cancel unnecessary retries when a qualifying echo is heard. [Single Key DMs](https://mikecarper.github.io/MeshCore/companion_protocol/#private-text-when-only-the-sender-knows-the-recipient) let compatible Companions send private text when only the sender knows the recipient's key, using a signed, encrypted introduction. The recipient accepts the sender through the app or opts into automatic acceptance; compact STM32WL builds omit this feature.
* **[Enhanced telemetry history](https://mikecarper.github.io/MeshCore/telemetry_decoder/).** Send compact temperature and battery-voltage snapshots of up to 165 samples per packet, plus external-voltage history in chunks. Read retained GPS history through the CLI; GPS history is not included in the automatic telemetry packets. Decode snapshots and CLI history in the browser.
diff --git a/docs/fleet_management.md b/docs/fleet_management.md
new file mode 100644
index 000000000..618b3d905
--- /dev/null
+++ b/docs/fleet_management.md
@@ -0,0 +1,179 @@
+---
+layout: default
+title: Private fleet management
+---
+
+
View this page on MeshCore Docs.
+
+
+
+# Private fleet management
+
+Fleet control lets one publisher send filtering and secondary-radio commands to
+all enrolled infrastructure nodes, or to one node, over a private LoRa channel.
+It is disabled until configured. It is supported on repeaters and room servers;
+fixed-size STM32 images omit it because of their flash limit.
+Both the publishing Companion and receivers need firmware built with fleet-control
+support; the existing app can use its local Command Line screen.
+
+## Read-only receivers
+
+Commands use two separate credentials:
+
+- A random **128-bit channel key** encrypts the traffic. Receivers need this key
+ to read the private channel.
+- The publisher's **Ed25519 public key** verifies commands. Only the publisher
+ holds the corresponding private key and can authorize changes.
+
+Giving a repeater the channel key and publisher public key does **not** give it
+the publisher's signing key. An ordinary channel message, a copied display name,
+or possession of the channel key cannot authorize a fleet command. Each command
+signature also binds the channel, target, sequence, expiry, and exact command.
+
+This makes management commands read-only for receivers. It does not turn ordinary
+MeshCore chat into a signed, read-only announcement channel: stock apps do not
+verify signatures on ordinary channel text. Fleet acknowledgements are convenient
+channel text, not authenticated inventory evidence. Use existing individually
+authenticated administration for sensitive confirmation.
+
+## Enroll nodes
+
+Use an existing authenticated admin connection or the local console. Generate a
+random private channel key; for example, `openssl rand -hex 16`. Do not use Public
+or a predictable hashtag key. Add that same key as a private channel on the
+publishing Companion, and note its channel index.
+
+Get the publishing Companion's full public identity key from its device information
+or existing identity command. On every managed repeater or room server, run:
+
+```text
+set fleet.controller
+set fleet.channel
+get fleet.channel
+get fleet.controller
+get fleet.stats
+```
+
+`get fleet.channel` reports enrollment, storage health, a channel hash, and the
+last accepted sequence. It does not reveal the channel key. `set fleet.channel
+off` disables reception; `set fleet.controller off` revokes the publisher. Key
+changes use the existing individually authenticated admin path, never fleet
+commands.
+
+Nodes need a clock set to current UTC. Commands expire after ten minutes and may
+be at most one minute ahead of a receiver's clock. A bad clock fails closed;
+correct it through the normal administration path.
+
+## Send commands from the app
+
+On the publishing Companion, use the existing local Command Line screen. The
+Companion signs with its own identity. No publisher private key is copied to a
+repeater, and no app protocol change is required.
+
+Examples use private channel index `3`:
+
+```text
+fleet send 3 all get radio2
+fleet send 3 all set tempradio2 910.5,500,8,5,rxtx,120
+fleet send 3 all set radio2.cross auto
+fleet send 3 all set flood.rule.4 type=grp_txt hops=6+ drop
+fleet send 3 all del flood.rule.4
+```
+
+To address one node, replace `all` with that node's complete 64-character public
+key. Names and short path IDs are not target identities. On air, the target is a
+128-bit SHA-256 digest of the full key. Separate fleets use separate channel keys;
+named subgroups are not part of this first version.
+
+Only local commands on the publishing Companion can originate fleet sends. A
+remote CLI command cannot turn that Companion into a multicast signing proxy.
+An `OK` means the Companion queued the command, not that every receiver applied
+it. Wait for acknowledgements and confirm the result on important nodes.
+
+Use at most one new command per second. The Unix-second sequence is reserved in
+durable storage **before** dispatch. The same sequence is never executed twice,
+including after reboot or a lost reply. Retry an uncertain command with a fresh
+sequence; the operation should be chosen to be idempotent.
+
+## Supported controls
+
+The command body is limited to **72 ASCII bytes**, independently of the target's
+full key in the local send command. Longer commands are rejected, not truncated.
+
+| Control | Fleet commands |
+| --- | --- |
+| Saved second profile | `get radio2`, `set radio2 ` |
+| Temporary second profile | `get tempradio2`, `set tempradio2 ` |
+| Profile diagnostics | `get radio2.status`, `get radio2.timing` |
+| Crossover | `get radio2.cross`, `set radio2.cross ` |
+| Filter rules | `get/set/del flood.filter[.n]`, `flood.rule[.n]` |
+| Path blacklist | `get/set/del flood.filter.blacklist[.n]` |
+| Channel moderation | `get/set/del flood.moderation[.n]` |
+| Scope policy | `get/set/del flood.channel.scope[.n]`, `flood.channel.scope.require[.n]` |
+| Group-data forwarding | `get/set flood.channel.data`, `flood.channel.data.hops` |
+| Flood hop limits | `get/set flood.max`, `flood.max.unscoped`, `flood.max.advert` |
+
+Each role still checks whether it supports the requested setting. Primary radio
+changes, identity export, passwords, ACL edits, filesystem operations, firmware
+installation, reboot, and unrestricted CLI are outside fleet capability.
+
+Secondary-radio mutations keep the old profile until their exact acknowledgement
+packets drain, with at least one successful physical transmission. Queue failure,
+failed transmissions, or an acknowledgement timeout cancels the staged mutation.
+Temporary-radio leases retain their original monotonic end time; waiting for an
+acknowledgement does not lengthen them.
+
+Individual acknowledgements wait 0.5–1.5 seconds; whole-fleet replies are spread
+over 0.5–60.5 seconds (0.5–15.5 seconds for radio mutations, to preserve short
+temporary leases). This reduces simultaneous responses but is not a reliable
+delivery protocol for an arbitrarily large fleet. Use individual requests for
+confirmation and divide very large fleets into separately keyed channels.
+
+## Filtering and recovery
+
+Local fleet reception happens before a node's forwarding decision, so that node
+can still process an authenticated command when a filter or `repeat off` blocks
+forwarding. **Intermediate nodes can still block delivery to downstream nodes.**
+Management traffic has no automatic exemption from forwarding filters. Preserve
+an appropriate group-data/channel forwarding rule and an existing direct-admin
+recovery path before changing network-wide filters. The primary radio is retained
+by this capability, but a forwarding policy can still partition the fleet.
+
+The receiver is allocated lazily when fleet settings are present or queried, with
+one bounded mailbox. Cryptographic work and storage writes occur after the receive
+stack unwinds. Signature-validation
+attempts are bounded to protect the radio loop from matching-hash junk. A corrupt
+or unreadable fleet store denies commands rather than discarding its replay state.
+
+## Ideas for packet-abuse containment
+
+A packet-count chart can show a burst, but cannot establish intent, an authenticated
+source, or the airtime consumed. A large Trace spike deserves investigation;
+MeshCore already rejects flood-form Trace and Control packets. Valid direct Trace
+requests need different treatment from flood forwarding rules.
+
+Useful next additions, rather than features already implemented here:
+
+- **Temporary containment with automatic rollback:** deploy a drop/rate policy
+ for a bounded period and restore the previous policy unless confirmed.
+- **Direct-request rate limits:** separately bound Trace, anonymous requests,
+ discovery, login, and expensive response generation without breaking legitimate
+ management, ACKs, or ongoing OTA exchanges.
+- **Policy preview and revisions:** validate complete rule bundles, detect board
+ limitations, apply atomically, and report the active revision before rollout.
+- **Better evidence:** report per-type receive/drop counts, airtime, queue pressure,
+ invalid signatures, duplicate rate, and radio recoveries. Truncated path IDs
+ remain observations, not authenticated attacker identities.
+- **Authenticated inventory:** stagger signed status replies with firmware version,
+ uptime/reset reason, battery, memory/storage headroom, and radio errors.
+- **Maintenance windows:** coordinated secondary-profile experiments and OTA
+ readiness checks, keeping existing firmware-signing policy intact.
+- **Per-controller roles and revocation:** multiple public verification keys with
+ separate filter-only/radio permissions and independent replay state.
+
+Start with observation, then a narrow temporary rule, then verify neighboring
+nodes still communicate. Software filtering can reduce forwarding and response
+amplification; it cannot recover airtime already occupied by an RF transmitter.
+
+See [flood filtering](flood_filtering.md), [radio profiles](radio_profiles.md), and
+[ACL administration](cli_commands.md#acl).
diff --git a/docs/index.md b/docs/index.md
index 8142c8c7d..acc635ed5 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -16,6 +16,7 @@ Choose firmware with the [firmware picker](firmware_picker.md). For help with a
- [Feature switches by role](role_feature_switches.md) and [Full Companion features](full_companion_features.md)
- [Firmware update guide](ota_user_guide.md), [easy LoRa OTA](ota_easy.md), and [scripted LoRa OTA](lora_ota_automation.md)
- [CLI commands](cli_commands.md), [availability by build](cli_build_matrix.md), and [radio profiles](radio_profiles.md)
+- [Private fleet management](fleet_management.md): signed filtering and secondary-radio commands for a fleet or one node
- [Companion protocol](companion_protocol.md), [packet format](packet_format.md), and [QR codes](qr_codes.md)
The navigation groups the remaining guides under Documentation, including board-specific updates, networking, power, and protocol references.
diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp
index a29c35173..6c5717b42 100644
--- a/examples/companion_radio/MyMesh.cpp
+++ b/examples/companion_radio/MyMesh.cpp
@@ -4,6 +4,7 @@
#include "NotificationSettingsFile.h"
#include "MyMesh.h"
#include
+#include
#include "CompanionBluetooth.h"
#include "CompanionWireless.h"
#include "CompanionRetry.h"
@@ -9704,6 +9705,107 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
&& meshBleTraceCommand(command, reply, reply_capacity)) return true;
#endif
+#if MESH_ENABLE_FLEET_CONTROL
+ if (!strncmp(command, "fleet", 5)
+ && (command[5] == 0 || command[5] == ' ' || command[5] == '\t')) {
+ // Only the attached Companion client can originate multicast management.
+ // A remote CLI peer must not borrow this Companion's signing identity.
+ if (sender_timestamp != 0) {
+ snprintf(reply, reply_capacity, "Error: fleet send requires local Companion access");
+ return true;
+ }
+ const char* text = command + 5;
+ while (*text == ' ' || *text == '\t') ++text;
+ if (strncmp(text, "send", 4)
+ || (text[4] != ' ' && text[4] != '\t')) {
+ snprintf(reply, reply_capacity,
+ "Error: use fleet send ");
+ return true;
+ }
+ text += 4;
+ while (*text == ' ' || *text == '\t') ++text;
+ uint32_t channel_index = 0;
+ bool have_index = false;
+ while (*text >= '0' && *text <= '9') {
+ have_index = true;
+ const uint8_t digit = *text++ - '0';
+ if (channel_index > (0xFFFFFFFFUL - digit) / 10UL) {
+ snprintf(reply, reply_capacity, "Error: invalid fleet channel index");
+ return true;
+ }
+ channel_index = channel_index * 10UL + digit;
+ }
+ if (!have_index || channel_index >= MAX_GROUP_CHANNELS
+ || (*text != ' ' && *text != '\t')) {
+ snprintf(reply, reply_capacity, "Error: invalid fleet channel index");
+ return true;
+ }
+ while (*text == ' ' || *text == '\t') ++text;
+ // Reuse the wire buffer for the temporary target token. Encoding happens
+ // only after the target has been parsed into its separate binary buffer.
+ uint8_t payload[mesh::FleetCommand::MaxPayloadLength];
+ const size_t target_length = strcspn(text, " \t");
+ if (target_length > PUB_KEY_SIZE * 2U
+ || text[target_length] == 0) {
+ snprintf(reply, reply_capacity, "Error: fleet target must be all or a full public key");
+ return true;
+ }
+ memcpy(payload, text, target_length);
+ payload[target_length] = 0;
+ uint8_t target[mesh::FleetCommand::TargetSize];
+ if (!mesh::FleetCommand::parseTarget((const char*)payload, target)) {
+ snprintf(reply, reply_capacity, "Error: fleet target must be all or a full public key");
+ return true;
+ }
+ text += target_length;
+ while (*text == ' ' || *text == '\t') ++text;
+ if (!mesh::FleetCommand::commandAllowed(text)) {
+ snprintf(reply, reply_capacity, "Error: command is not permitted for fleet management");
+ return true;
+ }
+ ChannelDetails channel;
+ if (!getChannel((int)channel_index, channel) || !channel.name[0]) {
+ snprintf(reply, reply_capacity, "Error: fleet channel is not configured");
+ return true;
+ }
+ bool padded_key = true;
+ for (size_t i = mesh::FleetCommand::KeySize;
+ i < sizeof(channel.channel.secret); ++i)
+ padded_key = padded_key && channel.channel.secret[i] == 0;
+ if (!padded_key || !mesh::FleetCommand::privateKeyAllowed(channel.channel.secret)) {
+ snprintf(reply, reply_capacity, "Error: fleet requires a private 128-bit channel key");
+ return true;
+ }
+ const uint32_t sequence = getRTCClock()->getCurrentTime();
+ static uint32_t last_fleet_sequence = 0;
+ if (sequence < mesh::FleetCommand::MinEpoch
+ || sequence > 0xFFFFFFFFUL - mesh::FleetCommand::MaxLifetime) {
+ snprintf(reply, reply_capacity, "Error: set the Companion clock before fleet send");
+ return true;
+ }
+ if (sequence <= last_fleet_sequence) {
+ snprintf(reply, reply_capacity, "Error: wait for the next clock second before fleet send");
+ return true;
+ }
+ const size_t length = mesh::FleetCommand::encode(
+ self_id, channel.channel.secret, sequence,
+ sequence + mesh::FleetCommand::MaxLifetime, target, text,
+ payload, sizeof(payload));
+ if (!length) {
+ snprintf(reply, reply_capacity, "Error: fleet command is too long or invalid");
+ } else if (!sendGroupData(channel.channel, nullptr, OUT_PATH_UNKNOWN,
+ mesh::FleetCommand::DataType, payload, (int)length)) {
+ snprintf(reply, reply_capacity, "Error: could not queue fleet command");
+ } else {
+ last_fleet_sequence = sequence;
+ snprintf(reply, reply_capacity, "OK - fleet command queued; seq=%lu",
+ (unsigned long)sequence);
+ }
+ memset(payload, 0, sizeof(payload));
+ return true;
+ }
+
+#endif
if (_radio_profiles.handle(command, reply, reply_capacity, sender_timestamp != 0)) return true;
if (!strncmp(command, "set tempradio ", 14)) command += 4;
if (!strcmp(command, "get tempradio")) command += 4;
diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp
index e7e695a23..fb7bd297c 100644
--- a/examples/simple_repeater/MyMesh.cpp
+++ b/examples/simple_repeater/MyMesh.cpp
@@ -2816,6 +2816,13 @@ void MyMesh::onAdvertRecv(mesh::Packet *packet, const mesh::Identity &id, uint32
}
void MyMesh::onGroupPacketRecv(mesh::Packet* packet) {
+#if MESH_ENABLE_FLEET_CONTROL
+ if (_cli.fleetChannel()) {
+ TransportKey scope;
+ const bool known = mesh::captureFleetReplyScope(region_map, recv_pkt_region, packet, scope);
+ _cli.fleetChannel()->receive(packet, *this, known ? &scope : nullptr);
+ }
+#endif
#if !defined(PORTABLE_MQTT_OBSERVER) && MESH_ENABLE_CLOCK_SYNC
// The base Mesh calls this for every unseen, structurally valid group packet
// before the forwarding decision. Public-channel decryption below also
@@ -4156,6 +4163,9 @@ bool MyMesh::sendRepeatersFloodText(const char* text, const TransportKey* scope,
void MyMesh::onSendComplete(mesh::Packet* packet) {
mesh::Mesh::onSendComplete(packet);
+#if MESH_ENABLE_FLEET_CONTROL
+ if (_cli.fleetChannel()) _cli.fleetChannel()->complete(packet, _cli.radioProfiles());
+#endif
if (temp_radio_reply_barrier.complete(packet) && !temp_radio_reply_barrier.waiting()) finishRadioReply(true);
if (packet == pending_battery_alert_packet) {
pending_battery_alert_packet = NULL;
@@ -4166,12 +4176,18 @@ void MyMesh::onSendComplete(mesh::Packet* packet) {
void MyMesh::onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original,
uint8_t priority) {
+#if MESH_ENABLE_FLEET_CONTROL
+ if (_cli.fleetChannel()) _cli.fleetChannel()->copy(packet, original);
+#endif
temp_radio_reply_barrier.trackCopy(original, packet);
mesh::Mesh::onRadioProfileCopyQueued(packet, original, priority);
}
void MyMesh::onSendFail(mesh::Packet* packet) {
mesh::Mesh::onSendFail(packet);
+#if MESH_ENABLE_FLEET_CONTROL
+ if (_cli.fleetChannel()) _cli.fleetChannel()->fail(packet, _cli.radioProfiles());
+#endif
if (temp_radio_reply_barrier.fail(packet)) {
// Failure of every acknowledgement copy cancels the unconfirmed
// handoff. A later cached-command retry may replay the reply, but it does
@@ -12585,6 +12601,19 @@ void MyMesh::loop() {
_cli.loop();
serviceRadioReplyDeadline();
processDeferredCliCommand();
+#if MESH_ENABLE_FLEET_CONTROL
+ if (_cli.fleetChannel()) {
+ _cli.fleetChannel()->service(*this, _cli.radioProfiles(), _prefs.node_name,
+ [this](uint32_t sequence, char* command, char* reply) {
+ if (region_load_active) {
+ strcpy(reply, "Err - region load active; retry later"); return;
+ }
+ // Signature and a strict command allowlist are checked before this
+ // dispatch. Fleet capability never grants unrestricted admin CLI.
+ handleCommand(sequence, nullptr, command, reply);
+ });
+ }
+#endif
servicePostMeshLoop();
#if defined(ENABLE_OTA) && OTA_DYNAMIC_CONTEXT
mesh::ota::ota_service_temp_radio_context(isAnyTempRadioActive());
diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h
index 8e4a679de..b49659baf 100644
--- a/examples/simple_repeater/MyMesh.h
+++ b/examples/simple_repeater/MyMesh.h
@@ -1092,6 +1092,7 @@ public:
bool sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size);
// CommonCLICallbacks
+ bool supportsFleetControl() const override { return true; }
mesh::Radio* getProfileRadio() override { return _radio; }
void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins, uint16_t preamble = 0) override;
bool scheduleNormalRadio() override;
diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp
index 901fd1bb1..f5d63a662 100644
--- a/examples/simple_room_server/MyMesh.cpp
+++ b/examples/simple_room_server/MyMesh.cpp
@@ -1166,9 +1166,35 @@ void MyMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id,
}
void MyMesh::onGroupPacketRecv(mesh::Packet* packet) {
+#if MESH_ENABLE_FLEET_CONTROL
+ if (_cli.fleetChannel()) {
+ TransportKey scope;
+ const bool known = mesh::captureFleetReplyScope(region_map, recv_pkt_region, packet, scope);
+ _cli.fleetChannel()->receive(packet, *this, known ? &scope : nullptr);
+ }
+#endif
_clock_sync.observeGroupPacket(packet);
}
+#if MESH_ENABLE_FLEET_CONTROL
+void MyMesh::onSendComplete(mesh::Packet* packet) {
+ mesh::Mesh::onSendComplete(packet);
+ if (_cli.fleetChannel()) _cli.fleetChannel()->complete(packet, _cli.radioProfiles());
+}
+
+void MyMesh::onSendFail(mesh::Packet* packet) {
+ mesh::Mesh::onSendFail(packet);
+ if (_cli.fleetChannel()) _cli.fleetChannel()->fail(packet, _cli.radioProfiles());
+}
+
+void MyMesh::onRadioProfileCopyQueued(mesh::Packet* packet,
+ const mesh::Packet* original,
+ uint8_t priority) {
+ mesh::Mesh::onRadioProfileCopyQueued(packet, original, priority);
+ if (_cli.fleetChannel()) _cli.fleetChannel()->copy(packet, original);
+}
+#endif
+
#if defined(WITH_MQTT_NEIGHBORS)
#define CTL_TYPE_NODE_DISCOVER_REQ 0x80
@@ -2679,6 +2705,18 @@ void MyMesh::loop() {
servicePendingSerialOutput();
#endif
_cli.loop();
+#if MESH_ENABLE_FLEET_CONTROL
+ if (_cli.fleetChannel()) {
+ _cli.fleetChannel()->service(*this, _cli.radioProfiles(), _prefs.node_name,
+ [this](uint32_t sequence, char* command, char* reply) {
+ if (region_load_active) {
+ strcpy(reply, "Err - region load active; retry later");
+ return;
+ }
+ handleCommand(sequence, command, reply);
+ });
+ }
+#endif
_clock_sync.loop();
#if MESH_ENABLE_TELEMETRY_HISTORY
sampleTelemetryHistory();
diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h
index 032770f70..acf7e6d56 100644
--- a/examples/simple_room_server/MyMesh.h
+++ b/examples/simple_room_server/MyMesh.h
@@ -409,6 +409,12 @@ protected:
void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override;
void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override;
void onGroupPacketRecv(mesh::Packet* packet) override;
+#if MESH_ENABLE_FLEET_CONTROL
+ void onSendComplete(mesh::Packet* packet) override;
+ void onSendFail(mesh::Packet* packet) override;
+ void onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original,
+ uint8_t priority) override;
+#endif
#if defined(WITH_MQTT_NEIGHBORS)
void onControlDataRecv(mesh::Packet* packet) override;
#endif
@@ -431,6 +437,7 @@ protected:
#endif
public:
+ bool supportsFleetControl() const override { return true; }
MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables);
void begin(FILESYSTEM* fs);
diff --git a/mkdocs.yml b/mkdocs.yml
index a680d0418..d9d843355 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -74,6 +74,7 @@ nav:
- host_cli_service.md
- Radio, networking, and power:
- radio_profiles.md
+ - fleet_management.md
- carrier_wave.md
- espnow_bridge_setup.md
- WiFi.md
diff --git a/platformio.ini b/platformio.ini
index 5940276e6..902e115ea 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -478,6 +478,7 @@ build_src_filter =
+<../src/helpers/ArduinoSerialInterface.cpp>
+<../src/helpers/DynamicConfigSerializer.cpp>
+<../src/helpers/CommonRadioPrefs.cpp>
+ +<../src/helpers/FleetCommand.cpp>
+<../src/helpers/ui/MomentaryButton.cpp>
lib_deps =
google/googletest @ 1.17.0
diff --git a/src/Dispatcher.h b/src/Dispatcher.h
index 0229f08e0..801753275 100644
--- a/src/Dispatcher.h
+++ b/src/Dispatcher.h
@@ -296,6 +296,7 @@ typedef uint32_t DispatcherAction;
* and scheduling of outbound Packets.
*/
class Dispatcher {
+ friend class FleetChannel; // bounded deferred receiver and exact reply retirement
enum class OutboundCancellation : uint8_t { None, Cancelled, Delivered };
Packet* outbound; // current outbound packet
unsigned long outbound_expiry, outbound_start, total_air_time, rx_air_time;
diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp
index 314a3fbe7..15ccc717e 100644
--- a/src/helpers/CommonCLI.cpp
+++ b/src/helpers/CommonCLI.cpp
@@ -2856,6 +2856,9 @@ uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) {
void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) {
PrefsSaveReplyGuard save_reply(_prefs_save_failures, reply);
mesh::cli::normalizeCommandVerb(command);
+#if MESH_ENABLE_FLEET_CONTROL
+ if (handleFleetCommand(command, reply)) return;
+#endif
#if MESH_BATTERY_CHARGE_CONTROL
if (mesh::power::handleBatteryChargeCommand(*_board, command, reply, 160)) return;
#endif
diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h
index feedaa649..df4a0b830 100644
--- a/src/helpers/CommonCLI.h
+++ b/src/helpers/CommonCLI.h
@@ -15,6 +15,10 @@
#include
#include
#include
+#include
+#if MESH_ENABLE_FLEET_CONTROL
+#include
+#endif
#ifndef DEFAULT_CAD_ENABLED
#define DEFAULT_CAD_ENABLED 0
@@ -478,6 +482,7 @@ struct LegacyObserverTail {
class CommonCLICallbacks {
public:
+ virtual bool supportsFleetControl() const { return false; }
// True only after an actual NTP response this boot, not a plausible RTC.
virtual bool managementNtpSynced() const { return false; }
virtual mesh::Radio* getProfileRadio() { return nullptr; }
@@ -831,6 +836,10 @@ class CommonCLI {
bool _common_save_succeeded = false;
uint32_t _prefs_save_failures = 0;
mesh::RadioProfileCLI _radio_profiles;
+#if MESH_ENABLE_FLEET_CONTROL
+ mesh::FleetChannel* _fleet_channel = nullptr; // allocate only when enrolled
+ bool handleFleetCommand(const char* command, char* reply);
+#endif
mesh::RTCClock* getRTCClock() { return _rtc; }
void savePrefs(
@@ -887,6 +896,9 @@ public:
uint16_t preamble);
mesh::RadioProfileCLI& radioProfiles() { return _radio_profiles; }
const mesh::RadioProfileCLI& radioProfiles() const { return _radio_profiles; }
+#if MESH_ENABLE_FLEET_CONTROL
+ mesh::FleetChannel* fleetChannel() { return _fleet_channel; }
+#endif
static bool calculateRxPowerSavingLevel(uint32_t level, uint8_t sf, float bw, uint32_t preamble,
uint32_t* rx_us, uint32_t* sleep_us);
static bool recalculateRxPowerSavingFromLevel(NodePrefs* prefs);
diff --git a/src/helpers/CommonCLI_Management.cpp b/src/helpers/CommonCLI_Management.cpp
index 5110b2b8e..f081eb58f 100644
--- a/src/helpers/CommonCLI_Management.cpp
+++ b/src/helpers/CommonCLI_Management.cpp
@@ -245,6 +245,12 @@ static bool resolveDataScope(DataRouteState& route, TransportKey& scope,
void CommonCLI::beginManagement(mesh::Mesh& mesh, FILESYSTEM* fs) {
_management_mesh = &mesh; _management_fs = fs;
_management_last_ms = millis(); _management_uptime_ms = _management_last_ms;
+#if MESH_ENABLE_FLEET_CONTROL
+ if (_callbacks->supportsFleetControl()
+ && (fs->exists("/fleet_channel") || fs->exists("/fleet_channel.bak"))) {
+ _fleet_channel = new (std::nothrow) mesh::FleetChannel(fs);
+ }
+#endif
if (!_data_route) {
mesh::hilStartupTrace("management_route_begin");
_data_route = new (std::nothrow) mesh::DataRouteState;
@@ -265,6 +271,25 @@ void CommonCLI::beginManagement(mesh::Mesh& mesh, FILESYSTEM* fs) {
mesh::hilStartupTrace("management_probe_ready");
}
+#if MESH_ENABLE_FLEET_CONTROL
+bool CommonCLI::handleFleetCommand(const char* command, char* reply) {
+ const bool fleet_command = !strncmp(command, "get fleet.", 10)
+ || !strncmp(command, "set fleet.", 10);
+ if (!fleet_command) return false;
+ if (!_callbacks->supportsFleetControl()) {
+ strcpy(reply, "Err - fleet control is available on infrastructure roles"); return true;
+ }
+ if (!_fleet_channel) _fleet_channel = new (std::nothrow) mesh::FleetChannel(_management_fs);
+ if (!_fleet_channel) {
+ strcpy(reply, "Err - fleet control memory unavailable"); return true;
+ }
+ if (!_fleet_channel->handleConfig(command, reply, 160)) {
+ strcpy(reply, "Err - use fleet.channel, fleet.controller, or fleet.stats");
+ }
+ return true;
+}
+#endif
+
void CommonCLI::loopManagement() {
const uint32_t now = millis();
_management_uptime_ms += uint32_t(now - _management_last_ms); _management_last_ms = now;
diff --git a/src/helpers/FleetChannel.cpp b/src/helpers/FleetChannel.cpp
new file mode 100644
index 000000000..25a3d636a
--- /dev/null
+++ b/src/helpers/FleetChannel.cpp
@@ -0,0 +1,237 @@
+#include "FleetChannel.h"
+#include "FilePresence.h"
+#include "FileRead.h"
+#include "PersistentStoreFormat.h"
+#include
+#include
+#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
+#include "AtomicFileWriter.h"
+#else
+#include "ContactFileTransaction.h"
+#endif
+
+namespace mesh {
+namespace {
+constexpr char Path[] = "/fleet_channel";
+constexpr size_t ImageSize = 64;
+bool zero(const uint8_t* p, size_t length) {
+ uint8_t value = 0;
+ while (length--) value |= *p++;
+ return value == 0;
+}
+}
+
+bool FleetChannel::load() {
+ if (!fs_) return false;
+#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
+ if (!ContactFileTransaction::recover(fs_, Path, filePresence)) return false;
+#endif
+ bool present = false;
+ if (!filePresence(fs_, Path, present)) return false;
+ if (!present) return true;
+ auto file = openFileRead(fs_, Path);
+ uint8_t image[ImageSize];
+ const bool read = file && file.size() == ImageSize
+ && file.read(image, ImageSize) == (int)ImageSize;
+ file.close();
+ if (!read || memcmp(image, "FCS1", 4) != 0 || image[4] > 1
+ || !zero(image + 5, 3)
+ || storage::readLE32(image + 60) != storage::updateCRC32(0xffffffff, image, 60)) return false;
+ if (!zero(image + 8, 16) && !FleetCommand::privateKeyAllowed(image + 8)) return false;
+ configured_ = image[4] != 0;
+ if (configured_ && zero(image + 8, 16)) return false;
+ memcpy(channel_.secret, image + 8, 16);
+ Utils::sha256(channel_.hash, sizeof(channel_.hash), channel_.secret, 16);
+ memcpy(controller_.pub_key, image + 24, 32);
+ controller_set_ = !zero(controller_.pub_key, 32);
+ last_sequence_ = storage::readLE32(image + 56);
+ return true;
+}
+
+bool FleetChannel::save(bool enabled, const uint8_t* key, const uint8_t* controller,
+ uint32_t sequence) {
+ if (!healthy_ || !fs_) return false;
+ uint8_t image[ImageSize] = {'F', 'C', 'S', '1', uint8_t(enabled)};
+ memcpy(image + 8, key, 16);
+ memcpy(image + 24, controller, 32);
+ storage::writeLE32(image + 56, sequence);
+ storage::writeLE32(image + 60, storage::updateCRC32(0xffffffff, image, 60));
+#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
+ AtomicFileWriter writer(fs_, Path);
+#else
+ ContactFileTransaction writer(fs_, Path, filePresence);
+#endif
+ return writer && writer.write(image, sizeof(image)) == sizeof(image) && writer.commit();
+}
+
+bool FleetChannel::handleConfig(const char* command, char* reply, size_t capacity) {
+ if (!strcmp(command, "get fleet.channel")) {
+ char fingerprint[3];
+ Utils::toHex(fingerprint, channel_.hash, 1);
+ snprintf(reply, capacity, "> %s,controller=%s,store=%s,hash=%s,last=%lu",
+ configured_ ? "on" : "off", controller_set_ ? "set" : "unset",
+ healthy_ ? "ok" : "error", fingerprint, (unsigned long)last_sequence_);
+ } else if (!strcmp(command, "get fleet.controller")) {
+ if (controller_set_) {
+ char key[65];
+ Utils::toHex(key, controller_.pub_key, 32);
+ snprintf(reply, capacity, "> %s", key);
+ } else snprintf(reply, capacity, "> unset");
+ } else if (!strcmp(command, "get fleet.stats")) {
+ snprintf(reply, capacity, "> accepted=%lu rejected=%lu busy=%lu pending=%u ack=%u",
+ (unsigned long)accepted_, (unsigned long)rejected_, (unsigned long)busy_,
+ unsigned(pending_), unsigned(barrier_.waiting()));
+ } else if (!strncmp(command, "set fleet.channel ", 18)) {
+ if (pending_ || barrier_.waiting()) {
+ snprintf(reply, capacity, "Err - fleet command pending; retry later"); return true;
+ }
+ const char* value = command + 18;
+ uint8_t key[16] = {};
+ const bool enabled = strcmp(value, "off") != 0;
+ if (enabled && (strlen(value) != 32 || !Utils::fromHex(key, 16, value)
+ || !FleetCommand::privateKeyAllowed(key))) {
+ snprintf(reply, capacity, "Err - use a private random 128-bit key or off"); return true;
+ }
+ if (!save(enabled, key, controller_.pub_key, last_sequence_)) {
+ snprintf(reply, capacity, "Err - fleet settings not saved; store unavailable"); return true;
+ }
+ configured_ = enabled;
+ memset(channel_.secret, 0, sizeof(channel_.secret));
+ memcpy(channel_.secret, key, sizeof(key));
+ Utils::sha256(channel_.hash, sizeof(channel_.hash), key, sizeof(key));
+ snprintf(reply, capacity, "OK - fleet channel %s; publisher signature required",
+ enabled ? "on" : "off");
+ } else if (!strncmp(command, "set fleet.controller ", 21)) {
+ if (pending_ || barrier_.waiting()) {
+ snprintf(reply, capacity, "Err - fleet command pending; retry later"); return true;
+ }
+ const char* value = command + 21;
+ uint8_t key[32] = {};
+ if (strcmp(value, "off") && (strlen(value) != 64 || !Utils::fromHex(key, 32, value) || zero(key, 32))) {
+ snprintf(reply, capacity, "Err - use publisher public key (64 hex) or off"); return true;
+ }
+ if (!save(configured_, channel_.secret, key, last_sequence_)) {
+ snprintf(reply, capacity, "Err - fleet settings not saved; store unavailable"); return true;
+ }
+ memcpy(controller_.pub_key, key, 32);
+ controller_set_ = !zero(key, 32);
+ snprintf(reply, capacity, "OK - fleet publisher %s", controller_set_ ? "set" : "off");
+ } else return false;
+ return true;
+}
+
+void FleetChannel::receive(Packet* packet, Mesh& mesh, const TransportKey* scope) {
+ if (!healthy_ || !configured_ || !controller_set_ || !packet
+ || packet->getPayloadType() != PAYLOAD_TYPE_GRP_DATA
+ || packet->payload_len < 1 + CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE
+ || packet->payload_len > sizeof(incoming_) || packet->payload[0] != channel_.hash[0]) return;
+ if (pending_ || barrier_.waiting()) { ++busy_; return; }
+ // Do no decryption, signature verification, or writes on the receive stack.
+ memcpy(incoming_, packet->payload, packet->payload_len);
+ incoming_len_ = packet->payload_len;
+ rx_profile_ = packet->radio_profile;
+ rx_generation_ = packet->radio_generation;
+ path_hash_size_ = packet->getPathHashSize();
+ reply_scoped_ = packet->hasTransportCodes();
+ reply_scope_known_ = scope != nullptr && !scope->isNull();
+ if (reply_scope_known_) reply_scope_ = *scope;
+ pending_ = true;
+ (void)mesh;
+}
+
+bool FleetChannel::decode(Mesh& mesh, FleetCommand::Decoded& command) {
+ const uint32_t now_ms = millis();
+ if (uint32_t(now_ms - verify_window_) >= 1000) {
+ verify_window_ = now_ms; verify_attempts_ = 0;
+ }
+ // A forged matching channel hash must not monopolize the radio loop with
+ // Ed25519 work. Legitimate publishers can retry with a fresh sequence.
+ if (verify_attempts_ >= 4) return false;
+ ++verify_attempts_;
+ uint8_t data[MAX_PACKET_PAYLOAD];
+ const int length = Utils::MACThenDecrypt(channel_.secret, data, incoming_ + 1, incoming_len_ - 1);
+ if (length < 3 || data[0] != uint8_t(FleetCommand::DataType)
+ || data[1] != uint8_t(FleetCommand::DataType >> 8)) return false;
+ const size_t payload_length = data[2];
+ const size_t unpadded = 3 + payload_length;
+ if (unpadded > (size_t)length || (unpadded + 15) / 16 * 16 != (size_t)length
+ || !zero(data + unpadded, length - unpadded)) return false;
+ if (payload_length < FleetCommand::HeaderSize + FleetCommand::SignatureSize
+ || storage::readLE32(data + 7) <= last_sequence_) return false;
+ if (!FleetCommand::decode(controller_, channel_.secret, data + 3, payload_length,
+ mesh.getRTCClock()->getCurrentTime(), mesh.self_id.pub_key, command)) return false;
+ broadcast_ = zero(data + 3 + 12, FleetCommand::TargetSize);
+ return true;
+}
+
+bool FleetChannel::acknowledge(Mesh& mesh, const char* name, uint32_t sequence,
+ const char* reply, bool radio_mutation) {
+ // Mesh::createGroupDatagram reserves one channel byte and a full block's
+ // padding allowance, so at most 168 plaintext bytes fit a 184-byte payload.
+ uint8_t data[MAX_PACKET_PAYLOAD - CIPHER_BLOCK_SIZE] = {};
+ const uint32_t time = mesh.getRTCClock()->getCurrentTime();
+ memcpy(data, &time, sizeof(time));
+ data[4] = 0; // TXT_TYPE_PLAIN
+ char identity[13];
+ Utils::toHex(identity, mesh.self_id.pub_key, 6);
+ // Keep names bounded and remove ':' so ordinary app channel parsers agree.
+ char sender[17];
+ snprintf(sender, sizeof(sender), "%.16s", name ? name : "node");
+ for (char* p = sender; *p; ++p) if (*p == ':') *p = ';';
+ const int count = snprintf((char*)data + 5, sizeof(data) - 5,
+ "%s: fleet %lu %s: %s", sender,
+ (unsigned long)sequence, identity, reply);
+ if (count < 0) return false;
+ const size_t length = count >= int(sizeof(data) - 5) ? sizeof(data) - 6 : size_t(count);
+ Packet* packet = mesh.createGroupDatagram(PAYLOAD_TYPE_GRP_TXT, channel_, data, 5 + length);
+ if (!packet) return false;
+ packet->radio_reply = true;
+ packet->flood_retry_policy = FLOOD_RETRY_POLICY_DENY;
+ // Acknowledgements have one tracked physical attempt per profile. A later
+ // untracked retry must never trigger a completed radio transition twice.
+ packet->radio_bound = false;
+ if (radio_mutation) barrier_.prepare(packet);
+ // Keep short temporary leases useful: their clock started at admission and
+ // may not be extended by waiting for a fleet acknowledgement.
+ const uint32_t maximum_delay = broadcast_ ? (radio_mutation ? 15500 : 60500) : 1500;
+ const uint32_t delay = mesh.getRNG()->nextInt(500, maximum_delay);
+ bool queued = false;
+ if (reply_scoped_) {
+ if (!reply_scope_known_) {
+ mesh.releasePacket(packet);
+ if (radio_mutation) barrier_.clear();
+ return false;
+ }
+ uint16_t codes[2] = {reply_scope_.calcTransportCode(packet), 0};
+ queued = mesh.sendFlood(packet, codes, delay, path_hash_size_);
+ } else queued = mesh.sendFlood(packet, delay, path_hash_size_);
+ if (!queued) { if (radio_mutation) barrier_.clear(); return false; }
+ if (radio_mutation) {
+ barrier_.arm(packet);
+ ack_deadline_ = millis() + 300000UL;
+ }
+ return true;
+}
+
+void FleetChannel::finish(RadioProfileCLI& profiles, bool delivered) {
+ barrier_.clear(); ack_deadline_ = 0;
+ profiles.finishReplyMutation(delivered);
+}
+void FleetChannel::complete(Packet* packet, RadioProfileCLI& profiles) {
+ if (barrier_.complete(packet) && !barrier_.waiting()) finish(profiles, true);
+}
+void FleetChannel::fail(Packet* packet, RadioProfileCLI& profiles) {
+ if (barrier_.fail(packet)) finish(profiles, false);
+ else if (!barrier_.waiting() && barrier_.succeeded()) finish(profiles, true);
+}
+void FleetChannel::serviceDeadline(Mesh& mesh, RadioProfileCLI& profiles) {
+ if (!barrier_.waiting() || int32_t(uint32_t(millis()) - ack_deadline_) < 0) return;
+ if (barrier_.contains(mesh.getOutboundInFlight())) mesh.cancelOutboundRadioRetry(mesh.getOutboundInFlight());
+ for (int i = mesh._mgr->getOutboundTotal() - 1; i >= 0; --i) {
+ if (!barrier_.contains(mesh._mgr->getOutboundByIdx(i))) continue;
+ Packet* packet = mesh._mgr->removeOutboundByIdx(i);
+ static_cast(mesh).onSendFail(packet);
+ mesh.releasePacket(packet);
+ }
+}
+} // namespace mesh
diff --git a/src/helpers/FleetChannel.h b/src/helpers/FleetChannel.h
new file mode 100644
index 000000000..79802c71e
--- /dev/null
+++ b/src/helpers/FleetChannel.h
@@ -0,0 +1,100 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace mesh {
+
+// Scope codes authenticate the original packet's payload. A new reply needs
+// fresh codes from the exact matching region key, including rotated keys.
+template
+bool captureFleetReplyScope(Regions& regions, const Region* region,
+ const Packet* packet, TransportKey& scope) {
+ if (!packet || !packet->hasTransportCodes() || !region || region->isWildcard()) return false;
+ TransportKey candidates[MAX_TKS_ENTRIES];
+ const int count = regions.getTransportKeysFor(*region, candidates, MAX_TKS_ENTRIES);
+ for (int i = 0; i < count && i < MAX_TKS_ENTRIES; ++i) {
+ const uint16_t code = candidates[i].calcTransportCode(packet);
+ if (code == packet->transport_codes[0] || code == packet->transport_codes[1]) {
+ scope = candidates[i]; return true;
+ }
+ }
+ return false;
+}
+
+// Optional infrastructure-only receiver. The publisher's private key is never
+// enrolled here. The ordinary channel key provides privacy; signatures grant
+// the deliberately narrower fleet capability.
+class FleetChannel {
+ FILESYSTEM* fs_;
+ GroupChannel channel_{};
+ Identity controller_;
+ uint32_t last_sequence_ = 0;
+ uint32_t ack_deadline_ = 0;
+ uint32_t verify_window_ = 0;
+ uint32_t accepted_ = 0, rejected_ = 0, busy_ = 0;
+ uint8_t verify_attempts_ = 0;
+ bool configured_ = false, controller_set_ = false, healthy_ = false;
+ bool pending_ = false;
+ bool broadcast_ = false;
+ uint8_t incoming_[MAX_PACKET_PAYLOAD]{};
+ uint8_t incoming_len_ = 0, rx_profile_ = 0, path_hash_size_ = 1;
+ uint32_t rx_generation_ = 0;
+ TransportKey reply_scope_{};
+ bool reply_scoped_ = false, reply_scope_known_ = false;
+ TempRadioReplyBarrier barrier_;
+
+ bool load();
+ bool save(bool enabled, const uint8_t* key, const uint8_t* controller,
+ uint32_t sequence);
+ bool decode(Mesh& mesh, FleetCommand::Decoded& command);
+ bool acknowledge(Mesh& mesh, const char* name, uint32_t sequence,
+ const char* reply, bool radio_mutation);
+ void finish(RadioProfileCLI& profiles, bool delivered);
+ void serviceDeadline(Mesh& mesh, RadioProfileCLI& profiles);
+
+ public:
+ explicit FleetChannel(FILESYSTEM* fs) : fs_(fs) { healthy_ = load(); }
+ bool handleConfig(const char* command, char* reply, size_t capacity);
+ void receive(Packet* packet, Mesh& mesh, const TransportKey* scope = nullptr);
+ void complete(Packet* packet, RadioProfileCLI& profiles);
+ void fail(Packet* packet, RadioProfileCLI& profiles);
+ void copy(Packet* packet, const Packet* original) { barrier_.trackCopy(original, packet); }
+ bool waiting() const { return barrier_.waiting(); }
+
+ template
+ void service(Mesh& mesh, RadioProfileCLI& profiles, const char* name,
+ Handler handle) {
+ serviceDeadline(mesh, profiles);
+ if (!pending_) return;
+ pending_ = false;
+ if (barrier_.waiting() || profiles.hasReplyMutation()) { ++busy_; return; }
+ FleetCommand::Decoded command;
+ if (!decode(mesh, command)) { ++rejected_; return; }
+ // Reserve before dispatch, including failed commands. A lost reply, reboot,
+ // or retry can never repeat a mutation. A storage failure grants no control.
+ if (!save(configured_, channel_.secret, controller_.pub_key, command.sequence)) {
+ healthy_ = false;
+ ++rejected_;
+ return;
+ }
+ last_sequence_ = command.sequence;
+ ++accepted_;
+ Dispatcher::ReceiveProfileScope receive_scope(mesh, rx_profile_, rx_generation_);
+ char reply[160] = {};
+ const uint32_t generation = profiles.replyMutationGeneration();
+ profiles.beginReplyCommand();
+ handle(command.sequence, command.command, reply);
+ profiles.endReplyCommand();
+ const bool changed = profiles.replyMutationGeneration() != generation;
+ if (!acknowledge(mesh, name, command.sequence, reply, changed) && changed) {
+ finish(profiles, false);
+ }
+ }
+};
+
+} // namespace mesh
diff --git a/src/helpers/FleetCommand.cpp b/src/helpers/FleetCommand.cpp
new file mode 100644
index 000000000..d7df4e18b
--- /dev/null
+++ b/src/helpers/FleetCommand.cpp
@@ -0,0 +1,262 @@
+#include "FleetCommand.h"
+#include
+#include
+
+namespace mesh {
+namespace {
+
+constexpr char Domain[] = "MeshCoreFleet1";
+constexpr size_t SignedMessageCapacity = sizeof(Domain) - 1
+ + FleetCommand::KeySize + FleetCommand::HeaderSize
+ + FleetCommand::MaxCommandLength;
+constexpr uint8_t PublicKey[FleetCommand::KeySize] = {
+ 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
+ 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72
+};
+
+bool zero(const uint8_t* value, size_t size) {
+ uint8_t combined = 0;
+ for (size_t i = 0; i < size; ++i) combined |= value[i];
+ return combined == 0;
+}
+
+void erase(uint8_t* bytes, size_t length) {
+ volatile uint8_t* destination = bytes;
+ while (length--) *destination++ = 0;
+}
+
+void write32(uint8_t* dest, uint32_t value) {
+ for (unsigned i = 0; i < 4; ++i) dest[i] = value >> (i * 8);
+}
+
+uint32_t read32(const uint8_t* source) {
+ uint32_t value = 0;
+ for (unsigned i = 0; i < 4; ++i) value |= uint32_t(source[i]) << (i * 8);
+ return value;
+}
+
+void targetHash(const uint8_t* full_key, uint8_t* target) {
+ SHA256 hash;
+ hash.update(full_key, PUB_KEY_SIZE);
+ hash.finalize(target, FleetCommand::TargetSize);
+}
+
+int hexDigit(char digit) {
+ if (digit >= '0' && digit <= '9') return digit - '0';
+ if (digit >= 'a' && digit <= 'f') return digit - 'a' + 10;
+ if (digit >= 'A' && digit <= 'F') return digit - 'A' + 10;
+ return -1;
+}
+
+bool lifetimeAllowed(uint32_t sequence, uint32_t expires) {
+ return sequence >= FleetCommand::MinEpoch && expires >= sequence
+ && expires - sequence <= FleetCommand::MaxLifetime;
+}
+
+size_t signedMessage(const uint8_t* key, const uint8_t* payload,
+ size_t length, uint8_t* output) {
+ const size_t prefix = sizeof(Domain) - 1 + FleetCommand::KeySize;
+ memcpy(output, Domain, sizeof(Domain) - 1);
+ memcpy(output + sizeof(Domain) - 1, key, FleetCommand::KeySize);
+ memcpy(output + prefix, payload, length);
+ return prefix + length;
+}
+
+bool keyEquals(const char* key, size_t length, const char* candidate) {
+ return strlen(candidate) == length && !memcmp(key, candidate, length);
+}
+
+// The CLI handler owns board-specific range checks. Authorization admits
+// only the exact namespace and a strictly numeric optional row suffix.
+bool rowKey(const char* key, size_t length, const char* family) {
+ const size_t family_length = strlen(family);
+ if (length < family_length || memcmp(key, family, family_length)) return false;
+ if (length == family_length) return true;
+ if (key[family_length] != '.') return false;
+ size_t cursor = family_length + 1;
+ if (cursor == length || key[cursor] < '1' || key[cursor] > '9') return false;
+ unsigned row = 0;
+ for (; cursor < length; ++cursor) {
+ if (key[cursor] < '0' || key[cursor] > '9') return false;
+ row = row * 10 + unsigned(key[cursor] - '0');
+ if (row > 255) return false;
+ }
+ return true;
+}
+
+bool decimalToken(const char* text, size_t length) {
+ if (!length) return false;
+ bool dot = false, digit = false;
+ for (size_t i = 0; i < length; ++i) {
+ if (text[i] >= '0' && text[i] <= '9') digit = true;
+ else if (text[i] == '.' && !dot) dot = true;
+ else return false;
+ }
+ return digit;
+}
+
+bool integerToken(const char* text, size_t length) {
+ if (!length) return false;
+ for (size_t i = 0; i < length; ++i)
+ if (text[i] < '0' || text[i] > '9') return false;
+ return true;
+}
+
+bool radioTuple(const char* arguments, bool temporary) {
+ if (!strcmp(arguments, "off")) return true;
+ unsigned fields = 0;
+ const unsigned expected = temporary ? 6 : 5;
+ const char* cursor = arguments;
+ for (;;) {
+ const char* end = strchr(cursor, ',');
+ const size_t length = end ? size_t(end - cursor) : strlen(cursor);
+ bool valid;
+ if (fields < 2) valid = decimalToken(cursor, length);
+ else if (fields < 4 || (temporary && fields == 5))
+ valid = integerToken(cursor, length);
+ else if (fields == 4) valid = keyEquals(cursor, length, "rx")
+ || keyEquals(cursor, length, "rxtx");
+ else valid = fields == expected
+ && (integerToken(cursor, length) || keyEquals(cursor, length, "auto"));
+ if (!valid || ++fields > expected + 1) return false;
+ if (!end) return fields == expected || fields == expected + 1;
+ cursor = end + 1;
+ }
+}
+
+} // namespace
+
+bool FleetCommand::privateKeyAllowed(const uint8_t* key) {
+ return key && !zero(key, KeySize) && memcmp(key, PublicKey, KeySize) != 0;
+}
+
+bool FleetCommand::parseTarget(const char* text, uint8_t* target) {
+ if (!text || !target) return false;
+ if (!strcmp(text, "all")) {
+ memset(target, 0, TargetSize);
+ return true;
+ }
+ if (strlen(text) != PUB_KEY_SIZE * 2) return false;
+ uint8_t full_key[PUB_KEY_SIZE];
+ for (size_t i = 0; i < PUB_KEY_SIZE; ++i) {
+ const int upper = hexDigit(text[i * 2]);
+ const int lower = hexDigit(text[i * 2 + 1]);
+ if (upper < 0 || lower < 0) return false;
+ full_key[i] = uint8_t((upper << 4) | lower);
+ }
+ // An invalid zero identity must not masquerade as the reserved all target.
+ if (zero(full_key, PUB_KEY_SIZE)) return false;
+ targetHash(full_key, target);
+ return !zero(target, TargetSize);
+}
+
+bool FleetCommand::commandAllowed(const char* command) {
+ if (!command) return false;
+ size_t length = 0;
+ while (command[length]) {
+ const unsigned char byte = command[length];
+ if (++length > MaxCommandLength || byte < 0x20 || byte > 0x7e
+ || byte == ';' || byte == '|' || byte == '&' || byte == '`'
+ || byte == '\\') return false;
+ }
+ if (!length || command[0] == ' ' || command[length - 1] == ' ') return false;
+ bool get = false, set = false, del = false;
+ if (!strncmp(command, "get ", 4)) get = true;
+ else if (!strncmp(command, "set ", 4)) set = true;
+ else if (!strncmp(command, "del ", 4)) del = true;
+ else return false;
+ const char* key = command + 4;
+ const char* separator = strchr(key, ' ');
+ const size_t key_length = separator ? size_t(separator - key) : strlen(key);
+ const char* arguments = separator ? separator + 1 : "";
+ if (!key_length || (separator && (!*arguments || *arguments == ' '))) return false;
+
+ if (keyEquals(key, key_length, "radio2")
+ || keyEquals(key, key_length, "tempradio2")) {
+ return get ? !*arguments : set && radioTuple(arguments, key_length == 10);
+ }
+ if (keyEquals(key, key_length, "radio2.status")
+ || keyEquals(key, key_length, "radio2.timing")) return get && !*arguments;
+ if (keyEquals(key, key_length, "radio2.cross")) {
+ return get ? !*arguments : set && (!strcmp(arguments, "on")
+ || !strcmp(arguments, "off") || !strcmp(arguments, "auto"));
+ }
+ const bool rows = rowKey(key, key_length, "flood.filter")
+ || rowKey(key, key_length, "flood.filter.blacklist")
+ || rowKey(key, key_length, "flood.rule")
+ || rowKey(key, key_length, "flood.moderation")
+ || rowKey(key, key_length, "flood.channel.scope")
+ || rowKey(key, key_length, "flood.channel.scope.require");
+ if (rows) return get ? !*arguments
+ : set ? *arguments != 0 : del && (!*arguments || !strcmp(arguments, "all"));
+ const bool settings = keyEquals(key, key_length, "flood.channel.data")
+ || keyEquals(key, key_length, "flood.channel.data.hops")
+ || keyEquals(key, key_length, "flood.max")
+ || keyEquals(key, key_length, "flood.max.unscoped")
+ || keyEquals(key, key_length, "flood.max.advert");
+ return settings && (get ? !*arguments : set && *arguments);
+}
+
+size_t FleetCommand::encode(const LocalIdentity& publisher, const uint8_t* key,
+ uint32_t sequence, uint32_t expires,
+ const uint8_t* target, const char* command,
+ uint8_t* output, size_t capacity) {
+ if (!privateKeyAllowed(key) || !target || !output
+ || !lifetimeAllowed(sequence, expires) || !commandAllowed(command)
+ || zero(publisher.pub_key, PUB_KEY_SIZE)) return 0;
+ const size_t command_length = strlen(command);
+ const size_t unsigned_length = HeaderSize + command_length;
+ const size_t length = unsigned_length + SignatureSize;
+ if (capacity < length) return 0;
+ memcpy(output, "FMC1", 4);
+ write32(output + 4, sequence);
+ write32(output + 8, expires);
+ memcpy(output + 12, target, TargetSize);
+ output[28] = uint8_t(command_length);
+ memcpy(output + HeaderSize, command, command_length);
+ uint8_t message[SignedMessageCapacity];
+ const size_t message_length = signedMessage(key, output, unsigned_length, message);
+ publisher.sign(output + unsigned_length, message, int(message_length));
+ erase(message, sizeof(message));
+ return length;
+}
+
+bool FleetCommand::decode(const Identity& publisher, const uint8_t* key,
+ const uint8_t* payload, size_t length, uint32_t now,
+ const uint8_t* self_public_key, Decoded& output) {
+ memset(&output, 0, sizeof(output));
+ if (!privateKeyAllowed(key) || !payload || !self_public_key
+ || zero(publisher.pub_key, PUB_KEY_SIZE) || zero(self_public_key, PUB_KEY_SIZE)
+ || now < MinEpoch || length < HeaderSize + SignatureSize
+ || length > MaxPayloadLength || memcmp(payload, "FMC1", 4)) return false;
+ const size_t command_length = payload[28];
+ if (!command_length || command_length > MaxCommandLength
+ || length != HeaderSize + command_length + SignatureSize) return false;
+ const uint32_t sequence = read32(payload + 4), expires = read32(payload + 8);
+ if (!lifetimeAllowed(sequence, expires) || expires < now
+ || (sequence > now && sequence - now > MaxClockLead)) return false;
+ if (!zero(payload + 12, TargetSize)) {
+ uint8_t target[TargetSize];
+ targetHash(self_public_key, target);
+ if (memcmp(target, payload + 12, TargetSize)) return false;
+ }
+ // Reject embedded NUL bytes, which would authorize a different text from
+ // the signed byte string when passed to the CLI parser.
+ if (memchr(payload + HeaderSize, 0, command_length)) return false;
+ char command[MaxCommandLength + 1];
+ memcpy(command, payload + HeaderSize, command_length);
+ command[command_length] = 0;
+ if (!commandAllowed(command)) return false;
+ const size_t unsigned_length = HeaderSize + command_length;
+ uint8_t message[SignedMessageCapacity];
+ const size_t message_length = signedMessage(key, payload, unsigned_length, message);
+ const bool valid = publisher.verify(payload + unsigned_length, message, int(message_length));
+ erase(message, sizeof(message));
+ if (!valid) return false;
+ output.sequence = sequence;
+ output.expires = expires;
+ memcpy(output.command, command, command_length + 1);
+ return true;
+}
+
+} // namespace mesh
diff --git a/src/helpers/FleetCommand.h b/src/helpers/FleetCommand.h
new file mode 100644
index 000000000..bd0395335
--- /dev/null
+++ b/src/helpers/FleetCommand.h
@@ -0,0 +1,61 @@
+#pragma once
+
+#include
+#include
+#include "../Identity.h"
+
+#ifndef MESH_ENABLE_FLEET_CONTROL
+ // Fixed 240 KiB STM32 images have no room for fleet control.
+ #if defined(STM32_PLATFORM)
+ #define MESH_ENABLE_FLEET_CONTROL 0
+ #else
+ #define MESH_ENABLE_FLEET_CONTROL 1
+ #endif
+#endif
+
+namespace mesh {
+
+// Signed, private-channel fleet commands. The channel key supplies privacy;
+// only the publisher's Ed25519 signature authorizes a command. Receivers store
+// the public identity and never need the publisher's private identity.
+class FleetCommand {
+public:
+ static constexpr uint16_t DataType = 0xFF01;
+ static constexpr size_t KeySize = 16;
+ static constexpr size_t TargetSize = 16;
+ static constexpr size_t SignatureSize = 64;
+ static constexpr size_t MaxCommandLength = 72;
+ static constexpr size_t HeaderSize = 29;
+ static constexpr size_t MaxPayloadLength = 165;
+ static constexpr uint32_t MinEpoch = 1735689600UL; // 2025-01-01 UTC
+ static constexpr uint32_t MaxLifetime = 600;
+ static constexpr uint32_t MaxClockLead = 60;
+
+ struct Decoded {
+ uint32_t sequence;
+ uint32_t expires;
+ char command[MaxCommandLength + 1];
+ };
+
+ static bool privateKeyAllowed(const uint8_t* key);
+ // "all" becomes the reserved zero target; otherwise require one complete
+ // public key and derive the first 16 bytes of SHA-256, not a short node ID.
+ static bool parseTarget(const char* text, uint8_t* target);
+ static bool commandAllowed(const char* command);
+
+ // Return the exact envelope size, or zero on invalid input. The signature
+ // binds the domain, private channel key, complete header, and command bytes.
+ static size_t encode(const LocalIdentity& publisher, const uint8_t* key,
+ uint32_t sequence, uint32_t expires,
+ const uint8_t* target, const char* command,
+ uint8_t* output, size_t capacity);
+
+ // Accept an exact envelope only: group framing owns padding validation.
+ // On success the caller must durably reject an old/repeated sequence before
+ // any mutation, and defer execution until the receive stack has unwound.
+ static bool decode(const Identity& publisher, const uint8_t* key,
+ const uint8_t* payload, size_t length, uint32_t now,
+ const uint8_t* self_public_key, Decoded& output);
+};
+
+} // namespace mesh
diff --git a/test/fixtures/companion_fleet_dispatch.h b/test/fixtures/companion_fleet_dispatch.h
new file mode 100644
index 000000000..6cb4d821e
--- /dev/null
+++ b/test/fixtures/companion_fleet_dispatch.h
@@ -0,0 +1,54 @@
+#pragma once
+#ifndef MESH_ENABLE_FLEET_CONTROL
+#define MESH_ENABLE_FLEET_CONTROL 1
+#endif
+
+#include
+#include
+#include
+
+// These narrower dispatch fixtures retain the complete production fleet branch
+// but have no channel peripheral. The separate companion fleet suite compiles
+// the real codec with real Ed25519 and exercises packet admission end to end.
+#ifndef MAX_GROUP_CHANNELS
+#define MAX_GROUP_CHANNELS 4
+#endif
+#ifndef PUB_KEY_SIZE
+#define PUB_KEY_SIZE 32
+#endif
+#ifndef OUT_PATH_UNKNOWN
+#define OUT_PATH_UNKNOWN 255
+#endif
+
+namespace mesh {
+struct FleetCommand {
+ static constexpr uint16_t DataType = 0xFF01;
+ static constexpr size_t KeySize = 16, TargetSize = 16, MaxPayloadLength = 165;
+ static constexpr uint32_t MinEpoch = 1735689600UL, MaxLifetime = 600;
+ static bool parseTarget(const char*, uint8_t*) { return false; }
+ static bool commandAllowed(const char*) { return false; }
+ static bool privateKeyAllowed(const uint8_t*) { return false; }
+ template
+ static size_t encode(const Signer&, const uint8_t*, uint32_t, uint32_t,
+ const uint8_t*, const char*, uint8_t*, size_t) {
+ assert(false && "fleet encoding belongs in the dedicated real-codec suite");
+ return 0;
+ }
+};
+}
+
+struct FleetFixtureChannel { uint8_t secret[32] = {}; };
+struct ChannelDetails { FleetFixtureChannel channel; char name[32] = {}; };
+struct FleetFixtureClock { uint32_t getCurrentTime() const { return 0; } };
+struct CompanionFleetFixture {
+ bool getChannel(int, ChannelDetails&) { return false; }
+ FleetFixtureClock* getRTCClock() {
+ static FleetFixtureClock clock;
+ return &clock;
+ }
+ bool sendGroupData(FleetFixtureChannel&, uint8_t*, uint8_t, uint16_t,
+ const uint8_t*, int) {
+ assert(false && "fleet packet admission belongs in its dedicated suite");
+ return false;
+ }
+};
diff --git a/test/test_companion_fleet_control.py b/test/test_companion_fleet_control.py
new file mode 100644
index 000000000..c84e46f30
--- /dev/null
+++ b/test/test_companion_fleet_control.py
@@ -0,0 +1,307 @@
+#!/usr/bin/env python3
+"""Execute the real Companion fleet-send CLI, preserving its client boundary.
+
+Only channel storage and packet admission are mocked here. Protocol encoding,
+target hashing and permission checks come from FleetCommand.cpp; OpenSSL
+supplies real Ed25519 signing and verification through the Identity interface.
+"""
+
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+from cpp_source import body
+
+
+ROOT = Path(__file__).resolve().parents[1]
+SOURCE = ROOT / "examples/companion_radio/MyMesh.cpp"
+
+HARNESS = r'''
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace mesh {
+Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); }
+LocalIdentity::LocalIdentity() : Identity() {
+ memset(prv_key, 0xAA, 32);
+ EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, prv_key, 32);
+ assert(key);
+ size_t length = sizeof(pub_key);
+ assert(EVP_PKEY_get_raw_public_key(key, pub_key, &length) == 1);
+ assert(length == sizeof(pub_key));
+ memcpy(prv_key + 32, pub_key, sizeof(pub_key));
+ EVP_PKEY_free(key);
+}
+void LocalIdentity::sign(uint8_t* signature, const uint8_t* data, int length) const {
+ EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, prv_key, 32);
+ EVP_MD_CTX* context = EVP_MD_CTX_new();
+ assert(key && context);
+ assert(EVP_DigestSignInit(context, nullptr, nullptr, nullptr, key) == 1);
+ size_t size = SIGNATURE_SIZE;
+ assert(EVP_DigestSign(context, signature, &size, data, length) == 1);
+ assert(size == SIGNATURE_SIZE);
+ EVP_MD_CTX_free(context); EVP_PKEY_free(key);
+}
+bool Identity::verify(const uint8_t* signature, const uint8_t* data, int length) const {
+ EVP_PKEY* key = EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, pub_key, sizeof(pub_key));
+ EVP_MD_CTX* context = EVP_MD_CTX_new();
+ assert(key && context);
+ assert(EVP_DigestVerifyInit(context, nullptr, nullptr, nullptr, key) == 1);
+ const bool valid = EVP_DigestVerify(context, signature, SIGNATURE_SIZE, data, length) == 1;
+ EVP_MD_CTX_free(context); EVP_PKEY_free(key);
+ return valid;
+}
+}
+
+#define MAX_GROUP_CHANNELS 4
+#define OUT_PATH_UNKNOWN 255
+struct GroupChannel {
+ uint8_t secret[32] = {};
+ uint8_t tx_radio = 0;
+};
+struct ChannelDetails { GroupChannel channel; char name[32] = {}; };
+struct Clock { uint32_t now = mesh::FleetCommand::MinEpoch + 1000;
+ uint32_t getCurrentTime() const { return now; } };
+class MyMesh {
+ public:
+ mesh::LocalIdentity self_id;
+ Clock clock;
+ ChannelDetails channels[MAX_GROUP_CHANNELS];
+ bool queue_ok = true;
+ unsigned queued = 0;
+ uint8_t sent_policy = 0;
+ uint16_t sent_type = 0;
+ uint8_t sent[mesh::FleetCommand::MaxPayloadLength] = {};
+ size_t sent_length = 0;
+ MyMesh() {
+ strcpy(channels[1].name, "private fleet");
+ memset(channels[1].channel.secret, 0x21, mesh::FleetCommand::KeySize);
+ channels[1].channel.tx_radio = 2;
+ }
+ bool getChannel(int index, ChannelDetails& out) {
+ if (index < 0 || index >= MAX_GROUP_CHANNELS) return false;
+ out = channels[index]; return true;
+ }
+ Clock* getRTCClock() { return &clock; }
+ bool sendGroupData(GroupChannel& channel, uint8_t* path, uint8_t path_length,
+ uint16_t type, const uint8_t* data, int length) {
+ assert(path == nullptr && path_length == OUT_PATH_UNKNOWN);
+ assert(length > 0 && size_t(length) <= sizeof(sent));
+ if (!queue_ok) return false;
+ sent_policy = channel.tx_radio; sent_type = type;
+ memcpy(sent, data, length); sent_length = length; ++queued;
+ return true;
+ }
+ bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply);
+};
+@METHOD@
+
+static void rejected(MyMesh& node, const char* text, uint32_t remote = 0) {
+ char reply[160] = {};
+ const unsigned before = node.queued;
+ assert(node.handleCommand(text, remote, reply));
+ assert(!strncmp(reply, "Error:", 6));
+ assert(before == node.queued);
+}
+
+static std::string fullKey(uint8_t value) {
+ char hex[65];
+ for (unsigned i = 0; i < 32; ++i) sprintf(hex + i * 2, "%02x", value);
+ return hex;
+}
+
+int main(int argc, char** argv) {
+ assert(argc == 2);
+ const std::string scenario = argv[1];
+ MyMesh node; char reply[160] = {};
+ if (scenario == "local_boundary") {
+ assert(!node.handleCommand(nullptr, 0, reply));
+ assert(!node.handleCommand("fleet send 1 all set radio2 off", 0, nullptr));
+ assert(!node.handleCommand("fleetish", 0, reply));
+ rejected(node, "fleet send 1 all set radio2 off", 1);
+ rejected(node, "fleet", 1);
+ rejected(node, "fleet send 1 all reboot");
+ rejected(node, "fleet send 1 all get prv.key");
+ rejected(node, "fleet send 1 all fleet send 1 all set radio2 off");
+ assert(node.handleCommand(" \tAB| fleet send 1 all set radio2 off", 0, reply));
+ assert(!strncmp(reply, "AB|OK - fleet command queued; seq=", 32));
+ assert(node.queued == 1 && node.sent_type == mesh::FleetCommand::DataType);
+ assert(node.sent_policy == 2); // The configured radio2 channel route survives.
+ } else if (scenario == "syntax") {
+ for (const char* command : {"fleet", "fleet send", "fleet sender 1 all set radio2 off",
+ "fleet send -1 all set radio2 off", "fleet send 4 all set radio2 off",
+ "fleet send 4294967296 all set radio2 off", "fleet send 1x all set radio2 off",
+ "fleet send 1 all", "fleet send 1 a set radio2 off",
+ "fleet send 1 111111111111 set radio2 off", "fleet send 1 all get password",
+ "fleet send 1 all set radio2 off\nreboot"}) rejected(node, command);
+ rejected(node, ("fleet send 1 " + std::string(65, '1') + " set radio2 off").c_str());
+ rejected(node, ("fleet send 1 all set flood.filter " + std::string(150, '1')).c_str());
+ assert(node.handleCommand("fleet\tsend\t1\tall\tset radio2 off", 0, reply));
+ assert(!strncmp(reply, "OK - fleet command queued", 25));
+ } else if (scenario == "private_channel") {
+ rejected(node, "fleet send 0 all set radio2 off");
+ memset(node.channels[1].channel.secret, 0, 32);
+ rejected(node, "fleet send 1 all set radio2 off");
+ const uint8_t public_key[16] = {0x8b,0x33,0x87,0xe9,0xc5,0xcd,0xea,0x6a,
+ 0xc9,0xe5,0xed,0xba,0xa1,0x15,0xcd,0x72};
+ memcpy(node.channels[1].channel.secret, public_key, sizeof(public_key));
+ strcpy(node.channels[1].name, "renamed public");
+ rejected(node, "fleet send 1 all set radio2 off");
+ memset(node.channels[1].channel.secret, 0x21, 32);
+ rejected(node, "fleet send 1 all set radio2 off");
+ memset(node.channels[1].channel.secret + 16, 0, 16);
+ strcpy(node.channels[1].name, "Public"); // Names are not key authorization.
+ assert(node.handleCommand("fleet send 1 all set radio2 off", 0, reply));
+ assert(node.queued == 1);
+ } else if (scenario == "queue_sequence") {
+ node.queue_ok = false;
+ rejected(node, "fleet send 1 all set radio2 off");
+ node.queue_ok = true;
+ assert(node.handleCommand("fleet send 1 all set radio2 off", 0, reply));
+ assert(node.queued == 1);
+ rejected(node, "fleet send 1 all set radio2 off");
+ ++node.clock.now;
+ assert(node.handleCommand("fleet send 1 all set radio2 off", 0, reply));
+ assert(node.queued == 2);
+ --node.clock.now;
+ rejected(node, "fleet send 1 all set radio2 off");
+ } else if (scenario == "clock") {
+ node.clock.now = 0;
+ rejected(node, "fleet send 1 all set radio2 off");
+ node.clock.now = mesh::FleetCommand::MinEpoch - 1;
+ rejected(node, "fleet send 1 all set radio2 off");
+ node.clock.now = UINT32_MAX;
+ rejected(node, "fleet send 1 all set radio2 off");
+ node.clock.now = mesh::FleetCommand::MinEpoch;
+ assert(node.handleCommand("fleet send 1 all set radio2 off", 0, reply));
+ assert(node.queued == 1);
+ } else if (scenario == "signed_target") {
+ const std::string command = "fleet send 1 " + fullKey(0x42)
+ + " set tempradio2 910.5,500,5,5,rxtx,1";
+ assert(node.handleCommand(command.c_str(), 0, reply));
+ assert(node.queued == 1);
+ uint8_t recipient[32]; memset(recipient, 0x42, sizeof(recipient));
+ mesh::FleetCommand::Decoded decoded;
+ assert(mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
+ node.sent, node.sent_length, node.clock.now, recipient, decoded));
+ assert(decoded.sequence == node.clock.now);
+ assert(decoded.expires == node.clock.now + mesh::FleetCommand::MaxLifetime);
+ assert(!strcmp(decoded.command, "set tempradio2 910.5,500,5,5,rxtx,1"));
+ recipient[31] ^= 1;
+ assert(!mesh::FleetCommand::decode(node.self_id, node.channels[1].channel.secret,
+ node.sent, node.sent_length, node.clock.now, recipient, decoded));
+ } else assert(false);
+ printf("Companion fleet scenario %s passed\n", argv[1]);
+}
+'''
+
+
+class CompanionFleetControlTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ compiler = shutil.which("g++") or shutil.which("clang++")
+ if compiler is None:
+ raise unittest.SkipTest("host C++ compiler required")
+ cls.compiler = compiler
+ source = SOURCE.read_text()
+ function = body(source, "bool MyMesh::handleCommand(const char* command,")
+ # Retain the real NULL, whitespace and request-prefix handling too.
+ prefix = function[:function.index("#if COMPANION_FEATURE_READER")]
+ fleet_start = function.rindex("#if MESH_ENABLE_FLEET_CONTROL", 0,
+ function.index('if (!strncmp(command, "fleet", 5)'))
+ fleet_end = function.index(" if (_radio_profiles.handle", fleet_start)
+ # Keep the actual compile guard as well as its complete branch so
+ # compact profiles prove that none of the fleet dependencies survive.
+ cls.production = prefix + function[fleet_start:fleet_end] + "\n return false;\n}\n"
+ cls.work = tempfile.TemporaryDirectory(prefix="meshcore-fleet-companion-")
+ work = Path(cls.work.name)
+ cls.binary = work / "companion-fleet"
+ fixture = work / "fixture.cpp"
+ fixture.write_text(HARNESS.replace("@METHOD@", cls.production))
+ result = subprocess.run([
+ compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
+ "-Wno-unused-parameter", "-O2",
+ "-fstack-usage", "-isystem", str(ROOT / "test/mocks"),
+ "-I", str(ROOT / "src"), str(fixture),
+ str(ROOT / "src/helpers/FleetCommand.cpp"), "-lcrypto", "-o", str(cls.binary),
+ ], capture_output=True, text=True, timeout=60)
+ if result.returncode:
+ cls.work.cleanup()
+ raise AssertionError(result.stdout + result.stderr)
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.work.cleanup()
+
+ 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)
+
+ def test_local_client_boundary_and_channel_tx_route(self):
+ self.scenario("local_boundary")
+
+ def test_invalid_syntax_targets_and_forbidden_commands(self):
+ self.scenario("syntax")
+
+ def test_private_128_bit_key_required_even_if_channel_is_renamed(self):
+ self.scenario("private_channel")
+
+ def test_queue_failure_does_not_consume_sequence_and_success_does(self):
+ self.scenario("queue_sequence")
+
+ def test_invalid_clock_and_timestamp_overflow_never_queue(self):
+ self.scenario("clock")
+
+ def test_production_encode_targets_full_key_and_sets_bounded_expiry(self):
+ self.scenario("signed_target")
+
+ def test_disabled_profiles_require_no_sender_peripherals_or_crypto_link(self):
+ harness = r'''
+#include
+#include
+#include
+#include
+#include
+static_assert(MESH_ENABLE_FLEET_CONTROL == 0, "compact profile must disable fleet");
+// No channels, clock, signing identity, codec definitions or sendGroupData.
+class MyMesh {
+ public:
+ bool handleCommand(const char*, uint32_t, char*);
+};
+@METHOD@
+int main() {
+ MyMesh node;
+ char reply[160] = {};
+ assert(!node.handleCommand("fleet send 1 all set radio2 off", 0, reply));
+ assert(!node.handleCommand("fleet send 1 all set radio2 off", 1, reply));
+ assert(!node.handleCommand("AB|fleet send 1 all set radio2 off", 0, reply));
+}
+'''
+ work = Path(self.work.name)
+ source = work / "disabled.cpp"
+ source.write_text(harness.replace("@METHOD@", self.production))
+ for profile, flags in (("stm32-default", ["-DSTM32_PLATFORM=1"]),
+ ("explicit-disable", ["-DMESH_ENABLE_FLEET_CONTROL=0"])):
+ with self.subTest(profile=profile):
+ executable = work / profile
+ built = subprocess.run([
+ self.compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
+ "-Wno-unused-parameter", "-isystem", str(ROOT / "test/mocks"),
+ "-I", str(ROOT / "src"), *flags, str(source), "-o", str(executable),
+ ], capture_output=True, text=True, timeout=60)
+ self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
+ result = subprocess.run([str(executable)], 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_companion_primary_radio_persistence.py b/test/test_companion_primary_radio_persistence.py
index 5ef6ad5be..40ac3e38a 100644
--- a/test/test_companion_primary_radio_persistence.py
+++ b/test/test_companion_primary_radio_persistence.py
@@ -18,6 +18,7 @@ HARNESS = r'''
#include
#include
#include
+#include "companion_fleet_dispatch.h"
#include
#include
#define constrain(value,low,high) ((value)<(low)?(low):((value)>(high)?(high):(value)))
@@ -57,7 +58,8 @@ struct Radio : mesh::Radio {
};
struct Sensors { double node_lat=0,node_lon=0; } sensors;
@COMMAND_FILTER@
-struct MyMesh {
+struct MyMesh : CompanionFleetFixture {
+ uint8_t self_id = 0; // Signing is unreachable in this radio-only fixture.
CompanionNodePrefs _prefs;
DataStore store;
DataStore* _store=&store;
@@ -293,6 +295,7 @@ public:
build = subprocess.run([compiler,'-std=c++17','-Wall','-Wextra',
'-Wno-unused-parameter','-Wno-sign-compare','-Wno-reorder',
'-DESP32_PLATFORM=1',*flags,'-I',str(work),
+ '-I',str(ROOT/'test/fixtures'),
'-I',str(ROOT/'test/fixtures/radio_profiles/mocks'),
'-I',str(ROOT/'test/mocks'),'-I',str(ROOT/'src'),
'-I',str(ROOT/'src/helpers'),'-I',str(ROOT),str(work/'test.cpp'),
diff --git a/test/test_fleet_channel_runtime.py b/test/test_fleet_channel_runtime.py
new file mode 100644
index 000000000..e396fcd8e
--- /dev/null
+++ b/test/test_fleet_channel_runtime.py
@@ -0,0 +1,689 @@
+#!/usr/bin/env python3
+"""Execute production fleet receiver/storage/crypto boundaries with host mocks.
+
+FleetChannel.cpp and FleetCommand.cpp are compiled unchanged. Only hardware,
+packet admission, clock and filesystem I/O are mocked. Ed25519 signatures and
+SHA-256 use real implementations; AES encryption/MAC are supplied by OpenSSL.
+The real RP2040 ContactFileTransaction and nRF52 AtomicFileWriter validate and
+publish persisted state using their actual platform-specific implementations.
+"""
+
+from pathlib import Path
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+from cpp_source import body
+
+ROOT = Path(__file__).resolve().parents[1]
+
+UTILS = r'''
+#pragma once
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#define PUB_KEY_SIZE 32
+#define PRV_KEY_SIZE 64
+#define SIGNATURE_SIZE 64
+#define PATH_HASH_SIZE 1
+#define CIPHER_MAC_SIZE 2
+#define CIPHER_BLOCK_SIZE 16
+inline unsigned hash_calls = 0, decrypt_calls = 0, verify_calls = 0, last_jitter_max = 0;
+namespace mesh {
+class RNG { public: uint32_t nextInt(uint32_t low, uint32_t high) {
+ assert(low == 500 && (high == 60500 || high == 15500 || high == 1500)); last_jitter_max=high; return 1000;
+}};
+class Utils { public:
+ static void sha256(uint8_t* out, size_t count, const uint8_t* data, int length) {
+ ++hash_calls; SHA256 hash; hash.update(data, length); hash.finalize(out, count);
+ }
+ static void toHex(char* out, const uint8_t* data, size_t length) {
+ for (size_t i = 0; i < length; ++i) sprintf(out + 2*i, "%02x", data[i]);
+ out[2*length] = 0;
+ }
+ static bool fromHex(uint8_t* out, int length, const char* hex) {
+ if (strlen(hex) != size_t(2*length)) return false;
+ for (int i=0;i='0'&&c<='9'?c-'0':c>='a'&&c<='f'?c-'a'+10:
+ c>='A'&&c<='F'?c-'A'+10:-1; };
+ int a=digit(hex[2*i]), b=digit(hex[2*i+1]); if(a<0||b<0) return false;
+ out[i]=uint8_t(a*16+b);
+ } return true;
+ }
+ static int encryptThenMAC(const uint8_t* key, uint8_t* out, const uint8_t* data, int size) {
+ uint8_t padded[256] = {}; const int rounded = (size+15)/16*16;
+ assert(rounded <= int(sizeof(padded))); memcpy(padded,data,size);
+ EVP_CIPHER_CTX* ctx=EVP_CIPHER_CTX_new(); assert(ctx);
+ assert(EVP_EncryptInit_ex(ctx,EVP_aes_128_ecb(),nullptr,key,nullptr));
+ EVP_CIPHER_CTX_set_padding(ctx,0); int produced=0, tail=0;
+ assert(EVP_EncryptUpdate(ctx,out+CIPHER_MAC_SIZE,&produced,padded,rounded));
+ assert(EVP_EncryptFinal_ex(ctx,out+CIPHER_MAC_SIZE+produced,&tail));
+ EVP_CIPHER_CTX_free(ctx); assert(produced+tail==rounded);
+ uint8_t mac[32]; unsigned mac_len=0;
+ assert(HMAC(EVP_sha256(),key,32,out+CIPHER_MAC_SIZE,rounded,mac,&mac_len));
+ memcpy(out,mac,CIPHER_MAC_SIZE); return rounded+CIPHER_MAC_SIZE;
+ }
+ static int MACThenDecrypt(const uint8_t* key,uint8_t* out,const uint8_t* data,int size) {
+ ++decrypt_calls; int cipher_size=size-CIPHER_MAC_SIZE;
+ if(cipher_size<=0 || cipher_size%16) return 0;
+ uint8_t mac[32]; unsigned mac_len=0;
+ assert(HMAC(EVP_sha256(),key,32,data+CIPHER_MAC_SIZE,cipher_size,mac,&mac_len));
+ if(memcmp(mac,data,CIPHER_MAC_SIZE)) return 0;
+ EVP_CIPHER_CTX* ctx=EVP_CIPHER_CTX_new(); assert(ctx);
+ assert(EVP_DecryptInit_ex(ctx,EVP_aes_128_ecb(),nullptr,key,nullptr));
+ EVP_CIPHER_CTX_set_padding(ctx,0); int produced=0,tail=0;
+ assert(EVP_DecryptUpdate(ctx,out,&produced,data+CIPHER_MAC_SIZE,cipher_size));
+ assert(EVP_DecryptFinal_ex(ctx,out+produced,&tail)); EVP_CIPHER_CTX_free(ctx);
+ return produced+tail;
+ }
+};
+}
+'''
+
+FILESYSTEM = r'''
+#pragma once
+#include