Support atomic two-packet signed fleet commands

Automatically split longer signed envelopes into two bounded group-data packets, reserve both before queueing, and roll back exact packet copies and retries on admission failure. Collect fragments in either order with a fixed deadline and verify the complete command before durable replay reservation and dispatch.

Raise whole-fleet protocol capacity to 230 bytes, preserve target-specific budgets and radio acknowledgement barriers, and reject rule-token and USB rescue-line truncation. Document existing app input limits and add real-signature, sanitizer, queue failure, timeout, replay, and compact-board regressions.
This commit is contained in:
mikecarper committed 2026-10-09 00:46:50 -07:00
1 parent d74a0f8b44
commit fdad79eaf7
18 files changed
+1649 -143

No files matched your search

+94 -21
View File
@@ -1691,6 +1691,64 @@ bool MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk
}
}
#if MESH_ENABLE_FLEET_CONTROL
void MyMesh::onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original, uint8_t priority) {
mesh::Mesh::onRadioProfileCopyQueued(packet, original, priority);
if (original && original == _fleet_admission_original) _fleet_admission_copy = packet;
}
bool MyMesh::sendFleetCommandData(mesh::GroupChannel& channel, const uint8_t* envelope, size_t length) {
if (!envelope || !length || length > mesh::FleetCommand::MaxEnvelopeLength) return false;
if (length <= mesh::FleetCommand::MaxPayloadLength) {
return sendGroupData(channel, nullptr, OUT_PATH_UNKNOWN,
mesh::FleetCommand::DataType, envelope, (int)length);
}
mesh::Packet* reserved[2] = {};
uint8_t group[3 + mesh::FleetCommand::MaxPayloadLength];
group[0] = (uint8_t)mesh::FleetCommand::DataType;
group[1] = (uint8_t)(mesh::FleetCommand::DataType >> 8);
// Reserve both encrypted packets before handing either to the dispatcher.
for (uint8_t part = 0; part < 2; ++part) {
const size_t bytes = mesh::FleetCommand::fragment(envelope, length, part,
group + 3, sizeof(group) - 3);
if (bytes) {
group[2] = (uint8_t)bytes;
reserved[part] = createGroupDatagram(PAYLOAD_TYPE_GRP_DATA, channel, group, 3 + bytes);
}
if (!reserved[part]) {
if (reserved[0]) releasePacket(reserved[0]);
memset(group, 0, sizeof(group));
return false;
}
}
memset(group, 0, sizeof(group));
_fleet_admission_original = reserved[0];
_fleet_admission_copy = nullptr;
const bool first_queued = sendFloodScoped(channel, reserved[0]);
mesh::Packet* first_copy = _fleet_admission_copy;
_fleet_admission_original = nullptr;
_fleet_admission_copy = nullptr;
if (!first_queued) {
// sendFloodScoped/sendPacket owns and releases the failed first packet.
releasePacket(reserved[1]);
return false;
}
if (sendFloodScoped(channel, reserved[1])) return true;
// A failed second send has already been released. No dispatcher loop runs
// inside admission, so the first packet and its optional copy are still
// queued and can be retired by exact pointer without touching other work.
for (int i = _mgr->getOutboundTotal() - 1; i >= 0; --i) {
const mesh::Packet* queued = _mgr->getOutboundByIdx(i);
if (queued != reserved[0] && queued != first_copy) continue;
mesh::Packet* cancelled = _mgr->removeOutboundByIdx(i);
cancelOutboundRadioRetry(cancelled);
onSendFail(cancelled); // Clear its pending flood retry metadata too.
releasePacket(cancelled);
}
return false;
}
#endif
void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
const char *text) {
#if MESH_ENABLE_ONE_KEY_DM
@@ -5264,7 +5322,10 @@ void MyMesh::resetUsbHostSessionInput() {
// CLI rescue deliberately remains active across reconnects, but no bytes
// typed by the old host may complete a command for the new one. This is
// especially important for the rescue shell's erase/rm commands.
if (_cli_rescue) memset(cli_command, 0, sizeof(cli_command));
if (_cli_rescue) {
memset(cli_command, 0, sizeof(cli_command));
_cli_line_overflow = false;
}
}
void MyMesh::handleCmdFrame(size_t len) {
@@ -9520,6 +9581,7 @@ void MyMesh::handleTerminalCommand(char* command) {
void MyMesh::enterCLIRescue() {
_cli_rescue = true;
cli_command[0] = 0;
_cli_line_overflow = false;
mesh::usbTerminalPort().println("========= CLI Rescue =========");
}
@@ -9784,16 +9846,15 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
snprintf(reply, reply_capacity, "Error: wait for the next clock second before fleet send");
return true;
}
uint8_t payload[mesh::FleetCommand::MaxPayloadLength];
uint8_t payload[mesh::FleetCommand::MaxEnvelopeLength];
const size_t length = mesh::FleetCommand::encode(
self_id, channel.channel.secret, sequence,
sequence + mesh::FleetCommand::MaxLifetime, targets, text,
payload, sizeof(payload));
if (!length) {
snprintf(reply, reply_capacity,
"Error: fleet targets and command exceed one packet; use fewer targets");
} else if (!sendGroupData(channel.channel, nullptr, OUT_PATH_UNKNOWN,
mesh::FleetCommand::DataType, payload, (int)length)) {
"Error: fleet targets and command exceed two packets; use fewer targets");
} else if (!sendFleetCommandData(channel.channel, payload, length)) {
snprintf(reply, reply_capacity, "Error: could not queue fleet command");
} else {
last_fleet_sequence = sequence;
@@ -10076,30 +10137,42 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
void MyMesh::checkCLIRescueCmd() {
Stream& output = mesh::usbTerminalPort();
Stream& input = mesh::usbCompanionPort();
int len = strlen(cli_command);
// `cli_command` must stay NUL-terminated within its bounds. If it ever isn't,
// strlen() above can return >= sizeof(cli_command) and the loop below would
// then index past the buffer, so clamp defensively.
if (len >= (int)sizeof(cli_command)) {
size_t len = strnlen(cli_command, sizeof(cli_command));
if (len == sizeof(cli_command)) {
cli_command[0] = 0;
len = 0;
_cli_line_overflow = true;
}
while (input.available() && len < sizeof(cli_command)-1) {
unsigned budget = sizeof(cli_command);
while (budget-- && input.available()) {
char c = input.read();
if (c != '\n') {
cli_command[len++] = c;
cli_command[len] = 0;
}
output.print(c); // echo
}
if (len == sizeof(cli_command)-1) { // buffer full: treat as a completed line
cli_command[sizeof(cli_command)-2] = '\r'; // place end-of-line marker inside the buffer
cli_command[sizeof(cli_command)-1] = 0; // keep the buffer NUL-terminated
if (c == '\r' || c == '\n') {
if (_cli_line_overflow) {
_cli_line_overflow = false;
#if defined(STM32_PLATFORM)
output.println(" Error: unknown command");
#else
output.print("ERR: too long\r\n");
#endif
} else if (len) goto complete_line;
return;
}
if (_cli_line_overflow) continue;
if (len == sizeof(cli_command)-1) {
_cli_line_overflow = true;
cli_command[0] = 0;
len = 0;
continue;
}
cli_command[len++] = c;
cli_command[len] = 0;
}
if (len > 0 && cli_command[len - 1] == '\r') { // received complete line
cli_command[len - 1] = 0; // replace newline with C string null terminator
return;
complete_line:
{
reply_buf[0] = 0;
#if COMPANION_FEATURE_READER
if (mesh::handleReaderCommand(cli_command, output)) {
+10
View File
@@ -355,6 +355,10 @@ protected:
bool sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis);
bool sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override;
bool sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override;
#if MESH_ENABLE_FLEET_CONTROL
bool sendFleetCommandData(mesh::GroupChannel& channel, const uint8_t* envelope, size_t length);
void onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original, uint8_t priority) override;
#endif
void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override;
#if MESH_PACKET_LOGGING && !MESH_PACKET_LOGGING_COMPACT
@@ -466,6 +470,11 @@ public:
bool canRecoverUsbLogging() const;
private:
#if MESH_ENABLE_FLEET_CONTROL
// Exact ownership of the optional radio copy during two-part admission.
mesh::Packet* _fleet_admission_original = nullptr;
mesh::Packet* _fleet_admission_copy = nullptr;
#endif
// Only snapshot the changed value: CompanionNodePrefs owns self-referencing
// runtime adapters and must not be copied as a transaction snapshot.
template <typename T> bool savePreference(T& preference, T value) {
@@ -723,6 +732,7 @@ private:
#endif
bool send_unscoped; // force un-scoped flood (instead of using send_scope)
char cli_command[80];
bool _cli_line_overflow = false;
char reply_buf[166];
uint8_t app_target_ver;
uint8_t *sign_data;
+18 -7
View File
@@ -7956,12 +7956,12 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
}
}
cursor = skipFloodFilterSpaces(cursor);
if (strlen(cursor) >= 192) {
char params[256];
if (strlen(cursor) >= sizeof(params)) {
strcpy(reply, "Err - rule parameters too long");
return;
}
char params[192];
strcpy(params, cursor);
char* tokens[18];
int token_count = 0;
@@ -8244,9 +8244,12 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
}
char rate_text[24];
const bool compact = floodFilterAsciiStartsWith(tokens[i], "q=");
StrHelper::strncpy(rate_text,
tokens[i] + (compact ? 2 : strlen("rate=")),
sizeof(rate_text));
const char* value = tokens[i] + (compact ? 2 : strlen("rate="));
if (strlen(value) >= sizeof(rate_text)) {
strcpy(reply, "Err - rate value too long");
return;
}
StrHelper::strncpy(rate_text, value, sizeof(rate_text));
char* slash = strchr(rate_text, '/');
if ((!compact && slash == NULL)
|| (slash != NULL && !(strcmp(slash, "/min") == 0
@@ -8574,12 +8577,16 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
}
}
cursor = skipFloodFilterSpaces(cursor);
if (strlen(cursor) >= 140) {
#if MESH_ENABLE_FLEET_CONTROL
char params[256];
#else
char params[140];
#endif
if (strlen(cursor) >= sizeof(params)) {
strcpy(reply, "Err - filter parameters too long");
return;
}
char params[140];
strcpy(params, cursor);
char* tokens[7];
int token_count = 0;
@@ -10175,6 +10182,10 @@ void MyMesh::setFloodGroupModeration(const char* args, char* reply) {
return;
}
char rate_text[24];
if (strlen(option + 5) >= sizeof(rate_text)) {
strcpy(reply, "Err - rate value too long");
return;
}
StrHelper::strncpy(rate_text, option + 5, sizeof(rate_text));
char* slash = strchr(rate_text, '/');
if (slash == NULL || !(strcmp(slash, "/min") == 0 || strcmp(slash, "/m") == 0)) {
@@ -1275,12 +1275,12 @@ void FloodRuleEngine::set(const char* args, char* reply,
}
}
cursor = skipSpaces(cursor);
if (strlen(cursor) >= 192) {
char params[256];
if (strlen(cursor) >= sizeof(params)) {
copyString(reply, "Err - rule parameters too long", 160);
return;
}
char params[192];
copyString(params, cursor, sizeof(params));
char* tokens[18];
int token_count = 0;
@@ -1564,9 +1564,12 @@ void FloodRuleEngine::set(const char* args, char* reply,
}
bool compact = asciiStartsWith(tokens[i], "q=");
char rate_text[24];
copyString(rate_text,
tokens[i] + (compact ? 2 : strlen("rate=")),
sizeof(rate_text));
const char* value = tokens[i] + (compact ? 2 : strlen("rate="));
if (strlen(value) >= sizeof(rate_text)) {
copyString(reply, "Err - rate value too long", 160);
return;
}
copyString(rate_text, value, sizeof(rate_text));
char* slash = strchr(rate_text, '/');
if ((!compact && slash == NULL)
|| (slash != NULL && !(strcmp(slash, "/min") == 0