Let USB repeaters and Full Companions manage direct OTA updates
This commit is contained in:
1 parent
5741006b3e
commit
435c740e5a
15 files changed
+1382
-15
No files matched your search
@@ -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<uint8_t>(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);
|
||||
|
||||
@@ -64,6 +64,9 @@ namespace mesh { class BeaconManager; }
|
||||
#endif
|
||||
#include <helpers/TrackerProtocol.h>
|
||||
#include <helpers/CompanionMotaControl.h>
|
||||
#if COMPANION_FEATURE_OTA_CLI
|
||||
#include <helpers/CompanionDirectAdminGuard.h>
|
||||
#endif
|
||||
#include <helpers/IdentityStore.h>
|
||||
#include <helpers/LogicalMessageCache.h>
|
||||
#include <helpers/SimpleMeshTables.h>
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,16 @@
|
||||
#if defined(ENABLE_OTA)
|
||||
#include <helpers/ota/OtaContext.h>
|
||||
#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 <helpers/DirectOtaAdminClient.h>
|
||||
#endif
|
||||
#include <RTClib.h>
|
||||
#include <CayenneLPP.h>
|
||||
#include <target.h>
|
||||
@@ -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
|
||||
|
||||
@@ -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<char>(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
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <Mesh.h>
|
||||
#include <string.h>
|
||||
|
||||
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<int32_t>(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<int32_t>(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
|
||||
@@ -0,0 +1,319 @@
|
||||
#pragma once
|
||||
|
||||
#include <Mesh.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
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<int32_t>(now - deadline) >= 0;
|
||||
}
|
||||
static void erase(void* buffer, size_t size) {
|
||||
volatile uint8_t* p = static_cast<volatile uint8_t*>(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<unsigned>(*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<uint8_t*>(&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<uint8_t>(*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<const uint8_t*>(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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <helpers/CompanionDirectAdminGuard.h>
|
||||
#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()
|
||||
@@ -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 <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#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<n;++i){int a=digit(text[i*2]),b=digit(text[i*2+1]);if(a<0||b<0)return false;out[i]=a*16+b;}
|
||||
return true;
|
||||
}
|
||||
static void toHex(char* out,const uint8_t* in,size_t n){const char* h="0123456789ABCDEF";for(size_t i=0;i<n;++i){out[i*2]=h[in[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<size;++i)out[i]=uint8_t(33+i);}};
|
||||
struct Mesh {
|
||||
LocalIdentity self_id;
|
||||
Clock clock;Random rng;Packet packet;
|
||||
std::vector<uint8_t> 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 <helpers/DirectOtaAdminClient.h>
|
||||
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<input.size();}
|
||||
char read(){return input[cursor++];}
|
||||
void print(char value){output+=value;}
|
||||
} console{wire,{}};
|
||||
constexpr size_t LOCAL_SERIAL_COMMAND_MAX=90;
|
||||
char command[LOCAL_SERIAL_COMMAND_MAX+2]{};
|
||||
int len=0;bool usb_ready=true,command_sensitive=false,command_overflow=false;
|
||||
bool line_complete=false,overlong_line_complete=false;
|
||||
size_t read_budget=256;
|
||||
auto console_available=[&](){return console.available();};
|
||||
auto console_read=[&](){return static_cast<int>(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<uint8_t> reply(uint32_t timestamp,uint8_t kind,const std::string& text){
|
||||
std::vector<uint8_t> 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<const uint8_t*>("\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<const uint8_t*>("\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<expected;++i)assert(hex.substr(i*2,2)=="41");
|
||||
}
|
||||
assert(command(client,radio,"ota.peer result 161",6407)=="ERR peer result offset");
|
||||
auto retained=command(client,radio,"ota.peer result",6408);
|
||||
deliver(client,radio,packet,30001,prefix+"unsolicited",6409);
|
||||
assert(command(client,radio,"ota.peer result",6410)==retained);
|
||||
command(client,radio,"ota.peer cmd "+key+" ver",6500);
|
||||
assert(command(client,radio,"ota.peer result",36500).find("state=error")!=std::string::npos);
|
||||
command(client,radio,"ota.peer close",36600);
|
||||
uint8_t secret[32];memset(secret,0xEE,32);client.sharedSecret(secret);
|
||||
for(auto b:secret)assert(b==0);
|
||||
assert(!client.hasSession());
|
||||
Client timeout;discover(timeout,radio,10);command(timeout,radio,"ota.peer login "+key+" password",6011);
|
||||
timeout.tick(36011);assert(!timeout.hasSession());
|
||||
assert(command(timeout,radio,"ota.peer result",36012).find("state=error")!=std::string::npos);
|
||||
Client denied;discover(denied,radio,10);command(denied,radio,"ota.peer login "+key+" password",6011);
|
||||
uint8_t bad[13]{};uint32_t tag=50000;memcpy(bad,&tag,4);bad[7]=4;
|
||||
denied.onData(radio,&packet,PAYLOAD_TYPE_RESPONSE,bad,sizeof(bad),6012);
|
||||
assert(!denied.hasSession());
|
||||
assert(command(denied,radio,"ota.peer result",6013).find("state=error")!=std::string::npos);
|
||||
Client failed;discover(failed,radio,10);radio.alloc_ok=false;command(failed,radio,"ota.peer login "+key+" password",6011);
|
||||
assert(!failed.hasSession()&&command(failed,radio,"ota.peer result",6012).find("state=error")!=std::string::npos);
|
||||
char input[256];strcpy(input,("ota.peer login "+key+" ").c_str());
|
||||
assert(Client::sensitiveInput(input));
|
||||
Client::clearInput(input,sizeof(input));for(char b:input)assert(b==0);
|
||||
assert(!Client::sensitiveInput(("ota.peer login "+key).c_str()));
|
||||
radio.alloc_ok=true;radio.self_id.invalid_peer=true;Client invalid;discover(invalid,radio,10);
|
||||
const int before=radio.calls;
|
||||
assert(command(invalid,radio,"ota.peer login "+key+" password",6011)=="ERR peer invalid public key");
|
||||
assert(radio.calls==before&&!invalid.hasSession());
|
||||
auto echoed=echoInput("ota.peer login "+key+" secret with spaces that overflows the input buffer\r");
|
||||
assert(echoed.find("secret")==std::string::npos&&echoed.find("overflows")==std::string::npos);
|
||||
assert(echoed.find(std::string(10,'*'))!=std::string::npos);
|
||||
radio.self_id.invalid_peer=false;Client room;discover(room,radio,100,3);
|
||||
assert(command(room,radio,"ota.peer peers 0",6101).find("type=3")!=std::string::npos);
|
||||
assert(command(room,radio,"ota.peer login "+key+" password",6102).find("state=pending")!=std::string::npos);
|
||||
assert(radio.sent.size()==16&&radio.sent[4]==0&&radio.sent[5]==0&&radio.sent[6]==0&&radio.sent[7]==0);
|
||||
assert(std::string(radio.sent.begin()+8,radio.sent.end())=="password");
|
||||
command(room,radio,"ota.peer close",6103);discover(room,radio,7000,3);
|
||||
assert(command(room,radio,"ota.peer login "+key+" password",67002)=="ERR peer needs fresh discovery");
|
||||
Client retries;discover(retries,radio,10);command(retries,radio,"ota.peer login "+key+" password",6011);
|
||||
mesh::Packet direct;uint8_t accepted_login[13]{};uint32_t stamp=60000;memcpy(accepted_login,&stamp,4);accepted_login[7]=3;
|
||||
retries.onData(radio,&direct,PAYLOAD_TYPE_RESPONSE,accepted_login,sizeof(accepted_login),6012);
|
||||
radio.pending=false;retries.service(radio,6013);assert(radio.type==PAYLOAD_TYPE_TXT_MSG);
|
||||
for(uint32_t now:{14013U,22014U}){retries.service(radio,now);assert(radio.type==PAYLOAD_TYPE_PATH);radio.pending=false;retries.service(radio,now+1);}
|
||||
const int attempts=radio.calls;retries.service(radio,30015);assert(radio.calls==attempts);
|
||||
retries.tick(36011);assert(!retries.hasSession());
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class DirectOtaAdminClientTest(unittest.TestCase):
|
||||
def test_direct_admin_session_guards_and_paginated_results(self):
|
||||
source = (ROOT/'examples/simple_repeater/main.cpp').read_text()
|
||||
start = source.index('while (usb_ready && read_budget-- > 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()
|
||||
@@ -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 <cassert>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
#include <vector>
|
||||
#include <RadioProfiles.h>
|
||||
#include <Packet.h>
|
||||
#include <helpers/ota/OtaManager.h>
|
||||
@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<Packet*> 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<<PH_VER_SHIFT)|(PAYLOAD_TYPE_OTA<<PH_TYPE_SHIFT)|ROUTE_TYPE_TRANSPORT_FLOOD;
|
||||
p->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<<PH_TYPE_SHIFT;
|
||||
prior.payload[0]=type;prior.payload_len=1;
|
||||
for(int i=0;i<queued;++i)node.manager.queued.push_back(&prior);
|
||||
const int copies=mask==3 ? 2 : mask ? 1 : 0;
|
||||
const bool allowed=copies && queued+copies<=OTA_EGRESS_QUEUE_CREDIT
|
||||
&& free>=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()
|
||||
@@ -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 <cassert>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <helpers/ota/MotaSourceSerial.h>
|
||||
#include <helpers/ota/MotaSeederProto.h>
|
||||
using namespace mesh::ota;
|
||||
struct Wire:Stream {
|
||||
std::deque<uint8_t> input;
|
||||
std::vector<uint8_t> 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<uint8_t> frame(const std::vector<uint8_t>& payload){
|
||||
std::vector<uint8_t> 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<uint8_t> payload(dangerous.begin(),dangerous.end());
|
||||
const auto response=frame(payload);
|
||||
for(size_t prefix=0;prefix<response.size();++prefix){
|
||||
g_mock_millis=0;Wire wire;
|
||||
SerialMotaSource source(wire,MotaStreamWritePolicy::NoFlush,5);
|
||||
source.enableSharedTextControl();
|
||||
wire.next_response.assign(response.begin(),response.begin()+prefix);
|
||||
std::vector<uint8_t> target(payload.size());
|
||||
assert(!source.read(0,0,target.data(),target.size()));
|
||||
assert(source.hasPendingResponse());
|
||||
for(size_t i=prefix;i<response.size();++i){
|
||||
wire.input.push_back(response[i]);
|
||||
assert(controls(source).empty());
|
||||
}
|
||||
assert(!source.hasPendingResponse());
|
||||
wire.push(command);
|
||||
assert(controls(source)==command);
|
||||
}
|
||||
// A complete long CLI request that arrives just before a source transaction
|
||||
// is retained intact while the binary count/read exchange finishes.
|
||||
g_mock_millis=0;Wire wire;
|
||||
SerialMotaSource source(wire,MotaStreamWritePolicy::NoFlush,5);
|
||||
source.enableSharedTextControl();
|
||||
wire.push(command);
|
||||
wire.next_response=frame({42});
|
||||
uint8_t value=0;assert(source.read(0,0,&value,1)&&value==42);
|
||||
assert(controls(source)==command);
|
||||
// ASCII records can arrive after the binary request has started. Passwords
|
||||
// and command arguments containing the response magic remain whole text,
|
||||
// including when the console already consumed the beginning of the line.
|
||||
const std::string login="ota.peer login "+std::string(64,'1')+" aims ms mms spaces\r\n";
|
||||
for(size_t split=0;split<login.size();++split){
|
||||
g_mock_millis=0;Wire racing;
|
||||
SerialMotaSource shared(racing,MotaStreamWritePolicy::NoFlush,5);shared.enableSharedTextControl();
|
||||
racing.push(login.substr(0,split));
|
||||
const auto prefix=controls(shared);
|
||||
racing.next_response.assign(login.begin()+split,login.end());
|
||||
const auto binary=frame({44});
|
||||
racing.next_response.insert(racing.next_response.end(),binary.begin(),binary.end());
|
||||
assert(shared.read(0,0,&value,1)&&value==44);
|
||||
assert(prefix+controls(shared)==login);
|
||||
}
|
||||
// Over-capacity controls invalidate the existing prefix and discard to its
|
||||
// delimiter, so the suffix never becomes a second mutating command.
|
||||
wire.push("ota.peer cmd "+std::string(700,'X')+"\r");
|
||||
wire.next_response=frame({43});
|
||||
assert(source.read(0,0,&value,1)&&value==43);
|
||||
auto discarded=controls(source);
|
||||
assert(!discarded.empty()&&discarded[0]==0);
|
||||
assert(discarded.find('X')==std::string::npos);
|
||||
wire.push("ota.peer result\r");
|
||||
assert(controls(source)=="ota.peer result\r");
|
||||
source.resetSessionState();assert(!source.hasPendingResponse());
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class RepeaterOtaSerialControlTest(unittest.TestCase):
|
||||
def test_late_binary_frames_and_long_interleaved_controls(self):
|
||||
with tempfile.TemporaryDirectory(prefix='meshcore-repeater-mota-control-') as folder:
|
||||
cpp = Path(folder)/'test.cpp'
|
||||
binary = Path(folder)/('test.exe' if os.name == 'nt' else 'test')
|
||||
cpp.write_text(HARNESS)
|
||||
sanitizers = [] if os.name == 'nt' else [
|
||||
'-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie']
|
||||
built=subprocess.run([
|
||||
'g++','-std=c++17','-DESP32_PLATFORM=1',
|
||||
*sanitizers,
|
||||
'-I'+str(ROOT/'test/mocks'),'-I'+str(ROOT/'src'),str(cpp),
|
||||
str(ROOT/'src/helpers/ota/MotaSourceSerial.cpp'),'-o',str(binary)],
|
||||
capture_output=True,text=True)
|
||||
self.assertEqual(built.returncode,0,built.stdout+built.stderr)
|
||||
ran=subprocess.run([str(binary)],capture_output=True,text=True)
|
||||
self.assertEqual(ran.returncode,0,ran.stdout+ran.stderr)
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user