From 435c740e5a25c25d32428e5ca0e2fa291ff2870d Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 26 Oct 2026 19:37:13 -0700 Subject: [PATCH] Let USB repeaters and Full Companions manage direct OTA updates --- examples/companion_radio/MyMesh.cpp | 60 +++- examples/companion_radio/MyMesh.h | 14 + examples/simple_repeater/MyMesh.cpp | 34 +++ examples/simple_repeater/MyMesh.h | 16 ++ examples/simple_repeater/main.cpp | 73 ++++- src/Mesh.cpp | 4 +- src/helpers/CompanionDirectAdminGuard.h | 69 +++++ src/helpers/DirectOtaAdminClient.h | 319 ++++++++++++++++++++++ src/helpers/ota/MotaSourceSerial.cpp | 16 +- src/helpers/ota/MotaSourceSerial.h | 13 +- src/helpers/ota/OtaContext.h | 1 + test/test_companion_direct_admin_guard.py | 152 +++++++++++ test/test_direct_ota_admin_client.py | 296 ++++++++++++++++++++ test/test_ota_zero_hop_route.py | 208 ++++++++++++++ test/test_repeater_ota_serial_control.py | 122 +++++++++ 15 files changed, 1382 insertions(+), 15 deletions(-) create mode 100644 src/helpers/CompanionDirectAdminGuard.h create mode 100644 src/helpers/DirectOtaAdminClient.h create mode 100644 test/test_companion_direct_admin_guard.py create mode 100644 test/test_direct_ota_admin_client.py create mode 100644 test/test_ota_zero_hop_route.py create mode 100644 test/test_repeater_ota_serial_control.py diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f2bfdf487..4d026e76c 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2526,6 +2526,27 @@ uint8_t MyMesh::handleTrackerStatusRequest(const ContactInfo& from, uint32_t tag } #endif +#if COMPANION_FEATURE_OTA_CLI +uint8_t MyMesh::getContactTxRadio(const mesh::Identity& peer) const { + return _ota_direct_admin.selected(peer, _ms->getMillis()) + ? static_cast(mesh::RADIO_TX_PRIMARY) : BaseChatMesh::getContactTxRadio(peer); +} + +void MyMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, + const uint8_t* secret, uint8_t* data, size_t len) { + if (!_ota_direct_admin.accepts(packet, type, secret, _ms->getMillis())) return; + BaseChatMesh::onPeerDataRecv(packet, type, sender_idx, secret, data, len); +} + +bool MyMesh::onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, + uint8_t* path, uint8_t path_len, uint8_t extra_type, + uint8_t* extra, uint8_t extra_len) { + if (!_ota_direct_admin.acceptsPath(packet, secret, path_len, _ms->getMillis())) return false; + return BaseChatMesh::onPeerPathRecv(packet, sender_idx, secret, path, path_len, + extra_type, extra, extra_len); +} +#endif + void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, uint8_t len) { const uint32_t now = _ms->getMillis(); servicePendingSerialReply(now); // Expire before considering any radio callback. @@ -6243,7 +6264,7 @@ void MyMesh::handleCmdFrame(size_t len) { || (cmd_frame[0] == CMD_SEND_ANON_REQ && len > 1 + PUB_KEY_SIZE) || (cmd_frame[0] == CMD_SEND_STATUS_REQ && len >= 1 + PUB_KEY_SIZE) || (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ - && len >= 2 + PUB_KEY_SIZE) + && len >= 2 + PUB_KEY_SIZE && cmd_frame[1] == 0) || (cmd_frame[0] == CMD_SEND_TELEMETRY_REQ && len >= 4 + PUB_KEY_SIZE) || (cmd_frame[0] == CMD_SEND_BINARY_REQ @@ -7033,6 +7054,9 @@ void MyMesh::handleCmdFrame(size_t len) { char *password = (char *)&cmd_frame[1 + PUB_KEY_SIZE]; cmd_frame[len] = 0; // ensure null terminator in password if (recipient) { +#if COMPANION_FEATURE_OTA_CLI + _ota_direct_admin.beginLogin(recipient->id, _ms->getMillis()); +#endif if (!beginPendingRequest(mesh::CompanionDelayedReplies::Login, *recipient)) { writeErrFrame(ERR_CODE_BAD_STATE); return; @@ -7086,6 +7110,25 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found } +#if COMPANION_FEATURE_OTA_CLI + } else if (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ + && len == 2 + PUB_KEY_SIZE && cmd_frame[1] >= 1 && cmd_frame[1] <= 3) { + // Mode 0 remains the existing flood path discovery. These opt-in modes + // never emit flood RF and stock firmware rejects them without sending RF. + ContactInfo* recipient = lookupContactByPubKey(&cmd_frame[2], PUB_KEY_SIZE); + if (!recipient) { + writeErrFrame(ERR_CODE_NOT_FOUND); + } else if (cmd_frame[1] == 1) { + _ota_direct_admin.arm(recipient->id, recipient->getSharedSecret(self_id), _ms->getMillis()); + writeOKFrame(); + } else if (cmd_frame[1] == 2) { + if (_ota_direct_admin.sendReturnPath(*this, recipient->id, _ms->getMillis())) writeOKFrame(); + else writeErrFrame(ERR_CODE_BAD_STATE); + } else { + if (_ota_direct_admin.matches(recipient->id, _ms->getMillis())) _ota_direct_admin.clear(); + writeOKFrame(); + } +#endif } else if (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ && len >= 2 + PUB_KEY_SIZE && cmd_frame[1] == 0) { uint8_t *pub_key = &cmd_frame[2]; ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); @@ -7798,6 +7841,12 @@ void MyMesh::handleCmdFrame(size_t len) { if (pkt) { uint8_t priority = cmd_frame[1]; if (tryParsePacket(pkt, &cmd_frame[2], len - 2)) { +#if COMPANION_FEATURE_OTA_CLI + if (pkt->getPayloadType() == PAYLOAD_TYPE_OTA + && _ota_direct_admin.active(_ms->getMillis())) { + pkt->tx_radio = mesh::RADIO_TX_PRIMARY; + } +#endif if (pkt->getPayloadType() == PAYLOAD_TYPE_RAW_CUSTOM) { const uint8_t policy = mesh::companion::trafficPolicy( mesh::companion::rawTrafficKind(pkt->payload, pkt->payload_len), @@ -10778,6 +10827,15 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, size_t reply_capacity = 160; while (*command == ' ' || *command == '\t') command++; +#if COMPANION_FEATURE_OTA_CLI + if (sender_timestamp == 0 && !strcmp(command, "get ota.direct")) { + snprintf(reply, reply_capacity, "> ota.direct v=1 active=%u busy=%u", + _ota_direct_admin.active(_ms->getMillis()) ? 1 : 0, + hasPendingRadioWork() ? 1 : 0); + return true; + } +#endif + if (strlen(command) > 3 && command[2] == '|') { // Optional two-character request prefix used by Companion CLI clients. memcpy(reply, command, 3); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 19d0610a7..609ed5bb0 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -64,6 +64,9 @@ namespace mesh { class BeaconManager; } #endif #include #include +#if COMPANION_FEATURE_OTA_CLI +#include +#endif #include #include #include @@ -527,6 +530,14 @@ protected: bool onContactOverwrite(const ContactInfo& contact) override; void onContactReferenceChanged(const ContactInfo* previous, ContactInfo* replacement) override; bool onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; +#if COMPANION_FEATURE_OTA_CLI + uint8_t getContactTxRadio(const mesh::Identity& peer) const override; + void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, + const uint8_t* secret, uint8_t* data, size_t len) override; + bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, + uint8_t* path, uint8_t path_len, uint8_t extra_type, + uint8_t* extra, uint8_t extra_len) override; +#endif void onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t* path) override; void onContactPathUpdated(const ContactInfo &contact) override; #if COMPANION_FEATURE_TEXT_TERMINAL @@ -636,6 +647,9 @@ public: bool canRecoverUsbLogging() const; private: +#if COMPANION_FEATURE_OTA_CLI + mesh::CompanionDirectAdminGuard _ota_direct_admin; +#endif #if MESH_ENABLE_FLEET_CONTROL // Exact ownership of the optional radio copy during two-part admission. mesh::Packet* _fleet_admission_original = nullptr; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index ce7588fa7..32630f099 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -2895,6 +2895,11 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m int MyMesh::searchPeersByHash(const uint8_t *hash) { int n = 0; +#if MESH_ENABLE_DIRECT_OTA_ADMIN + direct_ota_admin.tick(millis()); + if (direct_ota_admin.matchesHash(hash)) + matching_peer_indexes[n++] = mesh::DirectOtaAdminClient::PEER_INDEX; +#endif #if defined(WITH_MQTT_NEIGHBORS) // While a neighbor-scope discovery is active, overlay the heard neighbours // that are NOT already ACL clients so their RESPONSE packets can be decoded. @@ -2913,6 +2918,9 @@ int MyMesh::searchPeersByHash(const uint8_t *hash) { #endif for (int i = 0; i < acl.getNumClients() && n < MAX_CLIENTS; i++) { const auto* client = acl.getClientByIdx(i); +#if MESH_ENABLE_DIRECT_OTA_ADMIN + if (direct_ota_admin.matchesTarget(client->id)) continue; +#endif if (!guest_login.enabled() && (client->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) continue; if (client->id.isHashMatch(hash)) { @@ -2924,6 +2932,12 @@ int MyMesh::searchPeersByHash(const uint8_t *hash) { void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { int i = matching_peer_indexes[peer_idx]; +#if MESH_ENABLE_DIRECT_OTA_ADMIN + if (i == mesh::DirectOtaAdminClient::PEER_INDEX) { + direct_ota_admin.sharedSecret(dest_secret); + return; + } +#endif #if defined(WITH_MQTT_NEIGHBORS) // Overlay entries have no precomputed shared secret; derive it on the fly. if (neighbor_discover_active && i >= NEIGHBOR_DISCOVER_PEER_BASE) { @@ -3002,6 +3016,12 @@ void MyMesh::onGroupPacketRecv(mesh::Packet* packet) { void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const uint8_t *secret, uint8_t *data, size_t len) { int i = matching_peer_indexes[sender_idx]; +#if MESH_ENABLE_DIRECT_OTA_ADMIN + if (i == mesh::DirectOtaAdminClient::PEER_INDEX) { + direct_ota_admin.onData(*this, packet, type, data, len, millis()); + return; + } +#endif #if defined(WITH_MQTT_NEIGHBORS) // Overlay response: a heard neighbour (not an ACL client) answering our // anon-regions scope query. Consume it and stop -- it is not a client packet. @@ -3584,6 +3604,9 @@ bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t #define CTL_TYPE_NODE_DISCOVER_RESP 0x90 void MyMesh::onControlDataRecv(mesh::Packet* packet) { +#if MESH_ENABLE_DIRECT_OTA_ADMIN + direct_ota_admin.onDiscovery(packet, millis()); +#endif uint8_t type = packet->payload[0] & 0xF0; // just test upper 4 bits if (type == CTL_TYPE_NODE_DISCOVER_REQ && packet->payload_len >= 6 && (_prefs.discovery_responses & mesh::discovery::NODE_RESPONSES) @@ -12396,6 +12419,14 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * char *reply, int gpio_client_index, uint8_t gpio_path_hash_size, bool usb_origin) { _wireless_usb_command = usb_origin; +#if MESH_ENABLE_DIRECT_OTA_ADMIN + if (mesh::DirectOtaAdminClient::isCommand(command)) { + if (!usb_origin || sender_timestamp || sender != nullptr) + strcpy(reply, "ERR ota.peer requires physical USB"); + else direct_ota_admin.handleCommand(*this, command, reply, 160, millis()); + return; + } +#endif #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) _gpio_reply_tracker.beginCommand(gpio_client_index, gpio_path_hash_size, sender == NULL ? NULL : sender->id.pub_key); @@ -13361,6 +13392,9 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * } void MyMesh::loop() { +#if MESH_ENABLE_DIRECT_OTA_ADMIN + direct_ota_admin.service(*this, millis()); +#endif #if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED) _local_cli_output.service(); #endif diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 92c436e54..a6efa7300 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -12,6 +12,16 @@ #if defined(ENABLE_OTA) #include #endif +#ifndef MESH_ENABLE_DIRECT_OTA_ADMIN + #if defined(ESP32_PLATFORM) && defined(ENABLE_OTA) + #define MESH_ENABLE_DIRECT_OTA_ADMIN 1 + #else + #define MESH_ENABLE_DIRECT_OTA_ADMIN 0 + #endif +#endif +#if MESH_ENABLE_DIRECT_OTA_ADMIN + #include +#endif #include #include #include @@ -401,6 +411,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks bool handleGuestCommand(const char* command, char* reply, size_t capacity); bool handleDiscoveryResponseCommand(const char* command, char* reply, size_t capacity); ClientACL acl; +#if MESH_ENABLE_DIRECT_OTA_ADMIN + mesh::DirectOtaAdminClient direct_ota_admin; +#endif CommonCLI _cli; #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) UserGpioReplyTracker _gpio_reply_tracker; @@ -1350,6 +1363,9 @@ public: void handleUsbCommand(char* command, char* reply) { handleCommand(0, NULL, command, reply, -1, 1, true); } +#if MESH_ENABLE_DIRECT_OTA_ADMIN + void closeDirectOtaAdmin() { direct_ota_admin.reset(); } +#endif bool handleReplayResetCommand(ClientInfo* sender, const char* command, char* reply, bool usb_origin); #if MESH_ENABLE_HOST_CLI diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 21a3a6a7a..f089cd103 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -153,6 +153,9 @@ static constexpr size_t LOCAL_SERIAL_COMMAND_MAX = 159U; #endif static char command[LOCAL_SERIAL_COMMAND_MAX + 2U]; static bool command_overflow = false; +#if MESH_ENABLE_DIRECT_OTA_ADMIN +static bool command_sensitive = false; +#endif #ifdef ETHERNET_ENABLED static char ethernet_command[160]; #endif @@ -480,16 +483,48 @@ static void __attribute__((noinline)) serviceCommandInterfaces() { if (mesh::takeUsbTerminalSessionReset()) { #if MESH_ENABLE_HOST_KISS mesh::hostkiss::resetUsbSession(); +#endif +#if MESH_ENABLE_DIRECT_OTA_ADMIN + mesh::DirectOtaAdminClient::clearInput(command, sizeof(command)); + command_sensitive = false; + the_mesh.closeDirectOtaAdmin(); +#if defined(MESH_OTA_FOLDER_SERIAL_SHARED_CONSOLE) + mesh::ota::OtaContext::serialFolderSource().resetSessionState(); +#endif #endif command[0] = 0; command_overflow = false; the_mesh.cancelPendingSerialOutput(); } +#endif + Stream& console = mesh::usbConsolePort(); +#if MESH_ENABLE_DIRECT_OTA_ADMIN && defined(MESH_OTA_FOLDER_SERIAL_SHARED_CONSOLE) + auto& control_source = mesh::ota::OtaContext::serialFolderSource(); + control_source.enableSharedTextControl(); + auto console_available = [&]() { return control_source.availableControlBytes(); }; + auto console_read = [&]() { return control_source.readControlByte(); }; +#else + auto console_available = [&]() { return console.available(); }; + auto console_read = [&]() { return console.read(); }; +#endif +#if MESH_ENABLE_HOST_KISS + const auto kiss_can_read = [&]() { +#if MESH_ENABLE_DIRECT_OTA_ADMIN && defined(MESH_OTA_FOLDER_SERIAL_SHARED_CONSOLE) + return control_source.canReleaseControlInput(); +#else + return true; +#endif + }; #endif #if MESH_ENABLE_HOST_KISS // Claim binary input before a pending terminal listing can append text. // Keep the rest of this function alive for Ethernet and board housekeeping. - if (mesh::hostkiss::serviceUsb()) { + if (kiss_can_read() && mesh::hostkiss::serviceUsb()) { +#if MESH_ENABLE_DIRECT_OTA_ADMIN + mesh::DirectOtaAdminClient::clearInput(command, sizeof(command)); + command_sensitive = false; + the_mesh.closeDirectOtaAdmin(); +#endif command[0] = 0; command_overflow = false; #if MESH_USB_CONSOLE_COOPERATIVE @@ -506,7 +541,6 @@ static void __attribute__((noinline)) serviceCommandInterfaces() { usb_ready = usb_ready && !the_mesh.hasPendingSerialOutput() && mesh::canAcceptUsbConsoleCommand(); #endif - Stream& console = mesh::usbConsolePort(); #if defined(MESH_HIL_STARTUP_TRACE) && MESH_HIL_STARTUP_TRACE if (usb_ready && first_cli_ready) { first_cli_ready = false; @@ -525,14 +559,33 @@ static void __attribute__((noinline)) serviceCommandInterfaces() { bool line_complete = false; bool overlong_line_complete = false; size_t read_budget = 256; - while (usb_ready && read_budget-- > 0 && console.available()) { + while (usb_ready && read_budget-- > 0 && console_available()) { #if MESH_ENABLE_HOST_KISS - // Bytes may arrive after the loop-top KISS poll. Preserve its first FEND. - if (mesh::hostkiss::usbInputReserved(console.peek())) break; + // Reserve raw FEND only at a complete mOTA/text boundary. A late binary + // response body can contain FEND and must stay with its source framer. + if (kiss_can_read() && mesh::hostkiss::usbInputReserved(console.peek())) break; #endif - char c = console.read(); + const int value = console_read(); + if (value == -2) continue; // retained mOTA response byte, never CLI text + if (value < 0) break; + if (value == 0) { // source control-queue overflow invalidates the whole line +#if MESH_ENABLE_DIRECT_OTA_ADMIN + mesh::DirectOtaAdminClient::clearInput(command, sizeof(command)); +#else + command[0] = 0; +#endif + len = 0; + command_overflow = true; + continue; + } + char c = static_cast(value); if (c == '\n') continue; +#if MESH_ENABLE_DIRECT_OTA_ADMIN + command_sensitive = command_sensitive || mesh::DirectOtaAdminClient::sensitiveInput(command); + console.print(command_sensitive && c != '\r' ? '*' : c); +#else console.print(c); +#endif if (command_overflow) { if (c == '\r') { @@ -559,6 +612,10 @@ static void __attribute__((noinline)) serviceCommandInterfaces() { } if (overlong_line_complete) { +#if MESH_ENABLE_DIRECT_OTA_ADMIN + mesh::DirectOtaAdminClient::clearInput(command, sizeof(command)); + command_sensitive = false; +#endif console.print('\n'); console.println(" -> Err - command too long"); command[0] = 0; @@ -602,6 +659,10 @@ static void __attribute__((noinline)) serviceCommandInterfaces() { console.printf(" -> %s\r\n", reply); } +#if MESH_ENABLE_DIRECT_OTA_ADMIN + if (command_sensitive) mesh::DirectOtaAdminClient::clearInput(command, sizeof(command)); + command_sensitive = false; +#endif command[0] = 0; // reset command buffer } diff --git a/src/Mesh.cpp b/src/Mesh.cpp index ccc4db1c5..2dd0f6a79 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -3164,7 +3164,9 @@ Packet* Mesh::createOtaPacket(const uint8_t* data, size_t len) { bool Mesh::sendOtaFlood(Packet* packet, uint32_t delay_millis) { packet->header &= ~PH_ROUTE_MASK; - packet->header |= ROUTE_TYPE_FLOOD; + // A local hop cap cannot constrain other repeaters' forwarding policy. + // DIRECT0 prevents them relaying a zero-hop update at all. + packet->header |= getOtaHopLimit() == 0 ? ROUTE_TYPE_DIRECT : ROUTE_TYPE_FLOOD; packet->setPathHashSizeAndCount(1, 0); rememberOtaForward(packet); // downstream echoes of our own OTA must not be relayed _tables->markSeen(packet); // mark as sent, in case it floods back to us diff --git a/src/helpers/CompanionDirectAdminGuard.h b/src/helpers/CompanionDirectAdminGuard.h new file mode 100644 index 000000000..372a177a4 --- /dev/null +++ b/src/helpers/CompanionDirectAdminGuard.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include + +namespace mesh { + +// RAM-only receive admission for a USB client's selected OTA admin peer. +// Matching happens after ECDH/MAC verification, before Binary login events or +// queued CLI messages lose their original RF route metadata. +class CompanionDirectAdminGuard { + Identity peer_; + uint8_t secret_[PUB_KEY_SIZE] = {}; + uint32_t until_ = 0; + bool enabled_ = false, login_ = false; + +public: + static constexpr uint32_t LEASE_MS = 300000; + ~CompanionDirectAdminGuard() { clear(); } + void clear() { + volatile uint8_t* bytes = secret_; + for (size_t i = 0; i < sizeof(secret_); ++i) bytes[i] = 0; + enabled_ = login_ = false; + } + bool active(uint32_t now) { + if (enabled_ && static_cast(now - until_) >= 0) clear(); + return enabled_; + } + bool matches(const Identity& peer, uint32_t now) { + return active(now) && peer_.matches(peer); + } + bool selected(const Identity& peer, uint32_t now) const { + return enabled_ && static_cast(now - until_) < 0 && peer_.matches(peer); + } + void arm(const Identity& peer, const uint8_t* secret, uint32_t now) { + if (!matches(peer, now)) login_ = false; + peer_ = peer; + memcpy(secret_, secret, sizeof(secret_)); + enabled_ = true; + until_ = now + LEASE_MS; + } + void beginLogin(const Identity& peer, uint32_t now) { + if (matches(peer, now)) login_ = true; + } + bool accepts(const Packet* packet, uint8_t type, const uint8_t* secret, uint32_t now) { + if (!active(now) || !secret || memcmp(secret_, secret, sizeof(secret_))) return true; + if (!packet || packet->getPathHashCount() != 0) return false; + if (packet->isRouteDirect()) return true; + // Unknown ACL return paths cause old servers to flood the first login + // response. No relayed copy, other response type, or CLI uses this exception. + return login_ && type == PAYLOAD_TYPE_RESPONSE && packet->isRouteFlood(); + } + bool acceptsPath(const Packet* packet, const uint8_t* secret, uint8_t path_len, uint32_t now) { + if (!active(now) || !secret || memcmp(secret_, secret, sizeof(secret_))) return true; + return !(path_len & 63) && accepts(packet, PAYLOAD_TYPE_PATH, secret, now); + } + bool sendReturnPath(Mesh& mesh, const Identity& peer, uint32_t now) { + if (!matches(peer, now)) return false; + const uint8_t empty[1] = {}; + Packet* packet = mesh.createPathReturn(peer_, secret_, empty, 0, 0, nullptr, 0); + if (packet) packet->tx_radio = RADIO_TX_PRIMARY; + if (!packet || !mesh.sendDirect(packet, nullptr, 0)) return false; + login_ = false; + until_ = now + LEASE_MS; + return true; + } +}; + +} // namespace mesh diff --git a/src/helpers/DirectOtaAdminClient.h b/src/helpers/DirectOtaAdminClient.h new file mode 100644 index 000000000..eac706e0e --- /dev/null +++ b/src/helpers/DirectOtaAdminClient.h @@ -0,0 +1,319 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace mesh { + +// One USB-owned, RAM-only admin session. The selected peer is overlaid on the +// repeater's decrypt lookup, never added to its incoming-client ACL. +class DirectOtaAdminClient { +public: + static constexpr int PEER_INDEX = -2; + static constexpr size_t MAX_PEERS = 8; + static constexpr size_t RESULT_BYTES = 160; + static constexpr size_t RESULT_CHUNK = 48; + static constexpr uint32_t REQUEST_MS = 30000; + static constexpr uint32_t SESSION_MS = 300000; + struct Peer { Identity id; int16_t rssi; int8_t snr; uint8_t type; uint32_t heard; }; + +private: + enum Phase : uint8_t { Idle, Discover, Login, ReturnPath, Proof, Command }; + Phase phase_ = Idle; + Identity target_; + uint8_t secret_[PUB_KEY_SIZE] = {}; + Peer peers_[MAX_PEERS]; + uint8_t peers_count_ = 0; + bool session_ = false, authenticated_ = false, error_ = false; + uint32_t job_ = 0, deadline_ = 0, session_until_ = 0, discover_tag_ = 0; + uint32_t last_peer_timestamp_ = 0; + uint32_t proof_retry_at_ = 0; + uint8_t proof_attempts_ = 0; + uint8_t correlation_ = 0; + char prefix_[4] = {}; + uint8_t result_[RESULT_BYTES] = {}; + size_t result_length_ = 0; + + static bool expired(uint32_t now, uint32_t deadline) { + return static_cast(now - deadline) >= 0; + } + static void erase(void* buffer, size_t size) { + volatile uint8_t* p = static_cast(buffer); + while (size--) *p++ = 0; + } + void clearSession() { + erase(secret_, sizeof(secret_)); + erase(target_.pub_key, sizeof(target_.pub_key)); + session_ = authenticated_ = false; + last_peer_timestamp_ = 0; + } + void finish(const char* text, bool error = false) { + phase_ = Idle; + error_ = error; + result_length_ = strlen(text); + if (result_length_ > sizeof(result_)) result_length_ = sizeof(result_); + memcpy(result_, text, result_length_); + } + void begin(Phase phase, uint32_t now, uint32_t duration = REQUEST_MS) { + if (++job_ == 0) ++job_; + phase_ = phase; + deadline_ = now + duration; + error_ = false; + erase(result_, sizeof(result_)); + result_length_ = 0; + } + void queued(char* reply, size_t capacity) const { + snprintf(reply, capacity, "OTApeer job=%08lX state=pending", (unsigned long)job_); + } + bool queueCommand(Mesh& mesh, const char* command, uint32_t now) { + const size_t length = strlen(command); + // Preserve room for timestamp, type, reflected prefix and AES padding. + if (length == 0 || length > 154) return false; + uint8_t plain[5 + 3 + 154] = {}; + const uint32_t timestamp = mesh.getRTCClock()->getCurrentTimeUnique(); + memcpy(plain, ×tamp, 4); + plain[4] = 3 << 2; // TXT_TYPE_CLI_COMMAND + snprintf(prefix_, sizeof(prefix_), "%02X|", (unsigned)++correlation_); + memcpy(plain + 5, prefix_, 3); + memcpy(plain + 8, command, length); + Packet* packet = mesh.createDatagram(PAYLOAD_TYPE_TXT_MSG, target_, secret_, plain, 8 + length); + erase(plain, sizeof(plain)); + if (packet) packet->tx_radio = RADIO_TX_PRIMARY; + session_until_ = now + SESSION_MS; + return packet && mesh.sendDirect(packet, nullptr, 0); + } + bool queueReturnPath(Mesh& mesh) { + const uint8_t empty_path[1] = {}; + Packet* packet = mesh.createPathReturn(target_, secret_, empty_path, 0, 0, nullptr, 0); + if (packet) packet->tx_radio = RADIO_TX_PRIMARY; + if (!packet || !mesh.sendDirect(packet, nullptr, 0)) return false; + ++proof_attempts_; + phase_ = ReturnPath; + return true; + } + static bool parseKey(const char* text, Identity& id, const char*& rest) { + if (strlen(text) < PUB_KEY_SIZE * 2) return false; + char hex[PUB_KEY_SIZE * 2 + 1]; + memcpy(hex, text, PUB_KEY_SIZE * 2); + hex[PUB_KEY_SIZE * 2] = 0; + if (!Utils::fromHex(id.pub_key, PUB_KEY_SIZE, hex)) return false; + rest = text + PUB_KEY_SIZE * 2; + return *rest == 0 || *rest == ' '; + } + static bool offsetValue(const char* text, size_t& value) { + value = 0; + if (!*text) return true; + for (; *text; ++text) { + if (*text < '0' || *text > '9' || value > RESULT_BYTES) return false; + value = value * 10 + static_cast(*text - '0'); + } + return value <= RESULT_BYTES; + } + +public: + ~DirectOtaAdminClient() { clearSession(); erase(result_, sizeof(result_)); } + void reset() { + clearSession(); erase(result_, sizeof(result_)); + result_length_ = 0; phase_ = Idle; error_ = false; peers_count_ = 0; + } + static bool isCommand(const char* text) { + return text && strncmp(text, "ota.peer", 8) == 0 && (text[8] == 0 || text[8] == ' '); + } + // Called before console echo, while the password is still arriving. Once + // true the caller keeps masking until the complete line (even on overflow). + static bool sensitiveInput(const char* text) { + return strncmp(text, "ota.peer login ", 15) == 0 && strchr(text + 15, ' ') != nullptr; + } + static void clearInput(char* text, size_t capacity) { erase(text, capacity); } + bool hasSession() const { return session_; } + bool awaitingPeer() const { return session_ && (phase_ == Login || phase_ == ReturnPath || phase_ == Proof || phase_ == Command); } + bool matchesHash(const uint8_t* hash) const { return awaitingPeer() && target_.isHashMatch(hash); } + bool matchesTarget(const Identity& id) const { return awaitingPeer() && target_.matches(id); } + void sharedSecret(uint8_t* destination) const { memcpy(destination, secret_, PUB_KEY_SIZE); } + void tick(uint32_t now) { + if (phase_ != Idle && expired(now, deadline_)) { + if (phase_ == Discover) finish("discovery complete"); + else { + if (phase_ == Login || phase_ == ReturnPath || phase_ == Proof) clearSession(); + finish("request timed out; result is unknown", true); + } + } + if (session_ && expired(now, session_until_)) { + clearSession(); + if (phase_ != Idle) finish("admin session expired", true); + } + } + void service(Mesh& mesh, uint32_t now) { + tick(now); + if (phase_ == Proof && expired(now, proof_retry_at_) && proof_attempts_ < 3) { + if (!queueReturnPath(mesh)) { clearSession(); finish("login return path queue failed", true); } + } + // PATH has lower queue priority than CLI. Wait until its transmission and + // retries finish before admitting the proof, rather than relying on delay. + if (phase_ == ReturnPath && !mesh.hasPendingRadioWork()) { + phase_ = Proof; + if (!queueCommand(mesh, "get public.key", now)) { clearSession(); finish("login proof queue failed", true); } + else proof_retry_at_ = now + 8000; + } + } + void handleCommand(Mesh& mesh, const char* command, char* reply, size_t capacity, uint32_t now) { + tick(now); + const char* args = command + 8; + while (*args == ' ') ++args; + if (!strcmp(args, "status") || !*args) { + snprintf(reply, capacity, "OTApeer v=1 session=%u busy=%u peers=%u", authenticated_, phase_ != Idle, peers_count_); + return; + } + if (!strcmp(args, "close")) { + reset(); + strcpy(reply, "OTApeer closed"); + return; + } + if (!strncmp(args, "result", 6) && (args[6] == 0 || args[6] == ' ')) { + const char* rest = args + 6; + while (*rest == ' ') ++rest; + size_t offset; + if (!offsetValue(rest, offset) || offset > result_length_) { strcpy(reply, "ERR peer result offset"); return; } + size_t count = result_length_ - offset; + if (count > RESULT_CHUNK) count = RESULT_CHUNK; + char hex[RESULT_CHUNK * 2 + 1]; + if (count) Utils::toHex(hex, result_ + offset, count); + else strcpy(hex, "-"); + snprintf(reply, capacity, "OTApeer job=%08lX state=%s bytes=%u off=%u data=%s", (unsigned long)job_, + phase_ != Idle ? "pending" : (error_ ? "error" : "ok"), (unsigned)result_length_, (unsigned)offset, hex); + return; + } + if (!strncmp(args, "peers", 5) && (args[5] == 0 || args[5] == ' ')) { + const char* rest = args + 5; + while (*rest == ' ') ++rest; + size_t index; + if (!offsetValue(rest, index) || index >= peers_count_) { strcpy(reply, "ERR peer not found"); return; } + char hex[PUB_KEY_SIZE * 2 + 1]; Utils::toHex(hex, peers_[index].id.pub_key, PUB_KEY_SIZE); + snprintf(reply, capacity, "OTApeer peer=%u key=%s type=%u rssi=%d snr=%d", (unsigned)index, hex, + (unsigned)peers_[index].type, (int)peers_[index].rssi, (int)peers_[index].snr); + return; + } + if (phase_ != Idle) { strcpy(reply, "ERR peer request pending"); return; } + if (!strcmp(args, "discover")) { + clearSession(); peers_count_ = 0; + begin(Discover, now, 6000); + mesh.getRNG()->random(reinterpret_cast(&discover_tag_), 4); + if (!discover_tag_) discover_tag_ = 1; + uint8_t data[6] = {0x80, (1 << 2) | (1 << 3)}; + memcpy(data + 2, &discover_tag_, 4); + Packet* packet = mesh.createControlData(data, sizeof(data)); + if (packet) packet->tx_radio = RADIO_TX_PRIMARY; + if (!packet || !mesh.sendDirect(packet, nullptr, 0)) finish("discovery queue failed", true); + queued(reply, capacity); + return; + } + const bool login = !strncmp(args, "login ", 6); + const bool cli = !strncmp(args, "cmd ", 4); + if (login || cli) { + Identity peer; const char* rest; + if (!parseKey(args + (login ? 6 : 4), peer, rest) || *rest != ' ') { strcpy(reply, "ERR peer key or arguments"); return; } + ++rest; + if (peer.matches(mesh.self_id)) { strcpy(reply, "ERR peer cannot be this repeater"); return; } + for (const char* p = rest; *p; ++p) if (static_cast(*p) < 32 || *p == 127) { strcpy(reply, "ERR peer control character"); return; } + if (login) { + const size_t length = strlen(rest); + if (length > 63) { strcpy(reply, "ERR peer password too long"); return; } + const Peer* discovered = nullptr; + for (unsigned i = 0; i < peers_count_; ++i) { + if (peers_[i].id.matches(peer) && !expired(now, peers_[i].heard + 60000)) { + discovered = &peers_[i]; break; + } + } + if (!discovered) { strcpy(reply, "ERR peer needs fresh discovery"); return; } + const size_t password_offset = discovered->type == 3 ? 8 : 4; + clearSession(); target_ = peer; + mesh.self_id.calcSharedSecret(secret_, target_); + uint8_t nonzero = 0; + for (size_t i = 0; i < sizeof(secret_); ++i) nonzero |= secret_[i]; + if (!nonzero) { clearSession(); strcpy(reply, "ERR peer invalid public key"); return; } + session_ = true; session_until_ = now + SESSION_MS; + mesh.getRNG()->random(&correlation_, 1); + begin(Login, now); + uint8_t data[8 + 63] = {}; + const uint32_t timestamp = mesh.getRTCClock()->getCurrentTimeUnique(); + // Rooms prefix the password with a sync-since cursor; zero requests no + // destructive cursor change and the server preserves its saved value. + memcpy(data, ×tamp, 4); memcpy(data + password_offset, rest, length); + Packet* packet = mesh.createAnonDatagram(PAYLOAD_TYPE_ANON_REQ, mesh.self_id, target_, secret_, data, password_offset + length); + erase(data, sizeof(data)); + if (packet) packet->tx_radio = RADIO_TX_PRIMARY; + if (!packet || !mesh.sendDirect(packet, nullptr, 0)) { clearSession(); finish("login queue failed", true); } + } else { + if (!authenticated_ || !target_.matches(peer)) { strcpy(reply, "ERR peer login required"); return; } + begin(Command, now); + if (!queueCommand(mesh, rest, now)) finish("command queue failed or command too long", true); + } + queued(reply, capacity); + return; + } + strcpy(reply, "ERR use ota.peer status|discover|peers [n]|login KEY PASSWORD|cmd KEY COMMAND|result [off]|close"); + } + + void onDiscovery(const Packet* packet, uint32_t now) { + tick(now); + if (phase_ != Discover || !packet || !packet->isRouteDirect() || packet->getPathHashCount() != 0 + || packet->payload_len < 6 + PUB_KEY_SIZE + || (packet->payload[0] != 0x92 && packet->payload[0] != 0x93)) return; + uint32_t tag; memcpy(&tag, packet->payload + 2, 4); + if (tag != discover_tag_) return; + Identity id(packet->payload + 6); + for (unsigned i = 0; i < peers_count_; ++i) if (peers_[i].id.matches(id)) { + // A changing advertised role cannot silently change the login layout. + if (peers_[i].type == (packet->payload[0] & 15)) peers_[i].heard = now; + return; + } + if (peers_count_ == MAX_PEERS) return; + Peer& peer = peers_[peers_count_++]; peer.id = id; peer.rssi = packet->_rssi; peer.snr = packet->_snr; + peer.type = packet->payload[0] & 15; peer.heard = now; + } + + void onData(Mesh& mesh, const Packet* packet, uint8_t type, const uint8_t* data, size_t length, uint32_t now) { + tick(now); + if (!session_ || !packet || packet->getPathHashCount() != 0 || !data || length < 5) return; + // Older servers flood their first response before knowing a return path. + // Only that authenticated login response may use a zero-hop flood envelope. + if (!packet->isRouteDirect() && !(phase_ == Login && type == PAYLOAD_TYPE_RESPONSE && packet->isRouteFlood())) return; + uint32_t timestamp; memcpy(×tamp, data, 4); + if (timestamp <= last_peer_timestamp_) return; + if (phase_ == Login && type == PAYLOAD_TYPE_RESPONSE) { + // Modern ACL role 3 is full admin; region/filter managers cannot update. + if (length < 13 || data[4] != 0 || (data[7] & 7) != 3) { clearSession(); finish("admin login rejected", true); return; } + last_peer_timestamp_ = timestamp; + proof_attempts_ = 0; + if (!queueReturnPath(mesh)) { clearSession(); finish("login return path queue failed", true); } + return; + } + if ((phase_ != Proof && phase_ != Command) || type != PAYLOAD_TYPE_TXT_MSG + || (data[4] >> 2) != 1 || length < 8 || memcmp(data + 5, prefix_, 3)) return; + const uint8_t* text = data + 8; + size_t text_len = length - 8; + const void* terminator = memchr(text, 0, text_len); + if (terminator) text_len = static_cast(terminator) - text; + if (phase_ == Proof) { + while (text_len && (*text == ' ' || *text == '>')) { ++text; --text_len; } + char hex[PUB_KEY_SIZE * 2 + 1]; + if (text_len != PUB_KEY_SIZE * 2) { clearSession(); finish("invalid login identity proof", true); return; } + memcpy(hex, text, text_len); hex[text_len] = 0; + Identity proof; const char* rest; + if (!parseKey(hex, proof, rest) || !proof.matches(target_)) { clearSession(); finish("login identity mismatch", true); return; } + authenticated_ = true; + finish("authenticated"); + } else { + result_length_ = text_len > sizeof(result_) ? sizeof(result_) : text_len; + memcpy(result_, text, result_length_); + phase_ = Idle; error_ = false; + } + last_peer_timestamp_ = timestamp; + session_until_ = now + SESSION_MS; + } +}; + +} // namespace mesh diff --git a/src/helpers/ota/MotaSourceSerial.cpp b/src/helpers/ota/MotaSourceSerial.cpp index 2aca46b7b..21784a855 100644 --- a/src/helpers/ota/MotaSourceSerial.cpp +++ b/src/helpers/ota/MotaSourceSerial.cpp @@ -13,10 +13,13 @@ void SerialMotaSource::resetSessionState() { _response_payload_len = _response_remaining = 0; _control_offset = _control_len = 0; _control_discard_line = false; + _control_in_line = _last_control_byte = false; } void SerialMotaSource::queueControlByte(uint8_t b) { const bool newline = b == '\r' || b == '\n'; + _last_control_byte = true; + _control_in_line = !newline; if (_control_discard_line) { if (!newline) return; _control_discard_line = false; @@ -43,6 +46,7 @@ int SerialMotaSource::takeControlByte() { } int SerialMotaSource::readTrackedByte() { + _last_control_byte = false; const int value = _io.read(); if (value < 0) return value; if (!_shared_text_control) return value; @@ -53,7 +57,10 @@ int SerialMotaSource::readTrackedByte() { } switch (_response_stage) { case SYNC_FIRST: - if (b == MOTA_SEEDER_RSP_MAGIC0) _response_stage = SYNC_SECOND; + // The shared owner writes complete ASCII records under the same lock + // as binary responses. Once a CLI line starts, its password/arguments + // may contain "ms" without becoming a binary response header. + if (!_control_in_line && b == MOTA_SEEDER_RSP_MAGIC0) _response_stage = SYNC_SECOND; else queueControlByte(b); break; case SYNC_SECOND: @@ -61,10 +68,8 @@ int SerialMotaSource::readTrackedByte() { _response_stage = RESPONSE_OP; } else { queueControlByte(MOTA_SEEDER_RSP_MAGIC0); - if (b != MOTA_SEEDER_RSP_MAGIC0) { - queueControlByte(b); - _response_stage = SYNC_FIRST; - } + queueControlByte(b); + _response_stage = SYNC_FIRST; } break; case RESPONSE_OP: @@ -156,6 +161,7 @@ bool SerialMotaSource::txn(uint8_t op, const uint8_t* args, uint8_t arglen, while ((millis() - t0) < _to) { int c = readTrackedByte(); if (c < 0) { delay(1); continue; } + if (_shared_text_control && _last_control_byte) { prev = 0; continue; } if (prev == MOTA_SEEDER_RSP_MAGIC0 && (uint8_t)c == MOTA_SEEDER_RSP_MAGIC1) { got = true; break; } prev = (uint8_t)c; } diff --git a/src/helpers/ota/MotaSourceSerial.h b/src/helpers/ota/MotaSourceSerial.h index 40c74d5a4..55190acee 100644 --- a/src/helpers/ota/MotaSourceSerial.h +++ b/src/helpers/ota/MotaSourceSerial.h @@ -68,10 +68,19 @@ private: ResponseStage _response_stage = SYNC_FIRST; uint32_t _response_payload_len = 0; uint32_t _response_remaining = 0; + // A repeater's USB admin command can include a full key and remote CLI text. + // Retain that whole bounded line when a synchronous source read encounters + // it. Constrained nRF52 Companions retain their existing short-control size. +#if defined(ESP32_PLATFORM) + uint8_t _control_queue[384] = {}; +#else uint8_t _control_queue[64] = {}; - uint8_t _control_offset = 0; - uint8_t _control_len = 0; +#endif + uint16_t _control_offset = 0; + uint16_t _control_len = 0; bool _control_discard_line = false; + bool _control_in_line = false; + bool _last_control_byte = false; Stream& _io; uint32_t _to; diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 8c50ac9eb..c0fd5a680 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -56,6 +56,7 @@ #include "MotaSourceSerial.h" // relay an external folder served by a host daemon over the USB serial #include "../UsbLogging.h" #ifndef OTA_FOLDER_SERIAL_STREAM + #define MESH_OTA_FOLDER_SERIAL_SHARED_CONSOLE 1 #if defined(NRF52_PLATFORM) \ || MESH_ESP32_USB_CONSOLE_COOPERATIVE // Native USB serial-folder requests share the primary session facade. diff --git a/test/test_companion_direct_admin_guard.py b/test/test_companion_direct_admin_guard.py new file mode 100644 index 000000000..9ff43565d --- /dev/null +++ b/test/test_companion_direct_admin_guard.py @@ -0,0 +1,152 @@ +"""Execute Companion's guarded Binary modes and decrypted RX admission offline.""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +from test_direct_ota_admin_client import STUB +from test_replay_reset_integration import extract_braced + + +ROOT = Path(__file__).resolve().parents[1] +HARNESS = r''' +#include +#define CMD_SEND_PATH_DISCOVERY_REQ 52 +#define ERR_CODE_NOT_FOUND 1 +#define ERR_CODE_BAD_STATE 2 +struct ContactInfo { + mesh::Identity id; + uint8_t secret[32]; + ContactInfo(){memset(id.pub_key,0x11,32);memset(secret,0x44,32);} + const uint8_t* getSharedSecret(const mesh::LocalIdentity&)const{return secret;} +}; +struct Timer { uint32_t now=100;uint32_t getMillis()const{return now;} }; +struct BaseChatMesh:mesh::Mesh { + int data_calls=0,path_calls=0; + uint8_t getContactTxRadio(const mesh::Identity&)const{return 2;} + void onPeerDataRecv(mesh::Packet*,uint8_t,int,const uint8_t*,uint8_t*,size_t){++data_calls;} + bool onPeerPathRecv(mesh::Packet*,int,const uint8_t*,uint8_t*,uint8_t,uint8_t,uint8_t*,uint8_t){++path_calls;return true;} +}; +struct MyMesh:BaseChatMesh { + Timer timer;Timer* _ms=&timer;ContactInfo contact; + uint8_t cmd_frame[64]{};int response=-1; + mesh::CompanionDirectAdminGuard _ota_direct_admin; + ContactInfo* lookupContactByPubKey(const uint8_t* key,int){return !memcmp(key,contact.id.pub_key,32)?&contact:nullptr;} + void writeOKFrame(){response=0;} + void writeErrFrame(int value){response=value;} + void dispatch(size_t len){ + response=-1; +#if FULL + @BINARY@ +#else + (void)len; +#endif + } + void mode(int value){cmd_frame[0]=52;cmd_frame[1]=value;memcpy(cmd_frame+2,contact.id.pub_key,32);dispatch(34);} + bool command(const char* command,uint32_t sender_timestamp,char* reply){ + const size_t reply_capacity=160; +#if FULL + @GETTER@ +#else + (void)command;(void)sender_timestamp;(void)reply;(void)reply_capacity; +#endif + return false; + } + void onPeerDataRecv(mesh::Packet*,uint8_t,int,const uint8_t*,uint8_t*,size_t); + uint8_t getContactTxRadio(const mesh::Identity&)const; + bool onPeerPathRecv(mesh::Packet*,int,const uint8_t*,uint8_t*,uint8_t,uint8_t,uint8_t*,uint8_t); + void rawRoute(mesh::Packet* pkt){ +#if FULL + @RAW_ROUTE@ +#else + (void)pkt; +#endif + } +}; +@CALLBACKS@ +int main(){ + MyMesh m;char reply[160]{}; + m.mode(1); +#if FULL + assert(m.response==0&&m.calls==0); // capability negotiation has no RF + assert(m.getContactTxRadio(m.contact.id)==mesh::RADIO_TX_PRIMARY); + mesh::Identity another;assert(m.getContactTxRadio(another)==2); + mesh::Packet ota;ota.payload_type=12;m.rawRoute(&ota);assert(ota.tx_radio==mesh::RADIO_TX_PRIMARY); + mesh::Packet unrelated;unrelated.payload_type=2;m.rawRoute(&unrelated);assert(unrelated.tx_radio==0); + assert(m.command("get ota.direct",0,reply)&&strstr(reply,"active=1 busy=0")); + assert(!m.command("get ota.direct",1,reply)); + uint8_t body[13]{};mesh::Packet packet;packet.direct=false; + uint8_t other[32];memset(other,0x77,32); + m.onPeerDataRecv(&packet,PAYLOAD_TYPE_RESPONSE,0,m.contact.secret,body,13);assert(m.data_calls==0); + m._ota_direct_admin.beginLogin(m.contact.id,m.timer.now); + m.onPeerDataRecv(&packet,PAYLOAD_TYPE_RESPONSE,0,m.contact.secret,body,13);assert(m.data_calls==1); + packet.path_len=1; + m.onPeerDataRecv(&packet,PAYLOAD_TYPE_RESPONSE,0,m.contact.secret,body,13);assert(m.data_calls==1); + m.onPeerDataRecv(&packet,PAYLOAD_TYPE_RESPONSE,0,other,body,13);assert(m.data_calls==2); + packet.path_len=0; + m.onPeerDataRecv(&packet,PAYLOAD_TYPE_TXT_MSG,0,m.contact.secret,body,13);assert(m.data_calls==2); + m.mode(2);assert(m.response==0&&m.type==PAYLOAD_TYPE_PATH&&m.packet.tx_radio==mesh::RADIO_TX_PRIMARY); + assert(m.command("get ota.direct",0,reply)&&strstr(reply,"busy=1")); + m.pending=false;assert(m.command("get ota.direct",0,reply)&&strstr(reply,"busy=0")); + m.onPeerDataRecv(&packet,PAYLOAD_TYPE_RESPONSE,0,m.contact.secret,body,13);assert(m.data_calls==2); + packet.direct=true; + m.onPeerDataRecv(&packet,PAYLOAD_TYPE_TXT_MSG,0,m.contact.secret,body,13);assert(m.data_calls==3); + assert(!m.onPeerPathRecv(&packet,0,m.contact.secret,body,1,0,body,0));assert(m.path_calls==0); + assert(m.onPeerPathRecv(&packet,0,m.contact.secret,body,0,0,body,0));assert(m.path_calls==1); + m.mode(3);assert(m.response==0&&!m._ota_direct_admin.active(m.timer.now)); + assert(m.getContactTxRadio(m.contact.id)==2); + ota.tx_radio=0;m.rawRoute(&ota);assert(ota.tx_radio==0); + packet.direct=false;packet.path_len=2; + m.onPeerDataRecv(&packet,PAYLOAD_TYPE_TXT_MSG,0,m.contact.secret,body,13);assert(m.data_calls==4); + m.timer.now=0xFFFFFF00;m.mode(1);assert(m._ota_direct_admin.active(m.timer.now+1)); + assert(!m._ota_direct_admin.active(m.timer.now+mesh::CompanionDirectAdminGuard::LEASE_MS)); + m.mode(2);assert(m.response==ERR_CODE_BAD_STATE); + m.cmd_frame[1]=1;m.cmd_frame[2]^=1;m.dispatch(34);assert(m.response==ERR_CODE_NOT_FOUND); +#else + assert(m.response==-1&&m.calls==0); // reserved stock mode never sends RF + assert(!m.command("get ota.direct",0,reply)); +#endif +} +''' + + +class CompanionDirectAdminGuardTest(unittest.TestCase): + def test_guard_and_production_companion_wiring(self): + compiler = shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++17 compiler is required") + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + binary = extract_braced(source, "if (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ\n && len == 2 + PUB_KEY_SIZE").replace("#endif", "") + getter = extract_braced(source, 'if (sender_timestamp == 0 && !strcmp(command, "get ota.direct"))') + callbacks = "\n".join(extract_braced(source, signature) for signature in ( + "uint8_t MyMesh::getContactTxRadio(", "void MyMesh::onPeerDataRecv(", "bool MyMesh::onPeerPathRecv(")) + raw = extract_braced(source, "if (pkt->getPayloadType() == PAYLOAD_TYPE_OTA\n && _ota_direct_admin.active") + self.assertIn("_ota_direct_admin.beginLogin(recipient->id", source) + self.assertIn("&& len >= 2 + PUB_KEY_SIZE && cmd_frame[1] == 0)", source) + mesh = (ROOT / "src/Mesh.cpp").read_text() + start = mesh.index("Packet* Mesh::createPathReturn(const uint8_t* dest_hash,") + end = mesh.index("\nPacket* Mesh::createDatagram(", start) + stub = STUB.replace("@PATH_BUILDER@", mesh[start:end]) + stub = stub.replace("#define PAYLOAD_TYPE_PATH 8", "#define PAYLOAD_TYPE_PATH 8\n#define PAYLOAD_TYPE_OTA 12") + stub = stub.replace("bool direct=true;", "uint8_t payload_type=0;uint8_t getPayloadType()const{return payload_type;}\n bool direct=true;") + with tempfile.TemporaryDirectory(prefix="meshcore-companion-direct-") as folder: + folder = Path(folder) + (folder / "Mesh.h").write_text(stub) + for full in (0, 1): + code = HARNESS.replace("@BINARY@", binary).replace("@GETTER@", getter) + code = code.replace("@RAW_ROUTE@", raw) + code = code.replace("@CALLBACKS@", callbacks if full else "") + (folder / "test.cpp").write_text(code) + executable = folder / "guard.exe" + result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + f"-DFULL={full}", "-I" + str(folder), "-I" + str(ROOT / "src"), + str(folder / "test.cpp"), "-o", str(executable)], + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_direct_ota_admin_client.py b/test/test_direct_ota_admin_client.py new file mode 100644 index 000000000..1b17b427b --- /dev/null +++ b/test/test_direct_ota_admin_client.py @@ -0,0 +1,296 @@ +"""Execute the USB repeater admin client's production state machine.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +STUB = r''' +#pragma once +#include +#include +#include +#include +#include +#include +#define PUB_KEY_SIZE 32 +#define PAYLOAD_TYPE_ANON_REQ 7 +#define PAYLOAD_TYPE_TXT_MSG 2 +#define PAYLOAD_TYPE_RESPONSE 1 +#define PAYLOAD_TYPE_PATH 8 +#define PATH_HASH_SIZE 1 +#define MAX_COMBINED_PATH 64 +#define MAX_PACKET_PAYLOAD 184 +#define PH_TYPE_SHIFT 2 +#define MESH_DEBUG_PRINTLN(...) do {} while(0) +namespace mesh { +enum { RADIO_TX_PRIMARY=1 }; +struct Identity { + uint8_t pub_key[32]{}; + Identity()=default; + explicit Identity(const uint8_t* key){memcpy(pub_key,key,32);} + bool matches(const Identity& o) const{return !memcmp(pub_key,o.pub_key,32);} + bool isHashMatch(const uint8_t* hash) const{return pub_key[0]==hash[0];} + size_t copyHashTo(uint8_t* out)const{*out=pub_key[0];return 1;} +}; +struct LocalIdentity:Identity { + bool invalid_peer=false; + void calcSharedSecret(uint8_t* dest,const Identity& id)const{for(int i=0;i<32;++i)dest[i]=invalid_peer?0:pub_key[i]^id.pub_key[i];} +}; +struct Utils { + static int 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;} + static bool fromHex(uint8_t* out,size_t n,const char* text){ + if(strlen(text)!=n*2)return false; + for(size_t i=0;i>4];out[i*2+1]=h[in[i]&15];}out[n*2]=0;} + static size_t encryptThenMAC(const uint8_t* secret,uint8_t* out,const uint8_t* plain,size_t length){assert(secret[0]==0x44);out[0]=0xCA;out[1]=0xFE;memcpy(out+2,plain,length);return length+2;} +}; +struct Packet { + uint8_t payload[184]{}; + size_t payload_len=0,path_len=0; + int tx_radio=0;int16_t _rssi=-95;int8_t _snr=20; + bool direct=true; + bool isRouteDirect()const{return direct;} + bool isRouteFlood()const{return !direct;} + size_t getPathHashCount()const{return path_len&63;} + uint8_t header=0; +}; +struct Clock {uint32_t now=10000;uint32_t getCurrentTimeUnique(){return ++now;}}; +struct Random {void random(uint8_t* out,size_t size){for(size_t i=0;i sent; + int type=-1,calls=0;bool alloc_ok=true,send_ok=true,pending=false; + Mesh(){memset(self_id.pub_key,0x55,32);} + Clock* getRTCClock(){return &clock;} + Random* getRNG(){return &rng;} + Packet* make(int t,const uint8_t* data,size_t length){ + if(!alloc_ok)return nullptr; + ++calls;type=t;sent.assign(data,data+length);packet=Packet();return &packet; + } + Packet* createDatagram(int t,const Identity&,const uint8_t*,const uint8_t* d,size_t n){return make(t,d,n);} + Packet* createAnonDatagram(int t,const LocalIdentity&,const Identity&,const uint8_t*,const uint8_t* d,size_t n){return make(t,d,n);} + Packet* createControlData(const uint8_t* d,size_t n){return make(11,d,n);} + Packet* obtainNewPacket(){if(!alloc_ok)return nullptr;++calls;type=PAYLOAD_TYPE_PATH;packet=Packet();return &packet;} + Packet* createPathReturn(const uint8_t*,const uint8_t*,const uint8_t*,uint8_t,uint8_t,const uint8_t*,size_t); + Packet* createPathReturn(const Identity& dest,const uint8_t* secret,const uint8_t* path,uint8_t length,uint8_t type,const uint8_t* extra,size_t size){uint8_t hash[1];dest.copyHashTo(hash);return createPathReturn(hash,secret,path,length,type,extra,size);} + bool sendDirect(Packet* p,const uint8_t* path,uint8_t length){assert(p&&path==nullptr&&length==0&&p->tx_radio==RADIO_TX_PRIMARY);if(type==PAYLOAD_TYPE_PATH)sent.assign(p->payload,p->payload+p->payload_len);pending=send_ok;return send_ok;} + bool hasPendingRadioWork()const{return pending;} +}; +@PATH_BUILDER@ +} +''' + +HARNESS = r''' +#include +using Client=mesh::DirectOtaAdminClient; +static uint32_t millis(){return 100;} +struct UsbRole:mesh::Mesh { + Client direct_ota_admin; + void dispatch(uint32_t sender_timestamp,void* sender,char* command,char* reply,bool usb_origin){ + @USB_GATE@ + strcpy(reply,"not a peer command"); + } +}; +static std::string echoInput(const std::string& wire){ + struct Console { + const std::string& input;std::string output;size_t cursor=0; + bool available()const{return cursor(console.read());}; + @ECHO_LOOP@ + assert(line_complete||overlong_line_complete); + return console.output; +} +static const std::string key(64,'1'); +static std::string command(Client& client,mesh::Mesh& radio,const std::string& text,uint32_t now=100){ + char output[161];memset(output,0x5A,sizeof(output)); + client.handleCommand(radio,text.c_str(),output,160,now); + assert(output[160]==0x5A&&strlen(output)<160); + return output; +} +static std::vector reply(uint32_t timestamp,uint8_t kind,const std::string& text){ + std::vector data(5+text.size());memcpy(data.data(),×tamp,4);data[4]=kind<<2; + memcpy(data.data()+5,text.data(),text.size());return data; +} +static void deliver(Client& c,mesh::Mesh& radio,mesh::Packet& packet,uint32_t tag,const std::string& text,uint32_t now=200){ + auto data=reply(tag,1,text);c.onData(radio,&packet,PAYLOAD_TYPE_TXT_MSG,data.data(),data.size(),now); +} +static void discover(Client& c,mesh::Mesh& radio,uint32_t now,uint8_t type=2){ + command(c,radio,"ota.peer discover",now); + mesh::Packet packet;packet.payload_len=38;packet.payload[0]=0x90|type; + memcpy(packet.payload+2,radio.sent.data()+2,4);memset(packet.payload+6,0x11,32); + c.onDiscovery(&packet,now+1);c.tick(now+6000); +} +static void authenticate(Client& c,mesh::Mesh& radio,uint32_t now=100){ + auto accepted=command(c,radio,"ota.peer login "+key+" s p ace ",now); + assert(accepted.find("state=pending")!=std::string::npos); + assert(radio.type==PAYLOAD_TYPE_ANON_REQ); + assert(std::string(radio.sent.begin()+4,radio.sent.end())=="s p ace "); + assert(c.hasSession()&&c.matchesHash(reinterpret_cast("\x11"))); + uint8_t login[13]{};uint32_t tag=20000;memcpy(login,&tag,4);login[7]=3; + mesh::Packet packet; + packet.direct=false;packet.path_len=1;c.onData(radio,&packet,PAYLOAD_TYPE_RESPONSE,login,sizeof(login),now+1); + assert(radio.type==PAYLOAD_TYPE_ANON_REQ); + packet.direct=true;packet.path_len=1;c.onData(radio,&packet,PAYLOAD_TYPE_RESPONSE,login,sizeof(login),now+2); + assert(radio.type==PAYLOAD_TYPE_ANON_REQ); + packet.direct=false;packet.path_len=0x40;c.onData(radio,&packet,PAYLOAD_TYPE_RESPONSE,login,sizeof(login),now+3); + assert(radio.type==PAYLOAD_TYPE_PATH); + // Execute Mesh.cpp's real PATH builder: destination/source hashes followed + // by authenticated ciphertext containing empty return path + dummy payload. + assert(radio.sent.size()==10&&radio.sent[0]==0x11&&radio.sent[1]==0x55); + assert(radio.sent[2]==0xCA&&radio.sent[3]==0xFE&&radio.sent[4]==0&&radio.sent[5]==0xFF); + c.service(radio,now+3);assert(radio.type==PAYLOAD_TYPE_PATH); // do not overtake the PATH + radio.pending=false;c.service(radio,now+4); + assert(radio.type==PAYLOAD_TYPE_TXT_MSG&&radio.sent[4]==12); + std::string proof(radio.sent.begin()+5,radio.sent.begin()+8); + assert(std::string(radio.sent.begin()+8,radio.sent.end())=="get public.key"); + deliver(c,radio,packet,20001,proof+"> "+key,now+4); // flood proof is never accepted + assert(command(c,radio,"ota.peer result",now+5).find("state=pending")!=std::string::npos); + packet.direct=true;deliver(c,radio,packet,20001,"ZZ|> "+key,now+5); + deliver(c,radio,packet,tag,proof+"> "+key,now+6); // stale server timestamp + assert(command(c,radio,"ota.peer result",now+7).find("state=pending")!=std::string::npos); + deliver(c,radio,packet,20001,proof+"> "+key,now+8); + assert(command(c,radio,"ota.peer status",now+9).find("session=1 busy=0")!=std::string::npos); + assert(command(c,radio,"ota.peer result",now+10).find("state=ok")!=std::string::npos); + assert(!c.matchesHash(reinterpret_cast("\x11"))); // do not steal ACL packets when idle +} +int main(){ + UsbRole role;char gate_command[]="ota.peer status",gate_reply[160]; + role.dispatch(0,nullptr,gate_command,gate_reply,false);assert(!strcmp(gate_reply,"ERR ota.peer requires physical USB")); + role.dispatch(1,nullptr,gate_command,gate_reply,true);assert(!strcmp(gate_reply,"ERR ota.peer requires physical USB")); + role.dispatch(0,&role,gate_command,gate_reply,true);assert(!strcmp(gate_reply,"ERR ota.peer requires physical USB")); + role.dispatch(0,nullptr,gate_command,gate_reply,true);assert(!strcmp(gate_reply,"OTApeer v=1 session=0 busy=0 peers=0")); + Client client;mesh::Mesh radio;mesh::Packet packet; + assert(command(client,radio,"ota.peer status")=="OTApeer v=1 session=0 busy=0 peers=0"); + assert(command(client,radio,"ota.peer cmd "+key+" ver")=="ERR peer login required"); + assert(command(client,radio,"ota.peer login "+key+" password")=="ERR peer needs fresh discovery"); + command(client,radio,"ota.peer discover");assert(radio.type==11&&radio.sent[0]==0x80&&radio.sent[1]==12); + packet.payload_len=38;packet.payload[0]=0x92; + memcpy(packet.payload+2,radio.sent.data()+2,4);memset(packet.payload+6,0x11,32); + packet.path_len=1;client.onDiscovery(&packet,101);assert(command(client,radio,"ota.peer peers")=="ERR peer not found"); + packet.path_len=0x80;packet.direct=false;client.onDiscovery(&packet,102); + packet.direct=true;packet.payload[2]^=1;client.onDiscovery(&packet,103); + packet.payload[2]^=1;client.onDiscovery(&packet,104);client.onDiscovery(&packet,105); + assert(command(client,radio,"ota.peer status").find("peers=1")!=std::string::npos); + assert(command(client,radio,"ota.peer peers 0").find("key="+key)!=std::string::npos); + client.tick(6100);assert(command(client,radio,"ota.peer result",6101).find("state=ok")!=std::string::npos); + authenticate(client,radio,6200); + assert(command(client,radio,"ota.peer cmd "+std::string(64,'2')+" ver",6300)=="ERR peer login required"); + command(client,radio,"ota.peer cmd "+key+" ota status",6400); + std::string prefix(radio.sent.begin()+5,radio.sent.begin()+8); + auto original=command(client,radio,"ota.peer result",6401); + assert(command(client,radio,"ota.peer discover",6402)=="ERR peer request pending"); + packet=mesh::Packet();packet.path_len=2; + deliver(client,radio,packet,30000,prefix+std::string(160,'A'),6403); + assert(command(client,radio,"ota.peer result",6404).find("state=pending")!=std::string::npos); + packet.path_len=0;deliver(client,radio,packet,30000,prefix+std::string(160,'A'),6405); + for(unsigned off:{0U,48U,96U,144U}){ + auto page=command(client,radio,"ota.peer result "+std::to_string(off),6406); + assert(page.find("state=ok bytes=160 off="+std::to_string(off))!=std::string::npos); + auto hex=page.substr(page.find("data=")+5); + const unsigned expected=off==144?16:48; + assert(hex.size()==expected*2); + for(unsigned i=0;i 0 && console_available())') + opening = source.index('{',start) + end,depth=opening+1,1 + while depth: + depth += (source[end]=='{') - (source[end]=='}') + end += 1 + code = HARNESS.replace('@ECHO_LOOP@',source[start:end]) + mesh_source = (ROOT/'examples/simple_repeater/MyMesh.cpp').read_text() + start = mesh_source.index('if (mesh::DirectOtaAdminClient::isCommand(command))') + opening = mesh_source.index('{',start) + end,depth=opening+1,1 + while depth: + depth += (mesh_source[end]=='{') - (mesh_source[end]=='}') + end += 1 + code = code.replace('@USB_GATE@',mesh_source[start:end]) + with tempfile.TemporaryDirectory(prefix='meshcore-ota-peer-') as folder: + root = Path(folder) + mesh_cpp = (ROOT/'src/Mesh.cpp').read_text() + start = mesh_cpp.index('Packet* Mesh::createPathReturn(const uint8_t* dest_hash,') + end = mesh_cpp.index('\nPacket* Mesh::createDatagram(', start) + (root/'Mesh.h').write_text(STUB.replace('@PATH_BUILDER@', mesh_cpp[start:end])) + (root/'test.cpp').write_text(code) + sanitizers = [] if os.name == 'nt' else [ + '-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie'] + binary = root/('test.exe' if os.name == 'nt' else 'test') + result = subprocess.run([ + 'g++','-std=c++17','-Wall','-Wextra','-Werror', + *sanitizers, '-DMESH_ENABLE_DIRECT_OTA_ADMIN=1','-I'+str(root),'-I'+str(ROOT/'src'), + str(root/'test.cpp'),'-o',str(binary)],capture_output=True,text=True) + self.assertEqual(result.returncode,0,result.stdout+result.stderr) + result = subprocess.run([str(binary)],capture_output=True,text=True) + self.assertEqual(result.returncode,0,result.stdout+result.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_ota_zero_hop_route.py b/test/test_ota_zero_hop_route.py new file mode 100644 index 000000000..5706e0dcf --- /dev/null +++ b/test/test_ota_zero_hop_route.py @@ -0,0 +1,208 @@ +"""Execute the production OTA adapter through its final Packet route selection.""" +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile +import unittest + +from test_radio_receive_contract import method + + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +@CONSTANTS@ +namespace mesh { +namespace ota { +struct Context { + struct Manager { + OtaReplyRoute route; + OtaReplyRoute replyRoute() const { return route; } + } manager; +}; +static Context context; +Context* ota_context_if_active() { return &context; } +} +struct Manager { + int free=40; + std::vector queued; + int getFreeCount() { return free; } + int getOutboundTotal() { return int(queued.size()); } + Packet* getOutboundByIdx(int i) { return queued[i]; } +}; +using PacketManager=Manager; +struct Tables { + int seen=0; + uint8_t route=255; + void markSeen(const Packet* p) { ++seen;route=p->getRouteType(); } +}; +struct Radio { + RadioProfiles config; + const RadioProfiles* profiles() const { return &config; } +}; +@HELPERS@ +struct Mesh { + Manager manager; Manager* _mgr=&manager; + Tables tables; Tables* _tables=&tables; + Radio radio; Radio* _radio=&radio; + Packet packet; + uint8_t hops=0, sent_mask=0, sent_priority=255; + uint32_t sent_delay=0; + int allocations=0, sends=0, remembered=0; + bool temporary=true, admit=true; + bool isAnyTempRadioActive() { return temporary; } + uint8_t getOtaHopLimit() const { return hops; } + Packet* obtainNewPacket() { + ++allocations; + if (!manager.free) return nullptr; + --manager.free; packet=Packet();return &packet; + } + void releasePacket(Packet*) { ++manager.free; } + void rememberOtaForward(const Packet*) { ++remembered; } + bool sendPacket(Packet* p,uint8_t priority,uint32_t delay) { + ++sends;sent_mask=getTransmitProfileMask(p); + sent_priority=priority;sent_delay=delay; + // Model Dispatcher ownership on both admission and rejection. + releasePacket(p);return admit; + } + uint8_t getTransmitProfileMask(const Packet*) const; + Packet* createOtaPacket(const uint8_t*,size_t); + bool sendOtaFlood(Packet*,uint32_t delay=0); + static bool otaSendAdapter(void*,const uint8_t*,uint16_t,bool); +}; +@MASK@ +@CREATE@ +@SEND@ +@ADAPTER@ +} + +static void profiles(mesh::Mesh& node,uint8_t mask) { + auto& p=node.radio.config; + p.primary_temporary=p.secondary_temporary=true; + p.secondary.mode=mask==0 ? mesh::RadioProfileMode::Rx : mesh::RadioProfileMode::RxTx; + p.cross=mask==3 ? mesh::RadioCrossMode::On : mesh::RadioCrossMode::Off; + auto& route=mesh::ota::context.manager.route; + route.profile=(mask==2 || mask==0) ? 1 : 0; + route.generation=p.generation[route.profile]; +} +int main() { + // Every message keeps its existing primary/background priority and wire + // payload. A stale nonempty path and transport route cannot survive reuse. + for (uint8_t hops : {0,1,3,255}) { + for (uint8_t type : {mesh::ota::OTA_ADV,mesh::ota::OTA_HAVE, + mesh::ota::OTA_QUERY,mesh::ota::OTA_MANIFEST,mesh::ota::OTA_DATA, + mesh::ota::OTA_PROOF,mesh::ota::OTA_REQ}) { + mesh::Mesh node;profiles(node,1);node.hops=hops; + auto* p=node.obtainNewPacket(); + p->header=(PAYLOAD_VER_2<setPathHashSizeAndCount(3,4);p->payload[0]=type;p->payload[1]=0xF3;p->payload_len=2; + p->tx_radio=mesh::RADIO_TX_PRIMARY;p->radio_bound=true; + p->radio_origin_generation=123;p->radio_generation=456; + assert(node.sendOtaFlood(p,789)); + assert(p->getRouteType()==(hops ? ROUTE_TYPE_FLOOD : ROUTE_TYPE_DIRECT)); + assert(p->getPathHashCount()==0 && p->getPathHashSize()==1); + assert(p->getPayloadType()==PAYLOAD_TYPE_OTA && p->getPayloadVer()==PAYLOAD_VER_2); + assert(p->payload_len==2 && p->payload[0]==type && p->payload[1]==0xF3); + assert(p->tx_radio==mesh::RADIO_TX_PRIMARY && p->radio_bound); + assert(p->radio_origin_generation==123 && p->radio_generation==456); + assert(node.sent_priority==(mesh::ota::ota_is_transfer_message(type) ? OTA_TRANSFER_TX_PRIORITY : OTA_TX_PRIORITY)); + assert(node.sent_delay==789 && node.sent_mask==1); + assert(node.remembered==1 && node.tables.seen==1 && node.tables.route==p->getRouteType()); + } + } + // Execute the production admission and radio-mask code before the production + // route function: zero-hop must not weaken queue credit or the RX reserve. + for (uint8_t hops : {0,3}) { + for (uint8_t type : {mesh::ota::OTA_MANIFEST,mesh::ota::OTA_DATA, + mesh::ota::OTA_PROOF,mesh::ota::OTA_LEAVES}) { + for (uint8_t mask : {0,1,2,3}) { + for (int queued=0;queued<=OTA_EGRESS_QUEUE_CREDIT+1;++queued) { + for (int free=0;free<=OTA_EGRESS_MIN_FREE+3;++free) { + mesh::Mesh node;profiles(node,mask);node.hops=hops;node.manager.free=free; + mesh::Packet prior;prior.header=PAYLOAD_TYPE_OTA<=OTA_EGRESS_MIN_FREE+copies; + assert(node.otaSendAdapter(&node,&type,1,false)==allowed); + assert(node.manager.free==free); + assert(node.sends==int(allowed) && node.tables.seen==int(allowed)); + if (queued>=OTA_EGRESS_QUEUE_CREDIT || free<=OTA_EGRESS_MIN_FREE) + assert(node.allocations==0); + if (allowed) { + assert(node.sent_mask==mask && !node.packet.radio_reply && !node.packet.radio_local); + assert(node.packet.getRouteType()==(hops ? ROUTE_TYPE_FLOOD : ROUTE_TYPE_DIRECT)); + const auto route=mesh::ota::context.manager.route; + assert(node.packet.radio_profile==route.profile && node.packet.radio_origin==route.profile); + assert(node.packet.radio_generation==route.generation && node.packet.radio_origin_generation==route.generation); + assert(!node.packet.radio_bound && node.sent_priority==OTA_TRANSFER_TX_PRIORITY); + } + } + } + } + } + } + mesh::Mesh rejected;profiles(rejected,1);rejected.admit=false; + const uint8_t data=mesh::ota::OTA_DATA; + assert(!rejected.otaSendAdapter(&rejected,&data,1,true)); + assert(rejected.sends==1 && rejected.manager.free==40); + rejected.temporary=false;const int count=rejected.allocations; + assert(!rejected.otaSendAdapter(&rejected,&data,1,true) && rejected.allocations==count); +} +''' + + +class OtaZeroHopRouteTest(unittest.TestCase): + def test_production_adapter_direct_route_and_backpressure(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + self.skipTest("a host C++17 compiler is required") + source = (ROOT / "src/Mesh.cpp").read_text(encoding="utf-8") + header = (ROOT / "src/Mesh.h").read_text(encoding="utf-8") + dispatcher = (ROOT / "src/Dispatcher.cpp").read_text(encoding="utf-8") + constants = "\n".join(re.search(r"^#define " + name + r" \d+$", header, re.M)[0] + for name in ("OTA_EGRESS_QUEUE_CREDIT", "OTA_EGRESS_MIN_FREE", + "OTA_TX_PRIORITY", "OTA_TRANSFER_TX_PRIORITY")) + helpers = "\n".join(method(source, signature) for signature in ( + "static bool isPrimaryOtaTraffic(", "static uint8_t otaTrafficPriority(", + "static bool isPacedOtaResponse(", "static int queuedPacedOtaResponses(")) + code = HARNESS.replace("@CONSTANTS@", constants).replace("@HELPERS@", helpers) + code = code.replace("@MASK@", method(dispatcher, "uint8_t Dispatcher::getTransmitProfileMask(") + .replace("Dispatcher::", "Mesh::")) + code = code.replace("@CREATE@", method(source, "Packet* Mesh::createOtaPacket(")) + code = code.replace("@ADAPTER@", method(source, "bool Mesh::otaSendAdapter(")) + send = method(source, "bool Mesh::sendOtaFlood(") + with tempfile.TemporaryDirectory(prefix="ota-zero-hop-") as directory: + cpp, binary = Path(directory) / "test.cpp", Path(directory) / "test.exe" + command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-I", str(ROOT / "src"), + "-I", str(ROOT / "test/mocks"), str(cpp), str(ROOT / "src/Packet.cpp"), + "-o", str(binary)] + variants = [("production", send, True), + ("zero-hop-regression", send.replace( + "getOtaHopLimit() == 0 ? ROUTE_TYPE_DIRECT : ROUTE_TYPE_FLOOD", + "ROUTE_TYPE_FLOOD"), False)] + for label, candidate, expected in variants: + with self.subTest(variant=label): + cpp.write_text(code.replace("@SEND@", candidate), encoding="utf-8") + compiled = subprocess.run(command, capture_output=True, text=True, timeout=60) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20) + if expected: + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + else: + self.assertNotEqual(ran.returncode, 0, "the former FLOOD0 behavior must fail") + self.assertIn("p->getRouteType()", ran.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_repeater_ota_serial_control.py b/test/test_repeater_ota_serial_control.py new file mode 100644 index 000000000..c56577a5e --- /dev/null +++ b/test/test_repeater_ota_serial_control.py @@ -0,0 +1,122 @@ +"""Real serial source framing keeps repeater CLI outside binary responses.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +using namespace mesh::ota; +struct Wire:Stream { + std::deque input; + std::vector next_response; + int writes=0; + int available()override{return input.size();} + int read()override{if(input.empty())return -1;auto b=input.front();input.pop_front();return b;} + size_t write(const uint8_t* data,size_t n)override{ + assert(n>=4&&data[0]=='M'&&data[1]=='S');++writes; + input.insert(input.end(),next_response.begin(),next_response.end());next_response.clear();return n; + } + void push(const std::string& s){input.insert(input.end(),s.begin(),s.end());} +}; +static std::vector frame(const std::vector& payload){ + std::vector result={'m','s',3,0}; + result.insert(result.end(),payload.begin(),payload.end()); + uint8_t sum=0;for(auto b:result)sum^=b;result.push_back(sum);return result; +} +static std::string controls(SerialMotaSource& source){ + std::string output; + for(unsigned guard=0;source.availableControlBytes()&&guard<2048;++guard){ + int value=source.readControlByte();if(value==-1)break;if(value>=0)output+=char(value); + } + return output; +} +int main(){ + const std::string command="ota.peer cmd "+std::string(64,'1')+" ota key add "+std::string(64,'A')+"\r"; + const std::string dangerous="\rota.peer cmd "+std::string(64,'2')+" reboot\r\nota folder off\r"; + const std::vector payload(dangerous.begin(),dangerous.end()); + const auto response=frame(payload); + for(size_t prefix=0;prefix target(payload.size()); + assert(!source.read(0,0,target.data(),target.size())); + assert(source.hasPendingResponse()); + for(size_t i=prefix;i