mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-22 13:01:37 +00:00
Add signed private fleet controls for filtering and secondary radio
Enroll a private channel and publisher public key on repeaters and room servers. Local Companion fleet sends sign targeted or whole-fleet commands, with expiry, durable replay protection, and a restricted command allowlist. Preserve exact transmitted-reply barriers for secondary-radio changes, bound validation and replies, and qualify crypto, persistence, transport scope, role permissions, and compact-profile exclusions with regression tests.
This commit is contained in:
1 parent
a14bbdc58f
commit
d83fa7cf8e
27 files changed
+3028
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
---
|
||||
layout: default
|
||||
title: Private fleet management
|
||||
---
|
||||
<!-- meshcore-hosted-doc-link:start -->
|
||||
<p class="meshcore-hosted-doc-link"><a href="https://mikecarper.github.io/MeshCore/fleet_management/">View this page on MeshCore Docs</a>.</p>
|
||||
<!-- meshcore-hosted-doc-link:end -->
|
||||
|
||||
|
||||
# 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 <publisher-public-key-64-hex>
|
||||
set fleet.channel <private-channel-key-32-hex>
|
||||
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 <tuple|off>` |
|
||||
| Temporary second profile | `get tempradio2`, `set tempradio2 <tuple|off>` |
|
||||
| Profile diagnostics | `get radio2.status`, `get radio2.timing` |
|
||||
| Crossover | `get radio2.cross`, `set radio2.cross <auto|on|off>` |
|
||||
| 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).
|
||||
@@ -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.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "NotificationSettingsFile.h"
|
||||
#include "MyMesh.h"
|
||||
#include <helpers/CompanionTxRoutingCLI.h>
|
||||
#include <helpers/FleetCommand.h>
|
||||
#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 <channel-index> <all|64-hex-key> <command>");
|
||||
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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
#include <helpers/RepeaterRadioTiming.h>
|
||||
#include <helpers/RadioProfileCLI.h>
|
||||
#include <helpers/WirelessControl.h>
|
||||
#include <helpers/FleetCommand.h>
|
||||
#if MESH_ENABLE_FLEET_CONTROL
|
||||
#include <helpers/FleetChannel.h>
|
||||
#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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
#include "FleetChannel.h"
|
||||
#include "FilePresence.h"
|
||||
#include "FileRead.h"
|
||||
#include "PersistentStoreFormat.h"
|
||||
#include <Arduino.h>
|
||||
#include <stdio.h>
|
||||
#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<FILESYSTEM>)) 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<FILESYSTEM>);
|
||||
#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<Dispatcher&>(mesh).onSendFail(packet);
|
||||
mesh.releasePacket(packet);
|
||||
}
|
||||
}
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,100 @@
|
||||
#pragma once
|
||||
|
||||
#include <Mesh.h>
|
||||
#include <helpers/IdentityStore.h>
|
||||
#include <helpers/RadioProfileCLI.h>
|
||||
#include <helpers/TempRadioReplyBarrier.h>
|
||||
#include <helpers/FleetCommand.h>
|
||||
#include <helpers/TransportKeyStore.h>
|
||||
|
||||
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 <typename Regions, typename Region>
|
||||
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 <typename Handler>
|
||||
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
|
||||
@@ -0,0 +1,262 @@
|
||||
#include "FleetCommand.h"
|
||||
#include <SHA256.h>
|
||||
#include <string.h>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#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
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
#ifndef MESH_ENABLE_FLEET_CONTROL
|
||||
#define MESH_ENABLE_FLEET_CONTROL 1
|
||||
#endif
|
||||
|
||||
#include <cassert>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
// 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<class Signer>
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -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 <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <helpers/FleetCommand.h>
|
||||
#include <openssl/evp.h>
|
||||
|
||||
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 <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <helpers/FleetCommand.h>
|
||||
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()
|
||||
@@ -18,6 +18,7 @@ HARNESS = r'''
|
||||
#include <helpers/RadioProfileCLI.h>
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include <helpers/radiolib/RXPowerSaving.h>
|
||||
#include "companion_fleet_dispatch.h"
|
||||
#include <sys/stat.h>
|
||||
#include <limits>
|
||||
#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'),
|
||||
|
||||
@@ -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 <cstdint>
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
#include <cassert>
|
||||
#include <SHA256.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/hmac.h>
|
||||
#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<length;++i) {
|
||||
auto digit=[](char c) { return c>='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 <map>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <memory>
|
||||
#include <cstring>
|
||||
#include <algorithm>
|
||||
namespace fs { class FS; }
|
||||
class File {
|
||||
fs::FS* owner_=nullptr; std::string path_; size_t cursor_=0; bool live_=false;
|
||||
public:
|
||||
File() = default;
|
||||
File(fs::FS* owner,const char* path,bool live):owner_(owner),path_(path),live_(live){}
|
||||
explicit File(fs::FS& owner):owner_(&owner){}
|
||||
bool open(const char* path,int flags);
|
||||
explicit operator bool() const { return live_; }
|
||||
bool isDirectory() const { return false; }
|
||||
size_t size() const;
|
||||
size_t read(uint8_t* data,size_t length);
|
||||
size_t write(const uint8_t* data,size_t length);
|
||||
void flush() {}
|
||||
void close() {live_=false;}
|
||||
};
|
||||
namespace fs {
|
||||
class FS { public:
|
||||
std::map<std::string,std::vector<uint8_t>> files;
|
||||
bool fail_open=false, fail_read=false, fail_write=false, fail_rename=false;
|
||||
unsigned reads=0,writes=0,renames=0;
|
||||
std::string fail_rename_from;
|
||||
void _lockFS() {} void _unlockFS() {} FS* _getFS(){return this;}
|
||||
bool exists(const char* path) {return files.count(path)!=0;}
|
||||
bool remove(const char* path) {return files.erase(path)!=0;}
|
||||
bool mkdir(const char*) {return true;}
|
||||
bool rename(const char* old,const char* next) {
|
||||
++renames; if(fail_rename || fail_rename_from==old || !exists(old)) return false;
|
||||
files[next]=std::move(files[old]);files.erase(old);return true;
|
||||
}
|
||||
File open(const char* path,const char* mode="r") {
|
||||
if(!path||fail_open) return {};
|
||||
if(mode[0]=='w') {files[path].clear();return File(this,path,true);}
|
||||
return File(this,path,exists(path));
|
||||
}
|
||||
};
|
||||
}
|
||||
inline bool File::open(const char* path,int flags){
|
||||
if(!owner_)return false;*this=owner_->open(path,flags==2?"w":"r");return live_;
|
||||
}
|
||||
inline size_t File::size() const {return live_?owner_->files[path_].size():0;}
|
||||
inline size_t File::read(uint8_t* data,size_t length) {
|
||||
++owner_->reads; if(!live_||owner_->fail_read) return 0;
|
||||
auto& bytes=owner_->files[path_];size_t n=std::min(length,bytes.size()-cursor_);
|
||||
memcpy(data,bytes.data()+cursor_,n);cursor_+=n;return n;
|
||||
}
|
||||
inline size_t File::write(const uint8_t* data,size_t length) {
|
||||
++owner_->writes; if(!live_||owner_->fail_write) return 0;
|
||||
auto& bytes=owner_->files[path_];bytes.insert(bytes.end(),data,data+length);return length;
|
||||
}
|
||||
'''
|
||||
|
||||
MESH = r'''
|
||||
#pragma once
|
||||
#include <Identity.h>
|
||||
#include <FS.h>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
#define MAX_PACKET_PAYLOAD 184
|
||||
#define PAYLOAD_TYPE_GRP_TXT 5
|
||||
#define PAYLOAD_TYPE_GRP_DATA 6
|
||||
#define FLOOD_RETRY_POLICY_DENY 1
|
||||
inline uint32_t fake_ms = 1000;
|
||||
inline uint32_t millis() {return fake_ms;}
|
||||
namespace mesh {
|
||||
struct Packet {
|
||||
uint8_t type=PAYLOAD_TYPE_GRP_DATA,payload[MAX_PACKET_PAYLOAD]={},flood_retry_policy=0;
|
||||
uint16_t payload_len=0,transport_codes[2]={};
|
||||
uint32_t radio_generation=0;uint8_t radio_profile=0,path_hash_size=1;
|
||||
bool scoped=false,radio_reply=false,radio_bound=false;
|
||||
uint8_t getPayloadType() const{return type;}
|
||||
uint8_t getPathHashSize() const{return path_hash_size;}
|
||||
bool hasTransportCodes() const{return scoped;}
|
||||
};
|
||||
struct GroupChannel {uint8_t hash[PATH_HASH_SIZE]={},secret[32]={};};
|
||||
struct Clock {uint32_t now=1735689600UL+1000;
|
||||
uint32_t getCurrentTime() const{return now;}};
|
||||
class Mesh;
|
||||
class Dispatcher {public:
|
||||
class ReceiveProfileScope {Mesh& mesh_;uint8_t old_;uint32_t generation_;
|
||||
public:ReceiveProfileScope(Mesh&,uint8_t,uint32_t);~ReceiveProfileScope();};
|
||||
virtual void onSendFail(Packet*)=0;
|
||||
};
|
||||
class PacketManager {public:
|
||||
std::vector<Packet*> outbound;
|
||||
int getOutboundTotal() const{return int(outbound.size());}
|
||||
Packet* getOutboundByIdx(int i){return outbound.at(i);}
|
||||
Packet* removeOutboundByIdx(int i){Packet* p=outbound.at(i);outbound.erase(outbound.begin()+i);return p;}
|
||||
};
|
||||
class Mesh:public Dispatcher {public:
|
||||
LocalIdentity self_id;Clock clock;RNG rng;PacketManager manager;PacketManager* _mgr=&manager;
|
||||
std::function<void(Packet*,const Packet*)> copying;
|
||||
std::function<void(Packet*)> failure;
|
||||
std::vector<Packet*> allocated;
|
||||
Packet* in_flight=nullptr;bool queue_ok=true,allocation_ok=true,fanout=false;
|
||||
unsigned queued=0,cancelled=0,released=0;
|
||||
uint8_t scoped_profile=0,ack_path_size=0;uint32_t scoped_generation=0,ack_delay=0;
|
||||
bool ack_scoped=false;uint16_t ack_codes[2]={};
|
||||
~Mesh(){for(Packet* p:allocated)delete p;}
|
||||
Clock* getRTCClock(){return &clock;}RNG* getRNG(){return &rng;}
|
||||
Packet* createGroupDatagram(uint8_t type,const GroupChannel& channel,const uint8_t* data,size_t length){
|
||||
assert(length<=168);if(!allocation_ok)return nullptr;Packet* p=new Packet;allocated.push_back(p);p->type=type;
|
||||
p->payload[0]=channel.hash[0];p->payload_len=1+Utils::encryptThenMAC(channel.secret,p->payload+1,data,int(length));return p;
|
||||
}
|
||||
bool sendFlood(Packet* p,uint32_t delay,uint8_t hashes){
|
||||
ack_delay=delay;ack_path_size=hashes;
|
||||
if(!queue_ok)return false;manager.outbound.push_back(p);++queued;
|
||||
if(fanout){Packet* second=new Packet(*p);allocated.push_back(second);manager.outbound.push_back(second);
|
||||
if(copying)copying(second,p);}
|
||||
return true;
|
||||
}
|
||||
bool sendFlood(Packet* p,const uint16_t* codes,uint32_t delay,uint8_t hashes){
|
||||
ack_scoped=true;memcpy(ack_codes,codes,sizeof(ack_codes));return sendFlood(p,delay,hashes);
|
||||
}
|
||||
const Packet* getOutboundInFlight()const{return in_flight;}
|
||||
void cancelOutboundRadioRetry(const Packet* p){assert(p==in_flight);++cancelled;}
|
||||
void onSendFail(Packet* p)override{if(failure)failure(p);}
|
||||
void releasePacket(Packet*){++released;}
|
||||
};
|
||||
inline Dispatcher::ReceiveProfileScope::ReceiveProfileScope(Mesh& m,uint8_t profile,uint32_t generation)
|
||||
:mesh_(m),old_(m.scoped_profile),generation_(m.scoped_generation){m.scoped_profile=profile;m.scoped_generation=generation;}
|
||||
inline Dispatcher::ReceiveProfileScope::~ReceiveProfileScope(){mesh_.scoped_profile=old_;mesh_.scoped_generation=generation_;}
|
||||
}
|
||||
'''
|
||||
|
||||
PROFILE = r'''
|
||||
#pragma once
|
||||
namespace mesh {
|
||||
class RadioProfileCLI {public:
|
||||
bool mutation=false,command=false;uint32_t generation=0;
|
||||
unsigned finishes=0,commits=0,rollbacks=0;
|
||||
void beginReplyCommand(){command=true;}void endReplyCommand(){command=false;}
|
||||
bool hasReplyMutation()const{return mutation;}
|
||||
uint32_t replyMutationGeneration()const{return generation;}
|
||||
void stage(){assert(command);mutation=true;++generation;}
|
||||
bool finishReplyMutation(bool delivered){assert(mutation);mutation=false;++finishes;
|
||||
if(delivered)++commits;else ++rollbacks;return true;}
|
||||
};
|
||||
}
|
||||
'''
|
||||
|
||||
HARNESS = r'''
|
||||
#include <helpers/FleetChannel.h>
|
||||
#include <helpers/PersistentStoreFormat.h>
|
||||
#include <cassert>
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
@TRANSPORT_METHODS@
|
||||
|
||||
inline unsigned identity_counter=0;
|
||||
namespace mesh {
|
||||
Identity::Identity(){memset(pub_key,0,sizeof(pub_key));}
|
||||
LocalIdentity::LocalIdentity():Identity(){
|
||||
memset(prv_key,0xAA+identity_counter++,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);
|
||||
memcpy(prv_key+32,pub_key,32);EVP_PKEY_free(key);
|
||||
}
|
||||
void LocalIdentity::sign(uint8_t* sig,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* ctx=EVP_MD_CTX_new();assert(key&&ctx);assert(EVP_DigestSignInit(ctx,nullptr,nullptr,nullptr,key)==1);
|
||||
size_t size=64;assert(EVP_DigestSign(ctx,sig,&size,data,length)==1);assert(size==64);
|
||||
EVP_MD_CTX_free(ctx);EVP_PKEY_free(key);
|
||||
}
|
||||
bool Identity::verify(const uint8_t* sig,const uint8_t* data,int length)const{
|
||||
++verify_calls;EVP_PKEY* key=EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519,nullptr,pub_key,32);
|
||||
EVP_MD_CTX* ctx=EVP_MD_CTX_new();assert(key&&ctx);assert(EVP_DigestVerifyInit(ctx,nullptr,nullptr,nullptr,key)==1);
|
||||
bool valid=EVP_DigestVerify(ctx,sig,64,data,length)==1;EVP_MD_CTX_free(ctx);EVP_PKEY_free(key);return valid;
|
||||
}
|
||||
}
|
||||
using namespace mesh;
|
||||
static constexpr const char* Path="/fleet_channel";
|
||||
static constexpr const char* Key="21212121212121212121212121212121";
|
||||
static uint8_t channel_key[32]={0x21,0x21,0x21,0x21,0x21,0x21,0x21,0x21,
|
||||
0x21,0x21,0x21,0x21,0x21,0x21,0x21,0x21};
|
||||
static std::string config(FleetChannel& fleet,const std::string& command){
|
||||
char reply[160]={};assert(fleet.handleConfig(command.c_str(),reply,sizeof(reply)));return reply;
|
||||
}
|
||||
static void enroll(FleetChannel& fleet,const LocalIdentity& publisher){
|
||||
assert(config(fleet,std::string("set fleet.channel ")+Key).rfind("OK",0)==0);
|
||||
char hex[65];Utils::toHex(hex,publisher.pub_key,32);
|
||||
assert(config(fleet,std::string("set fleet.controller ")+hex).rfind("OK",0)==0);
|
||||
}
|
||||
static Packet raw(const uint8_t* bytes,size_t length){
|
||||
Packet packet;Utils::sha256(packet.payload,1,channel_key,16);
|
||||
packet.payload_len=1+Utils::encryptThenMAC(channel_key,packet.payload+1,bytes,int(length));
|
||||
packet.radio_profile=1;packet.radio_generation=17;packet.path_hash_size=2;
|
||||
return packet;
|
||||
}
|
||||
static Packet command(const LocalIdentity& publisher,uint32_t sequence,const char* text="set radio2 off",
|
||||
const char* target="all"){
|
||||
uint8_t payload[184]={},destination[16];assert(FleetCommand::parseTarget(target,destination));
|
||||
size_t length=FleetCommand::encode(publisher,channel_key,sequence,sequence+300,destination,text,payload+3,sizeof(payload)-3);
|
||||
assert(length);payload[0]=uint8_t(FleetCommand::DataType);payload[1]=uint8_t(FleetCommand::DataType>>8);payload[2]=uint8_t(length);
|
||||
return raw(payload,3+length);
|
||||
}
|
||||
|
||||
static Packet signedUnchecked(const LocalIdentity& publisher,uint32_t sequence,uint32_t expires,
|
||||
const char* text){
|
||||
uint8_t data[184]={},message[160]={};size_t text_size=strlen(text);
|
||||
assert(text_size<=FleetCommand::MaxCommandLength);
|
||||
uint8_t* payload=data+3;memcpy(payload,"FMC1",4);
|
||||
storage::writeLE32(payload+4,sequence);storage::writeLE32(payload+8,expires);
|
||||
payload[28]=uint8_t(text_size);memcpy(payload+29,text,text_size);
|
||||
constexpr const char* domain="MeshCoreFleet1";const size_t domain_size=strlen(domain);
|
||||
memcpy(message,domain,domain_size);memcpy(message+domain_size,channel_key,16);
|
||||
memcpy(message+domain_size+16,payload,29+text_size);
|
||||
publisher.sign(payload+29+text_size,message,int(domain_size+16+29+text_size));
|
||||
data[0]=uint8_t(FleetCommand::DataType);data[1]=uint8_t(FleetCommand::DataType>>8);
|
||||
data[2]=uint8_t(29+text_size+64);return raw(data,3+data[2]);
|
||||
}
|
||||
|
||||
static void bind(FleetChannel& fleet,Mesh& mesh,RadioProfileCLI& profiles){
|
||||
mesh.copying=[&](Packet* second,const Packet* first){fleet.copy(second,first);};
|
||||
mesh.failure=[&](Packet* p){fleet.fail(p,profiles);};
|
||||
}
|
||||
static unsigned apply(FleetChannel& fleet,fs::FS& fs,Mesh& mesh,RadioProfileCLI& profiles,Packet& packet,
|
||||
bool mutation=false,bool fail_handler=false,const TransportKey* scope=nullptr){
|
||||
unsigned calls=0;fleet.receive(&packet,mesh,scope);
|
||||
fleet.service(mesh,profiles,"test:node",[&](uint32_t seq,const char* text,char* reply){
|
||||
++calls;assert(storage::readLE32(fs.files.at(Path).data()+56)==seq);
|
||||
assert(mesh.scoped_profile==1&&mesh.scoped_generation==17);assert(profiles.command);
|
||||
assert(!strcmp(text,"set radio2 off")||!strcmp(text,"get radio2.status"));
|
||||
if(mutation)profiles.stage();strcpy(reply,fail_handler?"Err - rejected":"OK");
|
||||
});
|
||||
assert(mesh.scoped_profile==0&&mesh.scoped_generation==0);assert(!profiles.command);return calls;
|
||||
}
|
||||
static void reseal(std::vector<uint8_t>& image){
|
||||
storage::writeLE32(image.data()+60,storage::updateCRC32(0xffffffff,image.data(),60));
|
||||
}
|
||||
int main(int argc,char** argv){
|
||||
assert(argc==2);const std::string scenario=argv[1];fs::FS fs;Mesh mesh;RadioProfileCLI profiles;
|
||||
FleetChannel fleet(&fs);LocalIdentity publisher;bind(fleet,mesh,profiles);
|
||||
if(scenario=="enrollment"){
|
||||
Packet packet=command(publisher,mesh.clock.now);assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
assert(config(fleet,"get fleet.channel").find("off,controller=unset,store=ok")!=std::string::npos);
|
||||
assert(config(fleet,"set fleet.channel 00000000000000000000000000000000").rfind("Err",0)==0);
|
||||
assert(config(fleet,"set fleet.channel 8b3387e9c5cdea6ac9e5edbaa115cd72").rfind("Err",0)==0);
|
||||
assert(config(fleet,"set fleet.controller 0000000000000000000000000000000000000000000000000000000000000000").rfind("Err",0)==0);
|
||||
assert(config(fleet,std::string("set fleet.channel ")+Key).rfind("OK",0)==0);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==0);enroll(fleet,publisher);
|
||||
const auto& image=fs.files.at(Path);assert(image.size()==64);
|
||||
assert(!memcmp(image.data()+24,publisher.pub_key,32));
|
||||
const std::vector<uint8_t> private_seed(32,0xAB);
|
||||
assert(std::search(image.begin(),image.end(),private_seed.begin(),private_seed.end())==image.end());
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==1);
|
||||
assert(config(fleet,"set fleet.controller off").rfind("OK",0)==0);
|
||||
++mesh.clock.now;packet=command(publisher,mesh.clock.now);assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
}else if(scenario=="deferred_single_mailbox"){
|
||||
enroll(fleet,publisher);Packet a=command(publisher,mesh.clock.now),b=command(publisher,mesh.clock.now+1);
|
||||
unsigned decrypt_before=decrypt_calls,verify_before=verify_calls,writes_before=fs.writes;
|
||||
fleet.receive(&a,mesh);fleet.receive(&b,mesh);
|
||||
assert(decrypt_calls==decrypt_before&&verify_calls==verify_before&&fs.writes==writes_before);
|
||||
assert(config(fleet,"get fleet.stats").find("busy=1 pending=1")!=std::string::npos);
|
||||
assert(config(fleet,"set fleet.channel off").rfind("Err",0)==0);
|
||||
unsigned calls=0;fleet.service(mesh,profiles,"node",[&](uint32_t seq,const char*,char* reply){
|
||||
++calls;assert(seq==mesh.clock.now);strcpy(reply,"OK");});assert(calls==1);
|
||||
assert(config(fleet,"get fleet.stats").find("accepted=1")!=std::string::npos);
|
||||
}else if(scenario=="durable_replay"){
|
||||
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet,false,true)==1);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==0);FleetChannel rebooted(&fs);
|
||||
assert(apply(rebooted,fs,mesh,profiles,packet)==0);
|
||||
++mesh.clock.now;packet=command(publisher,mesh.clock.now);assert(apply(rebooted,fs,mesh,profiles,packet)==1);
|
||||
}else if(scenario=="reserve_fault"){
|
||||
enroll(fleet,publisher);const auto previous=fs.files.at(Path);fs.fail_write=true;
|
||||
Packet packet=command(publisher,mesh.clock.now);assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
assert(fs.files.at(Path)==previous);fs.fail_write=false;
|
||||
assert(config(fleet,"get fleet.channel").find("store=error")!=std::string::npos);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==0);FleetChannel rebooted(&fs);
|
||||
assert(apply(rebooted,fs,mesh,profiles,packet)==1);
|
||||
}else if(scenario=="config_fault"){
|
||||
enroll(fleet,publisher);const auto previous=fs.files.at(Path);fs.fail_write=true;
|
||||
assert(config(fleet,"set fleet.channel off").rfind("Err",0)==0);
|
||||
assert(fs.files.at(Path)==previous);fs.fail_write=false;
|
||||
assert(config(fleet,"get fleet.channel").find("on,controller=set")!=std::string::npos);
|
||||
FleetChannel rebooted(&fs);Packet packet=command(publisher,mesh.clock.now);
|
||||
assert(apply(rebooted,fs,mesh,profiles,packet)==1);
|
||||
}else if(scenario=="images"){
|
||||
enroll(fleet,publisher);const auto original=fs.files.at(Path);
|
||||
for(unsigned damage=0;damage<7;++damage){
|
||||
fs.files[Path]=original;auto& image=fs.files[Path];
|
||||
if(damage==0)image.resize(63);else if(damage==1)image.push_back(0);
|
||||
else if(damage==2)image[0]='X';else if(damage==3)image[4]=2;
|
||||
else if(damage==4)image[5]=1;else if(damage==5)image[60]^=1;
|
||||
else memset(image.data()+8,0,16);
|
||||
if(damage>=3&&damage!=5)reseal(image);
|
||||
FleetChannel bad(&fs);assert(config(bad,"get fleet.channel").find("store=error")!=std::string::npos);
|
||||
Packet packet=command(publisher,mesh.clock.now);assert(apply(bad,fs,mesh,profiles,packet)==0);
|
||||
assert(config(bad,std::string("set fleet.channel ")+Key).rfind("Err",0)==0);
|
||||
}
|
||||
fs.files[Path]=original;fs.fail_read=true;FleetChannel unreadable(&fs);fs.fail_read=false;
|
||||
assert(config(unreadable,"get fleet.channel").find("store=error")!=std::string::npos);
|
||||
#if defined(RP2040_PLATFORM)
|
||||
fs.files.erase(Path);fs.files[std::string(Path)+".bak"]=original;
|
||||
FleetChannel recovered(&fs);assert(config(recovered,"get fleet.channel").find("store=ok")!=std::string::npos);
|
||||
assert(fs.files[Path]==original);Packet packet=command(publisher,mesh.clock.now);
|
||||
assert(apply(recovered,fs,mesh,profiles,packet)==1);
|
||||
#endif
|
||||
}else if(scenario=="ack_both_copies"){
|
||||
enroll(fleet,publisher);mesh.fanout=true;Packet packet=command(publisher,mesh.clock.now);
|
||||
TransportKey scope;memset(scope.key,0x12,sizeof(scope.key));
|
||||
packet.scoped=true;packet.transport_codes[0]=scope.calcTransportCode(&packet);packet.transport_codes[1]=456;
|
||||
assert(apply(fleet,fs,mesh,profiles,packet,true,false,&scope)==1);assert(fleet.waiting());
|
||||
assert(mesh.manager.outbound.size()==2);assert(mesh.ack_scoped&&mesh.ack_codes[1]==0);
|
||||
assert(mesh.ack_codes[0]==scope.calcTransportCode(mesh.manager.outbound[0]));
|
||||
assert(mesh.ack_codes[0]!=packet.transport_codes[0]);
|
||||
assert(mesh.ack_delay==1000&&mesh.ack_path_size==2&&last_jitter_max==15500);
|
||||
assert(mesh.manager.outbound[0]->radio_reply&&!mesh.manager.outbound[0]->radio_bound);
|
||||
assert(mesh.manager.outbound[0]->flood_retry_policy==FLOOD_RETRY_POLICY_DENY);
|
||||
fleet.receive(&packet,mesh);assert(config(fleet,"get fleet.stats").find("busy=1")!=std::string::npos);
|
||||
fleet.complete(mesh.manager.removeOutboundByIdx(0),profiles);assert(fleet.waiting());assert(profiles.commits==0);
|
||||
fleet.fail(mesh.manager.removeOutboundByIdx(0),profiles);assert(!fleet.waiting());assert(profiles.commits==1&&profiles.rollbacks==0);
|
||||
}else if(scenario=="ack_all_fail"){
|
||||
enroll(fleet,publisher);mesh.fanout=true;Packet packet=command(publisher,mesh.clock.now);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet,true)==1);
|
||||
fleet.fail(mesh.manager.removeOutboundByIdx(0),profiles);assert(fleet.waiting()&&profiles.rollbacks==0);
|
||||
fleet.fail(mesh.manager.removeOutboundByIdx(0),profiles);assert(!fleet.waiting());assert(profiles.rollbacks==1);
|
||||
}else if(scenario=="ack_queue_or_allocation"){
|
||||
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);mesh.queue_ok=false;
|
||||
assert(apply(fleet,fs,mesh,profiles,packet,true)==1);assert(!fleet.waiting()&&profiles.rollbacks==1);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet,true)==0);mesh.queue_ok=true;mesh.allocation_ok=false;
|
||||
++mesh.clock.now;packet=command(publisher,mesh.clock.now);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet,true)==1);assert(profiles.rollbacks==2);
|
||||
}else if(scenario=="ack_timeout"){
|
||||
enroll(fleet,publisher);mesh.fanout=true;Packet packet=command(publisher,mesh.clock.now);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet,true)==1);fake_ms+=300000;
|
||||
fleet.service(mesh,profiles,"node",[](uint32_t,const char*,char*){assert(false);});
|
||||
assert(mesh.manager.outbound.empty()&&mesh.released==2);assert(profiles.rollbacks==1&&!fleet.waiting());
|
||||
++mesh.clock.now;packet=command(publisher,mesh.clock.now);assert(apply(fleet,fs,mesh,profiles,packet,true)==1);
|
||||
mesh.in_flight=mesh.manager.removeOutboundByIdx(0);fake_ms+=300000;
|
||||
fleet.service(mesh,profiles,"node",[](uint32_t,const char*,char*){assert(false);});
|
||||
assert(mesh.cancelled==1&&fleet.waiting());fleet.fail(mesh.in_flight,profiles);mesh.in_flight=nullptr;
|
||||
assert(!fleet.waiting()&&profiles.rollbacks==2);
|
||||
}else if(scenario=="tamper_padding_target"){
|
||||
enroll(fleet,publisher);Packet good=command(publisher,mesh.clock.now);
|
||||
uint8_t data[184];int size=Utils::MACThenDecrypt(channel_key,data,good.payload+1,good.payload_len-1);
|
||||
data[3+29]^=1;Packet forged=raw(data,size);assert(apply(fleet,fs,mesh,profiles,forged)==0);
|
||||
size=Utils::MACThenDecrypt(channel_key,data,good.payload+1,good.payload_len-1);
|
||||
data[size-1]=1;Packet padded=raw(data,size);assert(apply(fleet,fs,mesh,profiles,padded)==0);
|
||||
Packet mac=good;mac.payload[1]^=1;assert(apply(fleet,fs,mesh,profiles,mac)==0);
|
||||
char target[65];uint8_t other[32];memset(other,0x23,sizeof(other));Utils::toHex(target,other,sizeof(other));
|
||||
Packet wrong=command(publisher,mesh.clock.now,"set radio2 off",target);assert(apply(fleet,fs,mesh,profiles,wrong)==0);
|
||||
fake_ms+=1000;Utils::toHex(target,mesh.self_id.pub_key,32);
|
||||
Packet mine=command(publisher,mesh.clock.now,"set radio2 off",target);assert(apply(fleet,fs,mesh,profiles,mine)==1);
|
||||
assert(last_jitter_max==1500);
|
||||
}else if(scenario=="verify_budget"){
|
||||
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);
|
||||
packet.payload[1]^=1;unsigned before=decrypt_calls;
|
||||
for(unsigned i=0;i<20;++i)assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
assert(decrypt_calls-before==4);fake_ms+=1000;packet=command(publisher,mesh.clock.now);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==1);
|
||||
}else if(scenario=="primary_denied"){
|
||||
assert(!FleetCommand::commandAllowed("set radio 915,500,5,5"));
|
||||
assert(!FleetCommand::commandAllowed("set tempradio 915,500,5,5,10"));
|
||||
assert(!FleetCommand::commandAllowed("set password pass"));
|
||||
assert(!FleetCommand::commandAllowed("reboot"));
|
||||
assert(!FleetCommand::commandAllowed("set fleet.controller off"));
|
||||
|
||||
}else if(scenario=="signed_forbidden"){
|
||||
enroll(fleet,publisher);
|
||||
for(const char* text:{"set radio 915,500,5,5","set password pass","reboot",
|
||||
"set fleet.channel off","get prv.key","set flood.max 5;reboot"}){
|
||||
fake_ms+=1000;Packet packet=signedUnchecked(publisher,mesh.clock.now,mesh.clock.now+300,text);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
}
|
||||
assert(storage::readLE32(fs.files[Path].data()+56)==0);assert(mesh.queued==0);
|
||||
}else if(scenario=="clock_limits"){
|
||||
enroll(fleet,publisher);const uint32_t now=mesh.clock.now;
|
||||
const std::vector<std::pair<uint32_t,uint32_t>> invalid={{now-301,now-1},{now+61,now+100},
|
||||
{FleetCommand::MinEpoch-1,now+100},{now,now+FleetCommand::MaxLifetime+1},{now,now-1}};
|
||||
for(const auto& times:invalid){fake_ms+=1000;
|
||||
Packet packet=signedUnchecked(publisher,times.first,times.second,"set radio2 off");
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
}
|
||||
mesh.clock.now=0;fake_ms+=1000;Packet packet=command(publisher,now);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==0);mesh.clock.now=now;
|
||||
fake_ms+=1000;packet=signedUnchecked(publisher,now+60,now+100,"set radio2 off");
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==1);
|
||||
}else if(scenario=="transaction_faults"){
|
||||
for(unsigned fault=0;fault<4;++fault){
|
||||
fs::FS disk;Mesh transport;RadioProfileCLI radio;FleetChannel receiver(&disk);enroll(receiver,publisher);
|
||||
auto previous=disk.files.at(Path);Packet packet=command(publisher,transport.clock.now);
|
||||
if(fault==0)disk.fail_open=true;
|
||||
else if(fault==1)disk.fail_read=true;
|
||||
else if(fault==2)disk.fail_rename=true;
|
||||
else disk.fail_rename_from=std::string(Path)+".tmp";
|
||||
assert(apply(receiver,disk,transport,radio,packet)==0);assert(disk.files.at(Path)==previous);
|
||||
assert(config(receiver,"get fleet.channel").find("store=error")!=std::string::npos);
|
||||
}
|
||||
}else if(scenario=="backup_replay"){
|
||||
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==1);const auto persisted=fs.files.at(Path);
|
||||
#if defined(RP2040_PLATFORM)
|
||||
fs.files.erase(Path);fs.files[std::string(Path)+".bak"]=persisted;
|
||||
#else
|
||||
// Atomic LittleFS replacement never has a live-name gap. Abandoned .tmp
|
||||
// bytes are ignored; the committed live sequence remains authoritative.
|
||||
fs.files[std::string(Path)+".tmp"]=persisted;
|
||||
#endif
|
||||
FleetChannel rebooted(&fs);assert(apply(rebooted,fs,mesh,profiles,packet)==0);
|
||||
assert(storage::readLE32(fs.files.at(Path).data()+56)==mesh.clock.now);
|
||||
|
||||
}else if(scenario=="wrong_publisher"){
|
||||
enroll(fleet,publisher);LocalIdentity stranger;
|
||||
assert(memcmp(stranger.pub_key,publisher.pub_key,32));
|
||||
Packet packet=command(stranger,mesh.clock.now);assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
assert(verify_calls==1&&mesh.queued==0&&storage::readLE32(fs.files[Path].data()+56)==0);
|
||||
}else if(scenario=="bounded_ack"){
|
||||
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);
|
||||
fleet.receive(&packet,mesh);fleet.service(mesh,profiles,"sixteen:character:very long name",
|
||||
[&](uint32_t,const char*,char* reply){memset(reply,'x',159);reply[159]=0;});
|
||||
assert(mesh.queued==1);uint8_t data[184]={};Packet* ack=mesh.manager.outbound[0];
|
||||
assert(ack->payload_len<=MAX_PACKET_PAYLOAD);
|
||||
assert(Utils::MACThenDecrypt(channel_key,data,ack->payload+1,ack->payload_len-1)==176);
|
||||
assert(!strncmp((char*)data+5,"sixteen;characte",15));
|
||||
}else if(scenario=="unknown_scope"){
|
||||
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);packet.scoped=true;
|
||||
packet.transport_codes[0]=123;assert(apply(fleet,fs,mesh,profiles,packet,true)==1);
|
||||
assert(mesh.queued==0&&mesh.released==1&&profiles.rollbacks==1&&!fleet.waiting());
|
||||
}else if(scenario=="rotated_scope"){
|
||||
struct Region {bool wildcard=false;bool isWildcard()const{return wildcard;}};
|
||||
struct Regions {TransportKey keys[2];int getTransportKeysFor(const Region&,TransportKey* out,int max){
|
||||
assert(max==MAX_TKS_ENTRIES);memcpy(out,keys,sizeof(keys));return 2;}} regions;
|
||||
memset(regions.keys[0].key,0x11,16);memset(regions.keys[1].key,0x22,16);
|
||||
Region region;TransportKey captured{};Packet packet=command(publisher,mesh.clock.now);
|
||||
packet.scoped=true;packet.transport_codes[0]=regions.keys[1].calcTransportCode(&packet);
|
||||
assert(packet.transport_codes[0]!=regions.keys[0].calcTransportCode(&packet));
|
||||
assert(captureFleetReplyScope(regions,®ion,&packet,captured));
|
||||
assert(!memcmp(captured.key,regions.keys[1].key,16));
|
||||
enroll(fleet,publisher);assert(apply(fleet,fs,mesh,profiles,packet,true,false,&captured)==1);
|
||||
assert(mesh.ack_codes[0]==regions.keys[1].calcTransportCode(mesh.manager.outbound[0]));
|
||||
assert(mesh.ack_codes[0]!=packet.transport_codes[0]);
|
||||
region.wildcard=true;assert(!captureFleetReplyScope(regions,®ion,&packet,captured));
|
||||
assert(!captureFleetReplyScope(regions,static_cast<Region*>(nullptr),&packet,captured));
|
||||
region.wildcard=false;packet.transport_codes[0]^=1;
|
||||
assert(!captureFleetReplyScope(regions,®ion,&packet,captured));
|
||||
}else if(scenario=="existing_mutation"){
|
||||
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);
|
||||
profiles.mutation=true;assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
assert(storage::readLE32(fs.files[Path].data()+56)==0);
|
||||
profiles.mutation=false;assert(apply(fleet,fs,mesh,profiles,packet)==1);
|
||||
}else assert(false);
|
||||
printf("fleet runtime %s passed\n",argv[1]);
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class FleetChannelRuntimeTests(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.work = tempfile.TemporaryDirectory(prefix="meshcore-fleet-runtime-")
|
||||
work = Path(cls.work.name)
|
||||
(work / "helpers").mkdir()
|
||||
for name, content in {"Utils.h": UTILS, "FS.h": FILESYSTEM, "Mesh.h": MESH,
|
||||
"helpers/RadioProfileCLI.h": PROFILE,
|
||||
"Packet.h": '#pragma once\n#include <Mesh.h>\n',
|
||||
"Adafruit_LittleFS.h": '''
|
||||
#pragma once
|
||||
#include <FS.h>
|
||||
using Adafruit_LittleFS = fs::FS;
|
||||
namespace Adafruit_LittleFS_Namespace {}
|
||||
#define FILE_O_READ 1
|
||||
#define FILE_O_WRITE 2
|
||||
#define LFS_ERR_NOENT -2
|
||||
struct lfs_info {};
|
||||
inline int lfs_stat(fs::FS* fs,const char* path,lfs_info*) {return fs->exists(path)?0:LFS_ERR_NOENT;}
|
||||
''',
|
||||
"InternalFileSystem.h": '#pragma once\n#include <FS.h>\ninline fs::FS InternalFS;\n',
|
||||
"Arduino.h": '#pragma once\n#include <Mesh.h>\n'}.items():
|
||||
(work / name).write_text(content)
|
||||
fixture = work / "fixture.cpp"
|
||||
transport_source = (ROOT / "src/helpers/TransportKeyStore.cpp").read_text()
|
||||
methods = "\n".join(body(transport_source, signature) for signature in [
|
||||
"uint16_t TransportKey::calcTransportCode(", "bool TransportKey::isNull("])
|
||||
fixture.write_text(HARNESS.replace("@TRANSPORT_METHODS@", methods))
|
||||
sha_header = ROOT / "test/mocks/SHA256.h"
|
||||
(work / "SHA256.h").write_text(f'''
|
||||
#pragma once
|
||||
#define SHA256 SHA256Base
|
||||
#include "{sha_header}"
|
||||
#undef SHA256
|
||||
class SHA256 : public SHA256Base {{ public:
|
||||
void finalize(void* out,size_t length) {{ SHA256Base::finalize(static_cast<uint8_t*>(out),length); }}
|
||||
void finalizeHMAC(const uint8_t* key,size_t size,void* out,size_t length) {{
|
||||
SHA256Base::finalizeHMAC(key,size,static_cast<uint8_t*>(out),length); }}
|
||||
}};
|
||||
''')
|
||||
cls.binaries = {}
|
||||
sanitizer_flags = (["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie"]
|
||||
if os.environ.get("MESHCORE_FLEET_SANITIZERS") == "1" else [])
|
||||
for backend in ["RP2040", "NRF52"]:
|
||||
binary = work / f"fleet-runtime-{backend.lower()}"
|
||||
sources = [str(ROOT / "src/helpers/FleetChannel.cpp"),
|
||||
str(ROOT / "src/helpers/FleetCommand.cpp")]
|
||||
if backend == "NRF52":
|
||||
sources.append(str(ROOT / "src/helpers/AtomicFileWriter.cpp"))
|
||||
result = subprocess.run([
|
||||
compiler, *sanitizer_flags, "-std=c++17", f"-D{backend}_PLATFORM",
|
||||
"-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter",
|
||||
"-Wno-misleading-indentation", "-Wno-sign-compare", "-O2", "-g",
|
||||
"-I", str(work), "-isystem", str(ROOT / "test/mocks"),
|
||||
"-I", str(ROOT / "src"), str(fixture), *sources,
|
||||
"-lcrypto", "-o", str(binary),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
if result.returncode:
|
||||
cls.work.cleanup()
|
||||
raise AssertionError(f"{backend}:\n" + result.stdout + result.stderr)
|
||||
cls.binaries[backend] = binary
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.work.cleanup()
|
||||
|
||||
def scenario(self, name):
|
||||
for backend, binary in self.binaries.items():
|
||||
with self.subTest(backend=backend):
|
||||
result = subprocess.run([str(binary), name], capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_private_channel_and_public_only_publisher_enrollment(self):
|
||||
self.scenario("enrollment")
|
||||
|
||||
def test_receive_defers_crypto_writes_and_accepts_one_pending_command(self):
|
||||
self.scenario("deferred_single_mailbox")
|
||||
|
||||
def test_sequence_reserved_before_handler_including_error_survives_reboot(self):
|
||||
self.scenario("durable_replay")
|
||||
|
||||
def test_failed_sequence_save_grants_no_control_and_disables_receiver(self):
|
||||
self.scenario("reserve_fault")
|
||||
|
||||
def test_failed_configuration_write_preserves_durable_enrollment(self):
|
||||
self.scenario("config_fault")
|
||||
|
||||
def test_corrupt_short_long_unreadable_images_fail_closed_and_backup_recovers(self):
|
||||
self.scenario("images")
|
||||
|
||||
def test_ack_waits_for_both_profile_copies_with_one_success_and_retains_rx_scope(self):
|
||||
self.scenario("ack_both_copies")
|
||||
|
||||
def test_both_ack_transmissions_failing_roll_back_radio_mutation(self):
|
||||
self.scenario("ack_all_fail")
|
||||
|
||||
def test_ack_allocation_queue_failure_rolls_back_but_keeps_reserved_sequence(self):
|
||||
self.scenario("ack_queue_or_allocation")
|
||||
|
||||
def test_ack_timeout_cancels_queued_and_inflight_packets_before_rollback(self):
|
||||
self.scenario("ack_timeout")
|
||||
|
||||
def test_real_signature_mac_padding_and_full_identity_target_checks(self):
|
||||
self.scenario("tamper_padding_target")
|
||||
|
||||
def test_forged_packets_have_bounded_verification_cost(self):
|
||||
self.scenario("verify_budget")
|
||||
|
||||
def test_fleet_permission_never_allows_primary_radio_or_administrator_changes(self):
|
||||
self.scenario("primary_denied")
|
||||
|
||||
def test_publisher_signed_commands_still_cannot_escape_fleet_capability(self):
|
||||
self.scenario("signed_forbidden")
|
||||
|
||||
def test_expiry_future_clock_bounds_and_invalid_rtc_prevent_dispatch(self):
|
||||
self.scenario("clock_limits")
|
||||
|
||||
def test_open_read_rename_and_publish_faults_preserve_state_and_fail_closed(self):
|
||||
self.scenario("transaction_faults")
|
||||
|
||||
def test_recovered_backup_preserves_durable_anti_replay_sequence(self):
|
||||
self.scenario("backup_replay")
|
||||
|
||||
def test_another_valid_ed25519_identity_cannot_authorize_fleet_commands(self):
|
||||
self.scenario("wrong_publisher")
|
||||
|
||||
def test_long_cli_reply_and_sender_name_fit_one_encrypted_group_packet(self):
|
||||
self.scenario("bounded_ack")
|
||||
|
||||
def test_unknown_scoped_route_refuses_ack_and_rolls_back_radio_mutation(self):
|
||||
self.scenario("unknown_scope")
|
||||
|
||||
def test_scope_selects_matching_rotated_region_key_and_authenticates_new_ack_payload(self):
|
||||
self.scenario("rotated_scope")
|
||||
|
||||
def test_existing_remote_radio_mutation_blocks_fleet_without_reserving_sequence(self):
|
||||
self.scenario("existing_mutation")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,273 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <Ed25519.h>
|
||||
#include <helpers/FleetCommand.h>
|
||||
#include <SHA256.h>
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
using Fleet = mesh::FleetCommand;
|
||||
constexpr uint32_t Now = 1791500000UL;
|
||||
constexpr uint8_t ChannelKey[16] = {
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16
|
||||
};
|
||||
|
||||
class FixedRng : public mesh::RNG {
|
||||
public:
|
||||
void random(uint8_t* dest, size_t size) override {
|
||||
for (size_t i = 0; i < size; ++i) dest[i] = uint8_t(i + 1);
|
||||
}
|
||||
};
|
||||
|
||||
class FleetCommandTest : public ::testing::Test {
|
||||
protected:
|
||||
FixedRng rng;
|
||||
mesh::LocalIdentity publisher{&rng};
|
||||
std::array<uint8_t, Fleet::TargetSize> all{};
|
||||
Fleet::Decoded decoded{};
|
||||
bool previous_verifier = g_mock_ed25519_verify_result;
|
||||
|
||||
void SetUp() override { g_mock_ed25519_verify_result = true; }
|
||||
void TearDown() override { g_mock_ed25519_verify_result = previous_verifier; }
|
||||
|
||||
std::vector<uint8_t> encode(const char* command = "get radio2",
|
||||
uint32_t sequence = Now,
|
||||
uint32_t expires = Now + 120,
|
||||
const uint8_t* target = nullptr) {
|
||||
std::vector<uint8_t> bytes(Fleet::MaxPayloadLength);
|
||||
const size_t size = Fleet::encode(publisher, ChannelKey, sequence, expires,
|
||||
target ? target : all.data(), command, bytes.data(), bytes.size());
|
||||
bytes.resize(size);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
bool decode(const std::vector<uint8_t>& bytes, uint32_t now = Now) {
|
||||
return Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(),
|
||||
now, publisher.pub_key, decoded);
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(FleetCommandTest, ExplicitRadioAndFilterFamiliesAreAllowed) {
|
||||
for (const char* command : {
|
||||
"get radio2", "get tempradio2", "get radio2.status", "get radio2.timing",
|
||||
"get radio2.cross", "set radio2.cross auto", "set radio2.cross on",
|
||||
"set radio2.cross off", "set radio2 off", "set tempradio2 off",
|
||||
"set radio2 910.5,500,5,5,rxtx",
|
||||
"set tempradio2 910.5,500,5,5,rx,10,auto",
|
||||
"set radio2 910.5,500,5,5,rx,32",
|
||||
"get flood.filter", "get flood.filter.1", "get flood.filter.blacklist.2",
|
||||
"set flood.filter.2 grp_txt 4+", "set flood.filter.blacklist A1,B2",
|
||||
"del flood.filter.blacklist.2", "del flood.filter all",
|
||||
"get flood.rule.2", "set flood.rule.2 type=any rate=10/min stop",
|
||||
"del flood.rule.2", "del flood.rule all",
|
||||
"get flood.moderation.2", "set flood.moderation public '*' rate=2/min",
|
||||
"del flood.moderation all", "get flood.channel.scope.3",
|
||||
"set flood.channel.scope public scope=usa", "del flood.channel.scope.3",
|
||||
"get flood.channel.scope.require.2",
|
||||
"set flood.channel.scope.require.2 #fleet", "del flood.channel.scope.require all",
|
||||
"get flood.channel.data", "set flood.channel.data on",
|
||||
"get flood.channel.data.hops", "set flood.channel.data.hops 4",
|
||||
"get flood.max", "set flood.max 8", "get flood.max.unscoped",
|
||||
"set flood.max.unscoped 4", "get flood.max.advert", "set flood.max.advert 6"}) {
|
||||
EXPECT_TRUE(Fleet::commandAllowed(command)) << command;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, NoGenericAdminNamespaceOrPrefixEscapes) {
|
||||
for (const char* command : {
|
||||
"get prv.key", "backup prv.key 1234567890ABCDEF", "get wifi.password",
|
||||
"set wifi.password abc", "setperm abc 3", "reboot", "start ota",
|
||||
"get fleet", "set fleet.channel abc", "region add usa", "get radio",
|
||||
"set radio 910.5,500,5,5", "get radio2.reply", "get radio2.scan",
|
||||
"set tx.reply both", "set radioat2 910.5,500,5,5,rxtx,+5",
|
||||
"get flood.filter.secret", "get flood.filtering", "get flood.rulebook",
|
||||
"get flood.rule.1.secret", "get flood.filter.blacklistx",
|
||||
"get flood.channel.scope.required", "get flood.channel.data.secret",
|
||||
"get flood.max.secret", "get flood.max.unscoped.extra", "get flood.rule.0",
|
||||
"get flood.rule.01", "get flood.rule.256", "get flood.rule.9999999999",
|
||||
"xy|set radio2 off", "GET radio2", "get radio2", " get radio2",
|
||||
"get radio2 ", "get radio2 off", "del radio2", "set radio2",
|
||||
"set radio2.cross true", "del flood.channel.data", "set flood.filter.2",
|
||||
"del flood.rule.2 extra", "get flood.rule.1 all", "set flood.max",
|
||||
"set radio2 910.5,500,5,5,rxtx,auto,99",
|
||||
"set radio2 910.5,500,5,5,off", "set radio2 910.5,500,5,5,rxtx,",
|
||||
"set tempradio2 910.5,500,5,5,rxtx",
|
||||
"set tempradio2 910.5,500,5,5,rxtx,+10",
|
||||
"set radio2 910.5,500,5,5,rxtx;reboot", "get radio2\nreboot",
|
||||
"get radio2\r", "get\tradio2", "get radio2|reboot", "get radio2&reboot",
|
||||
"set flood.rule.1 type=any `reboot`", "set flood.rule.1 type=any\\reboot"}) {
|
||||
EXPECT_FALSE(Fleet::commandAllowed(command)) << command;
|
||||
}
|
||||
const std::string non_ascii = "set flood.rule.1 " + std::string(1, char(0x80));
|
||||
EXPECT_FALSE(Fleet::commandAllowed(non_ascii.c_str()));
|
||||
EXPECT_FALSE(Fleet::commandAllowed(nullptr));
|
||||
EXPECT_FALSE(Fleet::commandAllowed(""));
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, OnlyCompletePublicKeyTargetsOrExplicitAll) {
|
||||
std::array<uint8_t, Fleet::TargetSize> target;
|
||||
target.fill(0xAA);
|
||||
EXPECT_TRUE(Fleet::parseTarget("all", target.data()));
|
||||
EXPECT_EQ(all, target);
|
||||
EXPECT_FALSE(Fleet::parseTarget("ALL", target.data()));
|
||||
EXPECT_FALSE(Fleet::parseTarget("1234", target.data()));
|
||||
EXPECT_FALSE(Fleet::parseTarget(std::string(64, '0').c_str(), target.data()));
|
||||
EXPECT_FALSE(Fleet::parseTarget(std::string(64, 'z').c_str(), target.data()));
|
||||
EXPECT_FALSE(Fleet::parseTarget(nullptr, target.data()));
|
||||
EXPECT_FALSE(Fleet::parseTarget("all", nullptr));
|
||||
const char* full = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
|
||||
ASSERT_TRUE(Fleet::parseTarget(full, target.data()));
|
||||
const std::array<uint8_t, 16> expected = {
|
||||
0xae, 0x21, 0x6c, 0x2e, 0xf5, 0x24, 0x7a, 0x37,
|
||||
0x82, 0xc1, 0x35, 0xef, 0xa2, 0x79, 0xa3, 0xe4
|
||||
};
|
||||
EXPECT_EQ(expected, target); // independently generated SHA-256 vector
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, RejectsPublicZeroOrMissingPrivacyKeys) {
|
||||
const uint8_t public_key[16] = {
|
||||
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
|
||||
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72
|
||||
};
|
||||
const uint8_t empty[16] = {};
|
||||
EXPECT_TRUE(Fleet::privateKeyAllowed(ChannelKey));
|
||||
EXPECT_FALSE(Fleet::privateKeyAllowed(public_key));
|
||||
EXPECT_FALSE(Fleet::privateKeyAllowed(empty));
|
||||
EXPECT_FALSE(Fleet::privateKeyAllowed(nullptr));
|
||||
std::array<uint8_t, Fleet::MaxPayloadLength> output;
|
||||
for (const uint8_t* key : {public_key, empty, static_cast<const uint8_t*>(nullptr)}) {
|
||||
EXPECT_EQ(0U, Fleet::encode(publisher, key, Now, Now + 10, all.data(),
|
||||
"get radio2", output.data(), output.size()));
|
||||
const auto bytes = encode();
|
||||
EXPECT_FALSE(Fleet::decode(publisher, key, bytes.data(), bytes.size(), Now,
|
||||
publisher.pub_key, decoded));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, ExactEnvelopeRoundTripsThroughVerifierBoundary) {
|
||||
const auto bytes = encode();
|
||||
ASSERT_EQ(Fleet::HeaderSize + 10 + Fleet::SignatureSize, bytes.size());
|
||||
EXPECT_EQ(std::string("FMC1"), std::string(bytes.begin(), bytes.begin() + 4));
|
||||
EXPECT_EQ(10, bytes[28]);
|
||||
const unsigned calls = g_mock_ed25519_verify_calls;
|
||||
ASSERT_TRUE(decode(bytes));
|
||||
EXPECT_EQ(calls + 1, g_mock_ed25519_verify_calls);
|
||||
EXPECT_EQ(Now, decoded.sequence);
|
||||
EXPECT_EQ(Now + 120, decoded.expires);
|
||||
EXPECT_STREQ("get radio2", decoded.command);
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, FailedSignatureVerificationNeverReturnsCommand) {
|
||||
const auto bytes = encode();
|
||||
g_mock_ed25519_verify_result = false;
|
||||
decoded.sequence = 123;
|
||||
strcpy(decoded.command, "stale");
|
||||
const unsigned calls = g_mock_ed25519_verify_calls;
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
EXPECT_EQ(calls + 1, g_mock_ed25519_verify_calls);
|
||||
EXPECT_EQ(0U, decoded.sequence);
|
||||
EXPECT_EQ(0U, decoded.expires);
|
||||
EXPECT_STREQ("", decoded.command);
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, FullPayloadLimitFitsWithoutOverflow) {
|
||||
const std::string prefix = "set flood.rule.1 ";
|
||||
const std::string maximum = prefix + std::string(Fleet::MaxCommandLength - prefix.size(), 'a');
|
||||
ASSERT_TRUE(Fleet::commandAllowed(maximum.c_str()));
|
||||
const auto bytes = encode(maximum.c_str());
|
||||
ASSERT_EQ(Fleet::MaxPayloadLength, bytes.size());
|
||||
EXPECT_TRUE(decode(bytes));
|
||||
EXPECT_STREQ(maximum.c_str(), decoded.command);
|
||||
EXPECT_FALSE(Fleet::commandAllowed((maximum + "a").c_str()));
|
||||
EXPECT_TRUE(encode((maximum + "a").c_str()).empty());
|
||||
std::array<uint8_t, Fleet::MaxPayloadLength + 2> output;
|
||||
output.fill(0xA5);
|
||||
EXPECT_EQ(0U, Fleet::encode(publisher, ChannelKey, Now, Now + 10, all.data(),
|
||||
maximum.c_str(), output.data() + 1, 164));
|
||||
for (uint8_t value : output) EXPECT_EQ(0xA5, value);
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, RejectsTruncationTrailingPaddingAndWrongMagic) {
|
||||
const auto original = encode();
|
||||
for (size_t size = 0; size < original.size(); ++size) {
|
||||
const std::vector<uint8_t> shortened(original.begin(), original.begin() + size);
|
||||
EXPECT_FALSE(decode(shortened)) << size;
|
||||
}
|
||||
auto bytes = original;
|
||||
bytes.push_back(0);
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
bytes = original;
|
||||
bytes[0] ^= 1;
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
bytes = original;
|
||||
bytes[28] = 0;
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
bytes[28] = 73;
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, RejectsEmbeddedNulAndForbiddenSignedCommandShape) {
|
||||
auto bytes = encode();
|
||||
bytes[Fleet::HeaderSize + 4] = 0;
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
bytes = encode();
|
||||
memcpy(bytes.data() + Fleet::HeaderSize, "get radio1", 10);
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
bytes = encode();
|
||||
bytes[Fleet::HeaderSize + 9] = '\n';
|
||||
EXPECT_FALSE(decode(bytes));
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, MatchesHashedCompleteSelfIdentityOrAll) {
|
||||
std::array<uint8_t, Fleet::TargetSize> target;
|
||||
SHA256 hash;
|
||||
hash.update(publisher.pub_key, PUB_KEY_SIZE);
|
||||
hash.finalize(target.data(), target.size());
|
||||
ASSERT_TRUE(decode(encode("get radio2", Now, Now + 120, target.data())));
|
||||
target[15] ^= 1;
|
||||
EXPECT_FALSE(decode(encode("get radio2", Now, Now + 120, target.data())));
|
||||
EXPECT_TRUE(decode(encode()));
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, RequiresPlausibleClockAndBoundedExpiry) {
|
||||
EXPECT_TRUE(encode("get radio2", Fleet::MinEpoch - 1, Fleet::MinEpoch + 10).empty());
|
||||
EXPECT_TRUE(encode("get radio2", Now, Now - 1).empty());
|
||||
EXPECT_TRUE(encode("get radio2", Now, Now + 601).empty());
|
||||
EXPECT_FALSE(decode(encode(), Fleet::MinEpoch - 1));
|
||||
EXPECT_TRUE(decode(encode(), Now + 120));
|
||||
EXPECT_FALSE(decode(encode(), Now + 121));
|
||||
EXPECT_TRUE(decode(encode("get radio2", Now + 60, Now + 120)));
|
||||
EXPECT_FALSE(decode(encode("get radio2", Now + 61, Now + 120)));
|
||||
EXPECT_TRUE(decode(encode("get radio2", Now, Now + 600)));
|
||||
EXPECT_TRUE(decode(encode("get radio2", Now, Now)));
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, RejectsMissingAndZeroIdentityInputs) {
|
||||
const auto bytes = encode();
|
||||
const uint8_t empty[32] = {};
|
||||
const mesh::Identity absent(empty);
|
||||
EXPECT_FALSE(Fleet::decode(absent, ChannelKey, bytes.data(), bytes.size(),
|
||||
Now, publisher.pub_key, decoded));
|
||||
EXPECT_FALSE(Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(),
|
||||
Now, empty, decoded));
|
||||
EXPECT_FALSE(Fleet::decode(publisher, ChannelKey, bytes.data(), bytes.size(),
|
||||
Now, nullptr, decoded));
|
||||
EXPECT_FALSE(Fleet::decode(publisher, ChannelKey, nullptr, bytes.size(),
|
||||
Now, publisher.pub_key, decoded));
|
||||
}
|
||||
|
||||
TEST_F(FleetCommandTest, UnixRangeBoundaryDoesNotWrap) {
|
||||
const uint32_t maximum = UINT32_MAX;
|
||||
EXPECT_TRUE(decode(encode("get radio2", maximum, maximum), maximum - 60));
|
||||
EXPECT_FALSE(decode(encode("get radio2", maximum, maximum), maximum - 61));
|
||||
EXPECT_TRUE(encode("get radio2", maximum - 50, 1).empty());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise the production fleet envelope with real Ed25519, never native mocks."""
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
NOW = 1791500000
|
||||
CHANNEL = bytes(range(1, 17))
|
||||
DOMAIN = b"MeshCoreFleet1"
|
||||
PUBLISHER = Ed25519PrivateKey.from_private_bytes(bytes(range(1, 33)))
|
||||
PUBLIC = PUBLISHER.public_key().public_bytes(
|
||||
serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
||||
OTHER = Ed25519PrivateKey.from_private_bytes(bytes(range(33, 65)))
|
||||
OTHER_PUBLIC = OTHER.public_key().public_bytes(
|
||||
serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
||||
|
||||
|
||||
FIXTURE = r'''
|
||||
#include <helpers/FleetCommand.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <array>
|
||||
#include <cassert>
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// The production Identity interface is retained. OpenSSL implements its
|
||||
// signatures here so these tests cannot inherit native's always-true mock.
|
||||
namespace mesh {
|
||||
Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); }
|
||||
LocalIdentity::LocalIdentity(RNG* rng) {
|
||||
memset(prv_key, 0, sizeof(prv_key)); rng->random(prv_key, 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 && length == sizeof(pub_key));
|
||||
EVP_PKEY_free(key);
|
||||
}
|
||||
void LocalIdentity::sign(uint8_t* signature, const uint8_t* message, 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, message, size_t(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* message, 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, message, size_t(length)) == 1;
|
||||
EVP_MD_CTX_free(context); EVP_PKEY_free(key); return valid;
|
||||
}
|
||||
} // namespace mesh
|
||||
|
||||
struct FixedRng : mesh::RNG {
|
||||
void random(uint8_t* output, size_t size) override {
|
||||
for (size_t i = 0; i < size; ++i) output[i] = uint8_t(i + 1);
|
||||
}
|
||||
};
|
||||
|
||||
std::vector<uint8_t> unhex(const char* input) {
|
||||
const std::string text(input); assert(text.size() % 2 == 0);
|
||||
std::vector<uint8_t> bytes(text.size() / 2);
|
||||
for (size_t i = 0; i < bytes.size(); ++i)
|
||||
bytes[i] = uint8_t(std::stoul(text.substr(i * 2, 2), nullptr, 16));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
void hex(const uint8_t* bytes, size_t size) {
|
||||
constexpr char digits[] = "0123456789abcdef";
|
||||
for (size_t i = 0; i < size; ++i) std::cout << digits[bytes[i] >> 4] << digits[bytes[i] & 15];
|
||||
std::cout << '\n';
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
using Fleet = mesh::FleetCommand;
|
||||
if (argc == 7 && std::string(argv[1]) == "encode") {
|
||||
FixedRng rng; mesh::LocalIdentity publisher(&rng);
|
||||
const auto key = unhex(argv[2]); assert(key.size() == Fleet::KeySize);
|
||||
std::array<uint8_t, Fleet::TargetSize> target;
|
||||
if (!Fleet::parseTarget(argv[3], target.data())) { std::cout << "reject\n"; return 0; }
|
||||
std::array<uint8_t, Fleet::MaxPayloadLength> output;
|
||||
const size_t size = Fleet::encode(publisher, key.data(), uint32_t(std::stoul(argv[4])),
|
||||
uint32_t(std::stoul(argv[5])), target.data(), argv[6], output.data(), output.size());
|
||||
if (!size) std::cout << "reject\n"; else hex(output.data(), size);
|
||||
return 0;
|
||||
}
|
||||
if (argc == 7 && std::string(argv[1]) == "decode") {
|
||||
const auto public_key = unhex(argv[2]), key = unhex(argv[3]), self = unhex(argv[4]), bytes = unhex(argv[6]);
|
||||
assert(public_key.size() == PUB_KEY_SIZE && self.size() == PUB_KEY_SIZE && key.size() == Fleet::KeySize);
|
||||
mesh::Identity publisher(public_key.data()); Fleet::Decoded output;
|
||||
if (!Fleet::decode(publisher, key.data(), bytes.data(), bytes.size(),
|
||||
uint32_t(std::stoul(argv[5])), self.data(), output)) {
|
||||
assert(output.sequence == 0 && output.expires == 0 && output.command[0] == 0);
|
||||
std::cout << "reject\n";
|
||||
} else std::cout << output.sequence << '\n' << output.expires << '\n' << output.command << '\n';
|
||||
return 0;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def envelope(command="get radio2", sequence=NOW, expires=NOW + 120,
|
||||
target=b"\0" * 16, channel=CHANNEL, publisher=PUBLISHER):
|
||||
command = command.encode("ascii") if isinstance(command, str) else command
|
||||
unsigned = b"FMC1" + struct.pack("<II", sequence, expires) + target + bytes([len(command)]) + command
|
||||
return unsigned + publisher.sign(DOMAIN + channel + unsigned)
|
||||
|
||||
|
||||
class FleetCommandCryptoTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.directory = tempfile.TemporaryDirectory(prefix="meshcore-fleet-crypto-")
|
||||
cls.addClassCleanup(cls.directory.cleanup)
|
||||
path = Path(cls.directory.name)
|
||||
source = path / "fleet-crypto.cpp"
|
||||
source.write_text(FIXTURE)
|
||||
cls.binary = path / "fleet-crypto"
|
||||
build = subprocess.run([
|
||||
"g++", "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-Wno-unused-parameter", "-Wno-sign-compare",
|
||||
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
|
||||
"-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"),
|
||||
str(source), str(ROOT / "src/helpers/FleetCommand.cpp"),
|
||||
"-lcrypto", "-o", str(cls.binary)], capture_output=True, text=True)
|
||||
if build.returncode:
|
||||
raise AssertionError(build.stdout + build.stderr)
|
||||
|
||||
def run_tool(self, *arguments):
|
||||
result = subprocess.run([str(self.binary), *map(str, arguments)],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertNotIn("runtime error:", result.stderr)
|
||||
self.assertNotIn("AddressSanitizer", result.stderr)
|
||||
return result.stdout.strip()
|
||||
|
||||
def decode(self, payload, channel=CHANNEL, publisher=PUBLIC, self_key=PUBLIC, now=NOW):
|
||||
return self.run_tool("decode", publisher.hex(), channel.hex(), self_key.hex(), now, payload.hex())
|
||||
|
||||
def test_cpp_and_python_signatures_match_exactly(self):
|
||||
expected = envelope()
|
||||
actual = self.run_tool("encode", CHANNEL.hex(), "all", NOW, NOW + 120, "get radio2")
|
||||
self.assertEqual(actual, expected.hex())
|
||||
PUBLISHER.public_key().verify(expected[-64:], DOMAIN + CHANNEL + expected[:-64])
|
||||
self.assertEqual(self.decode(expected), f"{NOW}\n{NOW + 120}\nget radio2")
|
||||
|
||||
def test_python_signature_accepted_for_complete_hashed_target(self):
|
||||
target = hashlib.sha256(PUBLIC).digest()[:16]
|
||||
expected = envelope(target=target)
|
||||
actual = self.run_tool("encode", CHANNEL.hex(), PUBLIC.hex(), NOW, NOW + 120, "get radio2")
|
||||
self.assertEqual(actual, expected.hex())
|
||||
self.assertNotEqual(self.decode(expected), "reject")
|
||||
self.assertEqual(self.decode(expected, self_key=OTHER_PUBLIC), "reject")
|
||||
|
||||
def test_all_targets_reaches_another_receiver_without_other_private_key(self):
|
||||
self.assertNotEqual(self.decode(envelope(), self_key=OTHER_PUBLIC), "reject")
|
||||
|
||||
def test_wrong_publisher_and_forged_publisher_signature_rejected(self):
|
||||
self.assertEqual(self.decode(envelope(), publisher=OTHER_PUBLIC), "reject")
|
||||
self.assertEqual(self.decode(envelope(publisher=OTHER)), "reject")
|
||||
self.assertNotEqual(self.decode(envelope(publisher=OTHER), publisher=OTHER_PUBLIC), "reject")
|
||||
|
||||
def test_signature_is_bound_to_the_encryption_channel(self):
|
||||
different = bytes(range(17, 33))
|
||||
original = envelope()
|
||||
self.assertEqual(self.decode(original, channel=different), "reject")
|
||||
self.assertEqual(self.decode(envelope(channel=different)), "reject")
|
||||
self.assertNotEqual(self.decode(envelope(channel=different), channel=different), "reject")
|
||||
|
||||
def test_every_signature_byte_is_verified(self):
|
||||
original = envelope()
|
||||
for offset in range(len(original) - 64, len(original)):
|
||||
with self.subTest(signature_byte=offset):
|
||||
altered = bytearray(original)
|
||||
altered[offset] ^= 1
|
||||
self.assertEqual(self.decode(altered), "reject")
|
||||
|
||||
def test_tampered_command_that_remains_allowed_is_rejected(self):
|
||||
original = envelope(command="set flood.max 4")
|
||||
# Equal-length valid commands exercise cryptographic rejection, rather
|
||||
# than relying on the allowlist rejecting the changed text.
|
||||
other = envelope(command="set flood.max 5")
|
||||
forged = other[:-64] + original[-64:]
|
||||
self.assertEqual(self.decode(forged), "reject")
|
||||
|
||||
def test_every_header_field_and_target_is_signed(self):
|
||||
for changed in [
|
||||
envelope(sequence=NOW + 1)[:-64],
|
||||
envelope(expires=NOW + 121)[:-64],
|
||||
envelope(target=hashlib.sha256(PUBLIC).digest()[:16])[:-64],
|
||||
envelope(command="get tempradio2")[:-64]]:
|
||||
with self.subTest(header=changed.hex()):
|
||||
self.assertEqual(self.decode(changed + envelope()[-64:]), "reject")
|
||||
|
||||
def test_signed_forbidden_commands_and_multistatement_text_rejected(self):
|
||||
for command in ["get prv.key", "setperm 0102 3", "start ota", "reboot",
|
||||
"get radio2\nreboot", "xy|set radio2 off", "get radio2;reboot",
|
||||
"get radio2\0", "get flood.filter.secret", "get radio2.scan"]:
|
||||
with self.subTest(command=command):
|
||||
self.assertEqual(self.decode(envelope(command)), "reject")
|
||||
|
||||
def test_exact_and_oversized_command_boundaries(self):
|
||||
maximum = "set flood.rule.1 " + "a" * (72 - len("set flood.rule.1 "))
|
||||
accepted = envelope(maximum)
|
||||
self.assertEqual(len(accepted), 165)
|
||||
self.assertNotEqual(self.decode(accepted), "reject")
|
||||
self.assertEqual(self.decode(envelope(maximum + "a")), "reject")
|
||||
|
||||
def test_valid_signature_does_not_override_clock_policy(self):
|
||||
for sequence, expires, now in [
|
||||
(NOW, NOW - 1, NOW), (NOW, NOW + 601, NOW),
|
||||
(NOW + 61, NOW + 120, NOW), (NOW, NOW + 120, NOW + 121),
|
||||
(1735689599, 1735689600, 1735689600), (NOW, NOW + 120, 0)]:
|
||||
with self.subTest(sequence=sequence, expires=expires, now=now):
|
||||
self.assertEqual(self.decode(envelope(sequence=sequence, expires=expires), now=now), "reject")
|
||||
self.assertNotEqual(self.decode(envelope(sequence=NOW + 60, expires=NOW + 120)), "reject")
|
||||
self.assertNotEqual(self.decode(envelope(), now=NOW + 120), "reject")
|
||||
|
||||
def test_truncation_extra_padding_and_changed_length_rejected(self):
|
||||
original = envelope()
|
||||
for length in [0, 1, 28, 29, 92, len(original) - 1]:
|
||||
with self.subTest(length=length):
|
||||
self.assertEqual(self.decode(original[:length]), "reject")
|
||||
self.assertEqual(self.decode(original + b"\0"), "reject")
|
||||
altered = bytearray(original)
|
||||
altered[28] = 0
|
||||
self.assertEqual(self.decode(altered), "reject")
|
||||
|
||||
def test_default_public_key_cannot_form_fleet_privacy_channel(self):
|
||||
public_channel = bytes.fromhex("8b3387e9c5cdea6ac9e5edbaa115cd72")
|
||||
for channel in [b"\0" * 16, public_channel]:
|
||||
with self.subTest(channel=channel.hex()):
|
||||
self.assertEqual(self.decode(envelope(channel=channel), channel=channel), "reject")
|
||||
self.assertEqual(self.run_tool("encode", channel.hex(), "all", NOW, NOW + 120, "get radio2"), "reject")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,376 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute production role hooks around the separately tested fleet receiver."""
|
||||
|
||||
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 <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
#include <SHA256.h>
|
||||
#define MESH_ENABLE_CLOCK_SYNC 0
|
||||
#define MAX_TKS_ENTRIES 16
|
||||
static std::vector<int> events;
|
||||
namespace mesh {
|
||||
struct Packet {
|
||||
bool scoped = false;
|
||||
uint16_t transport_codes[2] = {};
|
||||
uint8_t payload[32] = {}, payload_len = 32, type = 5;
|
||||
bool hasTransportCodes() const { return scoped; }
|
||||
uint8_t getPayloadType() const { return type; }
|
||||
};
|
||||
struct Mesh {
|
||||
virtual ~Mesh() = default;
|
||||
virtual bool supportsFleetControl() const { return false; }
|
||||
virtual void onSendComplete(Packet*) { events.push_back(1); }
|
||||
virtual void onSendFail(Packet*) { events.push_back(3); }
|
||||
virtual void onRadioProfileCopyQueued(Packet*, const Packet*, uint8_t) { events.push_back(5); }
|
||||
};
|
||||
}
|
||||
struct TransportKey {
|
||||
uint8_t key[16] = {};
|
||||
uint16_t calcTransportCode(const mesh::Packet*) const;
|
||||
};
|
||||
struct Region { bool wildcard = false; bool isWildcard() const { return wildcard; } };
|
||||
struct Regions {
|
||||
std::vector<TransportKey> keys;
|
||||
unsigned calls = 0;
|
||||
int getTransportKeysFor(const Region&, TransportKey* out, int capacity) {
|
||||
++calls;
|
||||
const int count = std::min<int>(keys.size(), capacity);
|
||||
for (int i = 0; i < count; ++i) out[i] = keys[i];
|
||||
return count;
|
||||
}
|
||||
};
|
||||
@SCOPE_HELPERS@
|
||||
struct Profiles {};
|
||||
struct Fleet {
|
||||
unsigned received = 0, serviced = 0;
|
||||
bool scoped = false;
|
||||
TransportKey reply_scope;
|
||||
uint32_t sequence = 1735689610;
|
||||
char command[80] = "set radio2 off", reply[160] = {};
|
||||
void receive(mesh::Packet*, mesh::Mesh&, const TransportKey* scope) {
|
||||
++received; scoped = scope != nullptr;
|
||||
if (scope) reply_scope = *scope;
|
||||
}
|
||||
void complete(mesh::Packet*, Profiles&) { events.push_back(2); }
|
||||
void fail(mesh::Packet*, Profiles&) { events.push_back(4); }
|
||||
void copy(mesh::Packet*, const mesh::Packet*) { events.push_back(6); }
|
||||
template<typename Handler>
|
||||
void service(mesh::Mesh&, Profiles&, const char* name, Handler handler) {
|
||||
assert(!strcmp(name, "fleet node")); ++serviced;
|
||||
handler(sequence, command, reply);
|
||||
}
|
||||
};
|
||||
struct CLI {
|
||||
Fleet fleet;
|
||||
Profiles profiles;
|
||||
bool enrolled = true;
|
||||
Fleet* fleetChannel() { return enrolled ? &fleet : nullptr; }
|
||||
Profiles& radioProfiles() { return profiles; }
|
||||
};
|
||||
struct ClockSync {
|
||||
unsigned observed = 0;
|
||||
void observeGroupPacket(mesh::Packet*) { ++observed; }
|
||||
};
|
||||
class Repeater : public mesh::Mesh {
|
||||
public:
|
||||
CLI _cli;
|
||||
bool region_load_active = false;
|
||||
Regions region_map;
|
||||
Region region;
|
||||
Region* recv_pkt_region = ®ion;
|
||||
struct { const char* node_name = "fleet node"; } _prefs;
|
||||
unsigned commands = 0;
|
||||
uint32_t timestamp = 0;
|
||||
@REPEATER_CAPABILITY@
|
||||
void onGroupPacketRecv(mesh::Packet*);
|
||||
void serviceFleet();
|
||||
void handleCommand(uint32_t value, void* sender, char* command, char* reply) {
|
||||
assert(sender == nullptr && !strcmp(command, "set radio2 off"));
|
||||
timestamp = value; ++commands; strcpy(reply, "OK");
|
||||
}
|
||||
};
|
||||
class Room : public mesh::Mesh {
|
||||
public:
|
||||
CLI _cli;
|
||||
ClockSync _clock_sync;
|
||||
bool region_load_active = false;
|
||||
Regions region_map;
|
||||
Region region;
|
||||
Region* recv_pkt_region = ®ion;
|
||||
struct { const char* node_name = "fleet node"; } _prefs;
|
||||
unsigned commands = 0;
|
||||
uint32_t timestamp = 0;
|
||||
@ROOM_CAPABILITY@
|
||||
void onGroupPacketRecv(mesh::Packet*);
|
||||
void serviceFleet();
|
||||
#if MESH_ENABLE_FLEET_CONTROL
|
||||
void onSendComplete(mesh::Packet*) override;
|
||||
void onSendFail(mesh::Packet*) override;
|
||||
void onRadioProfileCopyQueued(mesh::Packet*, const mesh::Packet*, uint8_t) override;
|
||||
#endif
|
||||
void handleCommand(uint32_t value, char* command, char* reply) {
|
||||
assert(!strcmp(command, "set radio2 off"));
|
||||
timestamp = value; ++commands; strcpy(reply, "OK");
|
||||
}
|
||||
};
|
||||
@METHODS@
|
||||
|
||||
template<typename Role>
|
||||
void checkRole() {
|
||||
Role role;
|
||||
mesh::Packet packet;
|
||||
TransportKey original, rotated;
|
||||
memset(original.key, 0x11, sizeof(original.key));
|
||||
memset(rotated.key, 0x22, sizeof(rotated.key));
|
||||
role.region_map.keys = {original, rotated};
|
||||
packet.scoped = true;
|
||||
packet.transport_codes[0] = rotated.calcTransportCode(&packet);
|
||||
assert(role.supportsFleetControl());
|
||||
role.onGroupPacketRecv(&packet);
|
||||
role.serviceFleet();
|
||||
#if MESH_ENABLE_FLEET_CONTROL
|
||||
assert(role._cli.fleet.received == 1 && role._cli.fleet.serviced == 1);
|
||||
assert(role._cli.fleet.scoped);
|
||||
assert(!memcmp(role._cli.fleet.reply_scope.key, rotated.key, sizeof(rotated.key)));
|
||||
assert(role.commands == 1 && role.timestamp == role._cli.fleet.sequence);
|
||||
role.region_load_active = true;
|
||||
role.serviceFleet();
|
||||
assert(role.commands == 1);
|
||||
assert(strstr(role._cli.fleet.reply, "region load active"));
|
||||
#else
|
||||
assert(role._cli.fleet.received == 0 && role._cli.fleet.serviced == 0);
|
||||
assert(role.commands == 0);
|
||||
#endif
|
||||
role._cli.enrolled = false;
|
||||
const unsigned before = role._cli.fleet.serviced;
|
||||
role.onGroupPacketRecv(&packet);
|
||||
role.serviceFleet();
|
||||
assert(role._cli.fleet.serviced == before);
|
||||
}
|
||||
|
||||
static void checkScopeSelection() {
|
||||
Regions regions;
|
||||
Region region;
|
||||
mesh::Packet packet;
|
||||
TransportKey first, rotated, selected;
|
||||
memset(first.key, 0x31, sizeof(first.key));
|
||||
memset(rotated.key, 0xA4, sizeof(rotated.key));
|
||||
regions.keys = {first, rotated};
|
||||
assert(!mesh::captureFleetReplyScope(regions, ®ion, &packet, selected));
|
||||
assert(regions.calls == 0);
|
||||
packet.scoped = true;
|
||||
packet.payload[0] = 0x6D;
|
||||
packet.transport_codes[1] = rotated.calcTransportCode(&packet);
|
||||
assert(mesh::captureFleetReplyScope(regions, ®ion, &packet, selected));
|
||||
assert(!memcmp(selected.key, rotated.key, sizeof(rotated.key)));
|
||||
// Codes belong to the received payload. Changing it invalidates the match.
|
||||
packet.payload[0] ^= 1;
|
||||
assert(!mesh::captureFleetReplyScope(regions, ®ion, &packet, selected));
|
||||
packet.payload[0] ^= 1;
|
||||
packet.transport_codes[0] = first.calcTransportCode(&packet);
|
||||
packet.transport_codes[1] = 0;
|
||||
assert(mesh::captureFleetReplyScope(regions, ®ion, &packet, selected));
|
||||
assert(!memcmp(selected.key, first.key, sizeof(first.key)));
|
||||
region.wildcard = true;
|
||||
const unsigned calls = regions.calls;
|
||||
assert(!mesh::captureFleetReplyScope(regions, ®ion, &packet, selected));
|
||||
assert(!mesh::captureFleetReplyScope(regions, (const Region*)nullptr, &packet, selected));
|
||||
assert(!mesh::captureFleetReplyScope(regions, ®ion, nullptr, selected));
|
||||
assert(regions.calls == calls);
|
||||
}
|
||||
|
||||
int main() {
|
||||
checkScopeSelection();
|
||||
checkRole<Repeater>(); checkRole<Room>();
|
||||
Room room; mesh::Packet first, copy;
|
||||
room.onGroupPacketRecv(&first);
|
||||
assert(room._clock_sync.observed == 1); // Existing clock delivery survives.
|
||||
room.onSendComplete(&first);
|
||||
room.onSendFail(&first);
|
||||
room.onRadioProfileCopyQueued(©, &first, 7);
|
||||
#if MESH_ENABLE_FLEET_CONTROL
|
||||
assert((events == std::vector<int>{1,2,3,4,5,6}));
|
||||
#else
|
||||
assert((events == std::vector<int>{1,3,5}));
|
||||
#endif
|
||||
puts("Fleet role receive, deferred dispatch, busy guard and callbacks passed");
|
||||
}
|
||||
'''
|
||||
|
||||
AUTHORIZATION = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
#include <initializer_list>
|
||||
static unsigned configurations = 0, allocations = 0;
|
||||
namespace mesh {
|
||||
struct FleetChannel {
|
||||
explicit FleetChannel(void*) { ++allocations; }
|
||||
bool handleConfig(const char*, char* reply, size_t capacity) {
|
||||
assert(capacity == 160); ++configurations; strcpy(reply, "OK"); return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
struct Callbacks {
|
||||
bool supported = true;
|
||||
bool supportsFleetControl() const { return supported; }
|
||||
};
|
||||
class CommonCLI {
|
||||
public:
|
||||
Callbacks callbacks;
|
||||
Callbacks* _callbacks = &callbacks;
|
||||
void* _management_fs = nullptr;
|
||||
mesh::FleetChannel* _fleet_channel = nullptr;
|
||||
~CommonCLI() { delete _fleet_channel; }
|
||||
bool handleFleetCommand(const char*, char*);
|
||||
};
|
||||
struct ClientInfo {
|
||||
enum Role { Guest, Admin, RegionManager, FilterManager } role;
|
||||
bool isAdmin() const { return role == Admin; }
|
||||
bool isRegionMgr() const { return role == RegionManager; }
|
||||
bool isFilterMgr() const { return role == FilterManager; }
|
||||
};
|
||||
@MANAGER_HELPERS@
|
||||
@COMMON_FLEET@
|
||||
static void dispatch(CommonCLI& cli, ClientInfo* sender, const char* command, char* reply) {
|
||||
@MANAGER_GUARD@
|
||||
if (!cli.handleFleetCommand(command, reply)) strcpy(reply, "unhandled");
|
||||
}
|
||||
int main() {
|
||||
CommonCLI cli;
|
||||
char reply[160] = {};
|
||||
ClientInfo admin{ClientInfo::Admin};
|
||||
for (const char* command : {"get fleet.channel", "get fleet.controller", "get fleet.stats",
|
||||
"set fleet.channel off", "set fleet.controller 010203"}) {
|
||||
for (auto role : {ClientInfo::Guest, ClientInfo::RegionManager, ClientInfo::FilterManager}) {
|
||||
ClientInfo sender{role};
|
||||
const unsigned before = configurations;
|
||||
dispatch(cli, &sender, command, reply);
|
||||
assert(!strcmp(reply, "Err - not permitted"));
|
||||
assert(configurations == before);
|
||||
assert(!isRegionMgrAllowed(command) && !isFilterMgrAllowed(command));
|
||||
}
|
||||
dispatch(cli, &admin, command, reply);
|
||||
assert(!strcmp(reply, "OK"));
|
||||
dispatch(cli, nullptr, command, reply);
|
||||
assert(!strcmp(reply, "OK"));
|
||||
}
|
||||
assert(allocations == 1 && configurations == 10);
|
||||
assert(isFilterMgrAllowed("set flood.filter.1 group-text 4"));
|
||||
assert(isRegionMgrAllowed("set flood.channel.scope public #local"));
|
||||
assert(!isFilterMgrAllowed("set radio2 off"));
|
||||
assert(!isRegionMgrAllowed("get password"));
|
||||
CommonCLI unsupported;
|
||||
unsupported.callbacks.supported = false;
|
||||
assert(unsupported.handleFleetCommand("set fleet.channel off", reply));
|
||||
assert(strstr(reply, "infrastructure roles"));
|
||||
assert(allocations == 1 && configurations == 10);
|
||||
assert(!cli.handleFleetCommand("set fleeter.channel off", reply));
|
||||
puts("Fleet enrollment remains restricted to local/admin infrastructure dispatch");
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class FleetRoleWiringTests(unittest.TestCase):
|
||||
def test_enrollment_common_cli_stays_behind_live_manager_permissions(self):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
self.assertIsNotNone(compiler)
|
||||
repeater = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text()
|
||||
helpers = "\n".join(body(repeater, signature) for signature in (
|
||||
"static bool commandFamilyMatches(", "static bool isCommonManagerReadOnlyAllowed(",
|
||||
"static bool isRegionMgrAllowed(", "static bool isFilterMgrAllowed(",
|
||||
))
|
||||
handler = body(repeater, "void MyMesh::handleCommand(uint32_t sender_timestamp,")
|
||||
guard_start = handler.rindex("if (sender && !sender->isAdmin())", 0,
|
||||
handler.index("mesh::cli::handleACLGet("))
|
||||
guard = body(handler[guard_start:], "if (sender && !sender->isAdmin())")
|
||||
common = body((ROOT / "src/helpers/CommonCLI_Management.cpp").read_text(),
|
||||
"bool CommonCLI::handleFleetCommand(")
|
||||
harness = AUTHORIZATION.replace("@MANAGER_HELPERS@", helpers)
|
||||
harness = harness.replace("@COMMON_FLEET@", common).replace("@MANAGER_GUARD@", guard)
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-fleet-authorization-") as directory:
|
||||
work = Path(directory)
|
||||
source = work / "fixture.cpp"
|
||||
source.write_text(harness)
|
||||
for extended_filters in (0, 1):
|
||||
with self.subTest(extended_filters=extended_filters):
|
||||
executable = work / str(extended_filters)
|
||||
built = subprocess.run([
|
||||
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-DMESH_ENABLE_FLOOD_RULE_ENGINE=" + str(extended_filters),
|
||||
str(source), "-o", str(executable),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
||||
checked = subprocess.run([str(executable)], capture_output=True,
|
||||
text=True, timeout=10)
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
|
||||
def test_roles_with_feature_enabled_and_disabled(self):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
self.assertIsNotNone(compiler)
|
||||
methods = []
|
||||
capabilities = {}
|
||||
for role, folder in (("Repeater", "simple_repeater"), ("Room", "simple_room_server")):
|
||||
source = (ROOT / "examples" / folder / "MyMesh.cpp").read_text()
|
||||
header = (ROOT / "examples" / folder / "MyMesh.h").read_text()
|
||||
capabilities[role] = body(header, "bool supportsFleetControl() const override")
|
||||
methods.append(body(source, "void MyMesh::onGroupPacketRecv(").replace("MyMesh::", role + "::", 1))
|
||||
loop = body(source, "void MyMesh::loop()")
|
||||
fleet = body(loop, "if (_cli.fleetChannel())")
|
||||
methods.append("void " + role + "::serviceFleet() {\n#if MESH_ENABLE_FLEET_CONTROL\n"
|
||||
+ fleet + "\n#endif\n}\n")
|
||||
if role == "Room":
|
||||
for signature in ("void MyMesh::onSendComplete(", "void MyMesh::onSendFail(",
|
||||
"void MyMesh::onRadioProfileCopyQueued("):
|
||||
methods.append("#if MESH_ENABLE_FLEET_CONTROL\n"
|
||||
+ body(source, signature).replace("MyMesh::", role + "::", 1)
|
||||
+ "\n#endif\n")
|
||||
harness = HARNESS.replace("@METHODS@", "\n".join(methods))
|
||||
scope = body((ROOT / "src/helpers/FleetChannel.h").read_text(),
|
||||
"template <typename Regions, typename Region>")
|
||||
transport_code = body((ROOT / "src/helpers/TransportKeyStore.cpp").read_text(),
|
||||
"uint16_t TransportKey::calcTransportCode(")
|
||||
# The host SHA-256 shim has a byte-pointer output API; on-device Crypto
|
||||
# accepts void*. Preserve the production algorithm with that cast only.
|
||||
transport_code = transport_code.replace("&code, 2", "(uint8_t*)&code, 2")
|
||||
harness = harness.replace("@SCOPE_HELPERS@", transport_code + "\nnamespace mesh {\n" + scope + "\n}\n")
|
||||
harness = harness.replace("@REPEATER_CAPABILITY@", capabilities["Repeater"])
|
||||
harness = harness.replace("@ROOM_CAPABILITY@", capabilities["Room"])
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-fleet-role-") as directory:
|
||||
work = Path(directory)
|
||||
fixture = work / "fixture.cpp"
|
||||
fixture.write_text(harness)
|
||||
for enabled in (0, 1):
|
||||
with self.subTest(fleet_control=enabled):
|
||||
executable = work / str(enabled)
|
||||
built = subprocess.run([
|
||||
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-isystem", str(ROOT / "test/mocks"),
|
||||
"-DMESH_ENABLE_FLEET_CONTROL=" + str(enabled),
|
||||
str(fixture), "-o", str(executable),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
||||
checked = subprocess.run([str(executable)], capture_output=True,
|
||||
text=True, timeout=10)
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -20,6 +20,7 @@ PREAMBLE = r'''
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <helpers/RadioProfileCommandUtils.h>
|
||||
#include <helpers/BatteryChargeCLI.h>
|
||||
#include "companion_fleet_dispatch.h"
|
||||
#define ESP32 1
|
||||
#define WIFI_SSID "test"
|
||||
#define WITH_MQTT_BRIDGE 1
|
||||
@@ -113,7 +114,7 @@ struct WebConfigServer {
|
||||
};
|
||||
const char* FIRMWARE_VERSION="test";
|
||||
const char* FIRMWARE_BUILD_DATE="test";
|
||||
struct MyMesh {
|
||||
struct MyMesh : CompanionFleetFixture {
|
||||
mesh::RadioProfileCLI _radio_profiles;
|
||||
Identity self_id;
|
||||
Store store; Store* _store=&store;
|
||||
@@ -163,6 +164,11 @@ SCENARIOS = r'''
|
||||
int main() {
|
||||
MyMesh node;
|
||||
char reply[160] = {};
|
||||
for (uint32_t timestamp : {0u, 100u}) {
|
||||
node.onCLICommandRecv(ContactInfo{}, nullptr, timestamp,
|
||||
"fleet send 1 all set radio2 off", reply);
|
||||
assert(strstr(reply, "requires local Companion access"));
|
||||
}
|
||||
assert(node.handleCommand("set charge.voltage 4.1",0,reply));
|
||||
assert(board.charge_target==4100 && strstr(reply,"saved"));
|
||||
for(uint32_t stamp:{0u,100u}) {
|
||||
@@ -365,6 +371,7 @@ int main() {
|
||||
binary = Path(temp) / "test"
|
||||
result = subprocess.run([
|
||||
"c++", "-std=c++17", "-x", "c++", "-", "-I"+str(ROOT / "src"),
|
||||
"-I"+str(ROOT / "test/fixtures"),
|
||||
"-DENABLE_PRIVATE_KEY_EXPORT="+str(export),
|
||||
*(["-DWITH_WEBCONFIG=1"] if webconfig else []),
|
||||
*SANITIZER_FLAGS, "-o", str(binary),
|
||||
|
||||
Reference in new issue
Block a user