Files
MeshCore/src/helpers/ManagementReporter.cpp
T

417 lines
19 KiB
C++

#include "ManagementReporter.h"
#include "CommonCLI.h"
#include "FileRead.h"
#include "PersistentStoreFormat.h"
#include <new>
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
#include "AtomicFileWriter.h"
#else
#include "ContactFileTransaction.h"
#endif
#if defined(ESP32_PLATFORM)
#include <WiFi.h>
#include <esp_ota_ops.h>
#endif
#if defined(ENABLE_OTA)
#include "ota/OtaContext.h"
#include "ota/OtaDeflate.h"
#endif
namespace mesh {
using namespace management;
static constexpr char STATE_FILE[] = "/management";
static constexpr size_t STATE_SIZE = 120;
// Return zero for malformed/out-of-range input before any integer can wrap.
// Signs, whitespace and suffixes are not decimal day counts; leading zeroes
// remain valid. All management intervals have the same upper bound.
static unsigned parseDays(const char* text) {
unsigned days = 0;
if (!*text) return 0;
do {
if (*text < '0' || *text > '9') return 0;
days = days * 10 + (*text++ - '0');
if (days > 90) return 0;
} while (*text);
return days;
}
struct ManagementReporter::Working {
History history;
Extrema during_report;
AclList acl;
uint8_t header[CURRENT_HEADER] = {};
TransportKey scope;
uint8_t route_path[MAX_PATH_SIZE] = {};
uint8_t route_path_len = OUT_PATH_UNKNOWN;
uint32_t sample_in = 0, page_in = 0, lifetime = 0, history_seconds = 0;
uint8_t page = 0;
bool sending = false, flood = false, partial_period = true;
};
ManagementReporter::ManagementReporter(Mesh& mesh, MainBoard& board, SensorManager& sensors,
ClientACL& acl, NodePrefs& prefs, CommonCLICallbacks& callbacks,
CommonCLI& common_cli, FILESYSTEM* fs)
: mesh(mesh), board(board), sensors(sensors), acl(acl), prefs(prefs),
callbacks(callbacks), common_cli(common_cli), fs(fs) {
last_ms = millis(); healthy = load();
if (healthy && enabled && !allocate()) healthy = false;
}
ManagementReporter::~ManagementReporter() { delete work; erase(key, sizeof(key)); }
bool ManagementReporter::allocate() {
static_assert(sizeof(Working) <= 1536, "Keep opt-in management working RAM bounded");
if (!work) work = new (std::nothrow) Working;
return work != nullptr;
}
bool ManagementReporter::load() {
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
if (!ContactFileTransaction::recover(fs, STATE_FILE)) return false;
#endif
if (!fs->exists(STATE_FILE)) return true; // fresh install remains off
uint8_t b[STATE_SIZE] = {};
for (unsigned retry = 0; retry < 3; ++retry) {
auto f = openFileRead(fs, STATE_FILE);
const bool read = f && f.size() == sizeof(b) && f.read(b, sizeof(b)) == sizeof(b);
if (f) f.close();
const bool legacy = read && !memcmp(b, "MGC1", 4);
const bool current = read && !memcmp(b, "MGC2", 4);
const bool direct_valid = legacy ? Schedule::validDirect(b[5])
: (b[5] == 0 || Schedule::validDirect(b[5]));
const bool flood_valid = legacy
? (b[7] == OUT_PATH_UNKNOWN || Packet::isValidPathLen(b[7]))
: (b[7] == 0 || Schedule::validFlood(b[7]));
if ((!legacy && !current) || b[4] > 1 || b[6] > 1 ||
!direct_valid || !flood_valid || (b[4] && (!b[6] || (!b[5] && !b[7]))) ||
read32(b + 116) != storage::updateCRC32(0xffffffff, b, 116) ||
read32(b + 104) > 90 * DAY + 3600 || read32(b + 108) > 90 * DAY + 3600) continue;
// Do not discard the only durable legacy route in a later MGC2 checkpoint
// unless the shared route was successfully adopted (or already saved).
if (legacy && b[7] != OUT_PATH_UNKNOWN && !common_cli.adoptLegacyDataTxPath(b + 8, b[7])) {
erase(b, sizeof(b)); return false;
}
enabled = b[4]; direct_days = b[5]; keyed = b[6];
flood_days = legacy ? (direct_days < 21 ? 21 : direct_days) : b[7];
memcpy(key, b + 72, 32);
schedule.direct = read32(b + 104); schedule.flood = read32(b + 108);
if (!direct_days) schedule.direct = 0;
if (!flood_days) schedule.flood = 0;
sequence = read32(b + 112); erase(b, sizeof(b)); return true;
}
erase(b, sizeof(b)); return false; // unreadable/corrupt state never transmits
}
bool ManagementReporter::save() {
uint8_t b[STATE_SIZE] = {};
memcpy(b, "MGC2", 4); b[4] = enabled; b[5] = direct_days; b[6] = keyed;
b[7] = flood_days; memcpy(b + 72, key, 32);
write32(b + 104, schedule.direct); write32(b + 108, schedule.flood); write32(b + 112, sequence);
write32(b + 116, storage::updateCRC32(0xffffffff, b, 116));
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
AtomicFileWriter writer(fs, STATE_FILE);
#else
ContactFileTransaction writer(fs, STATE_FILE);
#endif
const bool ok = writer && writer.write(b, sizeof(b)) == sizeof(b) && writer.commit();
erase(b, sizeof(b)); if (ok) checkpoint = 0; return ok;
}
void ManagementReporter::cancel() {
if (work) work->sending = false;
}
uint32_t ManagementReporter::jitter() const {
return (read32(mesh.self_id.pub_key) ^ (sequence * 2654435761UL)) % 3600;
}
void ManagementReporter::snapshot() {
auto& w = *work; memset(w.header, 0, sizeof(w.header)); w.acl = AclList();
auto* p = w.header; memcpy(p, "MGR2", 4); memcpy(p + 4, mesh.self_id.pub_key, 16);
const UsbLoggingStatus usb = usbLoggingStatus();
encodeUsbStatus(p + HEADER, usb);
encodeUsbWatchdogEvent(p + WATCHDOG_EVENT_OFFSET, usb.last_event);
write32(p + 20, sequence); write32(p + 24, mesh.getRTCClock()->getCurrentTime());
const uint64_t hours = uptime / 3600; write16(p + 60, hours > 65535 ? 65535 : hours);
w.history.week().encode(p + 62); w.history.period.encode(p + 66);
const uint32_t history_hours = w.history_seconds / 3600;
p[70] = history_hours > 168 ? 168 : history_hours;
p[71] = w.flood ? flood_days : direct_days;
p[72] = !strcmp(callbacks.getRole(), "repeater") ? 1 : !strcmp(callbacks.getRole(), "room_server") ? 2 : 3;
uint16_t valid = MCU_TEMPERATURE | (history_hours < 168 ? PARTIAL_WEEK : 0) | (w.partial_period ? PARTIAL_PERIOD : 0);
p[75] = GPS | OTA; // known active-state bits; unknown is not confused with off
if (sensors.isGPSDetected()) p[73] |= GPS;
auto* gps = sensors.getLocationProvider();
if (gps && gps->isEnabled()) p[74] |= GPS;
#if defined(ESP32_PLATFORM)
p[73] |= WIFI; p[75] |= WIFI;
if (WiFi.status() == WL_CONNECTED) p[74] |= WIFI;
#endif
#if defined(NRF52_PLATFORM) || (defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT)
p[73] |= USB; p[75] |= USB;
if (board.isUsbDataConnected()) p[74] |= USB;
#endif
#ifdef WITH_MQTT_BRIDGE
p[73] |= NTP; p[75] |= NTP;
if (callbacks.managementNtpSynced()) p[74] |= NTP;
#endif
#if defined(ENABLE_OTA)
p[73] |= OTA;
ota::SelfFwInfo info;
if (ota::ota_self_firmware(info) && info.valid) {
write32(p + 28, info.fw_version); write32(p + 36, info.target_id);
memcpy(p + 40, info.body_hash, 8); write32(p + 48, info.image_len);
valid |= BASE; if (info.fw_version) valid |= FIRMWARE;
}
uint32_t caps = 1; // compiled OTA protocol
// bits 8..23 apply codecs; transfer capabilities are separate low bits.
caps |= 2; // OTA transport decoder is installed in all ENABLE_OTA builds
caps |= 4; // 2 KiB application transfer blocks
#if defined(ESP32_PLATFORM)
const esp_partition_t* running = esp_ota_get_running_partition();
const esp_partition_t* next = esp_ota_get_next_update_partition(nullptr);
if (running && next && running->address != next->address) {
write32(p + 52, next->size); valid |= STORE; p[74] |= OTA;
caps |= 7UL << 8; // full + detools sequential + in-place
}
#elif defined(NRF52_PLATFORM)
const auto bl = ota::ota_bootloader_app_caps();
#if defined(OTA_RAK_AUTO_STORE)
const auto* active_context = ota::ota_context_if_active();
const uint16_t codecs = bl.codec_mask &
(!active_context ? 4u : active_context->fetch_store.usesExternal() ? 5u :
active_context->fetch_store.usesInternal() ? 4u : 0u);
#elif defined(OTA_SD_STORE) || defined(OTA_QSPI_STORE)
const uint16_t codecs = bl.codec_mask & 5u; // full + in-place
#else
const uint16_t codecs = bl.codec_mask & 4u; // internal app path accepts deltas only
#endif
if (bl.present && bl.apply_abi >= 2) caps |= uint32_t(codecs) << 8;
ota::OtaBootloaderIdentity identity;
if (ota::ota_installed_bootloader_identity(identity) && identity.boot_version) {
write32(p + 32, identity.boot_version); valid |= BOOTLOADER;
}
// Never allocate an OTA workspace or wake external media just for a report.
if (auto* context = ota::ota_context_if_active()) {
const uint32_t capacity = context->fetch_store.capacity();
write32(p + 52, capacity); valid |= STORE;
if (capacity && bl.present && bl.apply_abi >= 2 && codecs) p[74] |= OTA;
} else {
p[75] &= ~OTA; // compiled support, readiness not established
}
#else
p[75] &= ~OTA;
#endif
#if defined(OTA_SEEDER_ONLY)
caps &= 0xff; p[74] &= ~OTA;
#endif
write32(p + 56, caps);
#endif
// If EndF is absent, only publish a version we can parse unambiguously.
if (!(valid & FIRMWARE)) {
unsigned major = 0, minor = 0, patch = 0, pre = 0;
const char* version = callbacks.getFirmwareVer(); if (*version == 'v') ++version;
const int n = sscanf(version, "%u.%u.%u.%u", &major, &minor, &patch, &pre);
if (n >= 3 && major <= 255 && minor <= 255 && patch <= 255 && pre <= 255) {
write32(p + 28, major << 24 | minor << 16 | patch << 8 | pre); valid |= FIRMWARE;
}
}
write16(p + 76, valid);
uint8_t token[12]; bool complete = true;
for (int i = 0; i < acl.getNumClients(); ++i) {
const auto* client = acl.getClientByIdx(i);
if (!client->isAdmin()) continue;
fingerprint(key, p + 4, client->id.pub_key, token); complete &= w.acl.add(token, ADMIN);
}
#if defined(ENABLE_OTA)
if (prefs.ota_signer_count > 4) complete = false;
for (unsigned i = 0; i < prefs.ota_signer_count && i < 4; ++i) {
fingerprint(key, p + 4, prefs.ota_signers[i], token); complete &= w.acl.add(token, OTA_SIGNER);
}
#endif
erase(token, sizeof(token));
if (!complete) healthy = false; // never silently truncate an ACL
}
void ManagementReporter::start(bool flood) {
if (sequence == UINT32_MAX) { healthy = false; return; }
auto& w = *work;
w.route_path_len = OUT_PATH_UNKNOWN;
memset(w.route_path, 0, sizeof(w.route_path));
if (flood) {
if (!common_cli.resolveDataTxScope(w.scope)) return;
} else {
const uint8_t* configured = nullptr;
if (!common_cli.getDataTxPath(configured, w.route_path_len)) return;
if (w.route_path_len & 63) {
Packet::copyPath(w.route_path, configured, w.route_path_len);
}
}
++sequence; schedule.reserve(flood, direct_days, flood_days, jitter());
if (!save()) { healthy = false; return; } // reserve BEFORE any packet leaves
w.flood = flood;
snapshot(); if (!healthy) return;
w.page = 0; w.page_in = 0;
w.during_report = Extrema();
w.sending = true; w.lifetime = 3600;
}
void ManagementReporter::sendPage() {
auto& w = *work;
uint8_t packet[MAX_PAYLOAD], enc[32]; memcpy(packet, w.header, CURRENT_HEADER);
const uint8_t first = w.page * CURRENT_PER_PAGE;
const uint8_t count = w.acl.count - first < CURRENT_PER_PAGE ? w.acl.count - first : CURRENT_PER_PAGE;
packet[78] = w.page; packet[79] = w.acl.pages(); packet[80] = w.acl.count;
packet[81] = first; packet[82] = count;
const size_t private_len = count * ENTRY, size = CURRENT_HEADER + private_len + TAG;
memcpy(packet + CURRENT_HEADER, w.acl.entries + first, private_len);
deriveKey(key, "MeshCore-MGR1-SIV", packet + 4, enc);
seal(enc, packet, CURRENT_HEADER, packet + CURRENT_HEADER, private_len,
packet + CURRENT_HEADER + private_len);
erase(enc, sizeof(enc));
Packet* pkt = mesh.createRawData(packet, size);
if (!pkt) { w.page_in = 60; return; }
// Dedicated background admission avoids normal direct-message priority and
// automatic retries. Dispatcher still enforces normal channel/duty limits.
if (!mesh.sendManagementData(pkt, w.flood, w.route_path, w.route_path_len,
prefs.path_hash_mode + 1,
w.flood ? w.scope.key : nullptr)) {
w.page_in = 60; return;
}
++w.page; w.page_in = 60;
if (w.page == w.acl.pages()) {
w.sending = false;
// Readings gathered while the frozen snapshot was being paged belong to
// the next report. Never discard that interval on completion.
w.history.period = w.during_report; w.partial_period = false;
}
}
void ManagementReporter::loop(uint64_t node_uptime_seconds) {
const uint32_t now = millis(), delta = now - last_ms; last_ms = now;
const uint64_t accumulated = uint64_t(fraction_ms) + delta;
const uint32_t elapsed = accumulated / 1000; fraction_ms = accumulated % 1000;
uptime = node_uptime_seconds == UINT64_MAX ? uptime + elapsed : node_uptime_seconds;
if (!healthy || !enabled || !keyed || !work) return;
auto& w = *work; schedule.advance(elapsed); checkpoint += elapsed;
w.history_seconds += elapsed;
w.sample_in = Schedule::sub(w.sample_in, elapsed);
w.page_in = Schedule::sub(w.page_in, elapsed);
w.lifetime = Schedule::sub(w.lifetime, elapsed);
if (w.sending && !w.lifetime) { w.sending = false; w.partial_period = true; }
if (!w.sample_in) {
const uint16_t voltage = board.getBattMilliVolts();
const float celsius = board.getMCUTemperature();
w.history.sample(w.history_seconds, voltage, celsius);
if (w.sending) w.during_report.add(voltage, temperature(celsius));
w.sample_in = 60;
}
if (checkpoint >= 3600 && !save()) { healthy = false; return; }
if (mesh.isAnyTempRadioActive() || mesh.hasOutbound() || !mesh.getRemainingTxBudget()) return;
if (!w.sending) {
const uint8_t* route_path = nullptr; uint8_t route_path_len = OUT_PATH_UNKNOWN;
// Flood has its own conservative schedule. MQTT upload has no deployed
// return path, so a direct transmission never postpones it. When both are
// due, the scoped flood replaces the redundant direct transmission.
if (flood_days && !schedule.flood) start(true);
if (!w.sending && direct_days
&& common_cli.getDataTxPath(route_path, route_path_len) && !schedule.direct) {
start(false);
}
}
if (healthy && w.sending && !w.page_in) sendPage();
}
bool ManagementReporter::command(char* command, char* reply, size_t size) {
if (!strcmp(command, "get mgmt")) {
const uint8_t* route_path = nullptr; uint8_t route_path_len = OUT_PATH_UNKNOWN;
TransportKey scope;
const bool routed = common_cli.getDataTxPath(route_path, route_path_len);
const bool scoped = common_cli.resolveDataTxScope(scope);
char direct[12], flood[12];
if (direct_days) snprintf(direct, sizeof(direct), "%ud", direct_days);
else strcpy(direct, "off");
if (flood_days) snprintf(flood, sizeof(flood), "%ud", flood_days);
else strcpy(flood, "off");
snprintf(reply, size, "> %s key=%s direct=%s/%luh flood=%s/%luh path=%s region=%s%s",
enabled ? "on" : "off", keyed ? "set" : "unset", direct,
(unsigned long)(schedule.direct / 3600), flood,
(unsigned long)(schedule.flood / 3600),
routed ? "set" : "missing", scoped ? "set" : "missing",
healthy ? "" : " FAULT(no TX)");
return true;
}
if (strncmp(command, "set mgmt.", 9)) return false;
char* value = strchr(command + 9, ' ');
if (!value) {
snprintf(reply, size, "ERR: mgmt.enabled/direct/flood/interval/password value");
return true;
}
++value;
if (!healthy) {
if (!strncmp(command, "set mgmt.password ", 18)) erase(value, strlen(value));
snprintf(reply, size, "ERR: management state fault; repair storage/reboot first"); return true;
}
if (!strncmp(command, "set mgmt.password ", 18)) {
const size_t len = strlen(value);
if (len < 12 || len > 96) { snprintf(reply, size, "ERR: password must be 12..96 bytes"); erase(value, len); return true; }
passwordKey(value, key); erase(value, len); keyed = true;
} else if (!strncmp(command, "set mgmt.enabled ", 17)) {
if (strcmp(value, "on") && strcmp(value, "off")) { snprintf(reply, size, "ERR: use on/off"); return true; }
const bool on = !strcmp(value, "on");
const uint8_t* route_path = nullptr; uint8_t route_path_len = OUT_PATH_UNKNOWN;
TransportKey scope;
const bool direct_ready = !direct_days
|| common_cli.getDataTxPath(route_path, route_path_len);
const bool flood_ready = !flood_days || common_cli.resolveDataTxScope(scope);
if (on && (!keyed || (!direct_days && !flood_days) || !direct_ready
|| !flood_ready || !allocate())) {
snprintf(reply, size, "ERR: password, enabled route(s), data.tx path/region and free RAM required");
return true;
}
if (on && !enabled && sequence == 0) {
if (direct_days && schedule.direct == direct_days * DAY) schedule.direct += jitter();
if (flood_days && schedule.flood == flood_days * DAY) schedule.flood += jitter();
}
enabled = on;
} else if (!strncmp(command, "set mgmt.direct ", 16)) {
if (!strcmp(value, "off")) {
if (enabled && !flood_days) {
snprintf(reply, size, "ERR: disable management or leave flood enabled"); return true;
}
direct_days = 0; schedule.direct = 0;
} else {
const unsigned n = parseDays(value);
const uint8_t* route_path = nullptr; uint8_t route_path_len = OUT_PATH_UNKNOWN;
if (!Schedule::validDirect(n)
|| !common_cli.getDataTxPath(route_path, route_path_len)) {
snprintf(reply, size, "ERR: direct needs data.tx path and 5..90 days"); return true;
}
direct_days = n; schedule.direct = direct_days * DAY;
}
} else if (!strncmp(command, "set mgmt.flood ", 15)) {
if (!strcmp(value, "off")) {
if (enabled && !direct_days) {
snprintf(reply, size, "ERR: disable management or leave direct enabled"); return true;
}
flood_days = 0; schedule.flood = 0;
} else {
const unsigned n = parseDays(value);
TransportKey scope;
if (!Schedule::validFlood(n)
|| !common_cli.resolveDataTxScope(scope)) {
snprintf(reply, size, "ERR: flood needs data.tx region and 21..90 days"); return true;
}
flood_days = n; schedule.flood = flood_days * DAY;
}
} else if (!strncmp(command, "set mgmt.interval ", 18)) {
const unsigned n = parseDays(value);
const uint8_t* route_path = nullptr; uint8_t route_path_len = OUT_PATH_UNKNOWN;
TransportKey scope;
if (!Schedule::validDirect(n)
|| !common_cli.getDataTxPath(route_path, route_path_len)
|| !common_cli.resolveDataTxScope(scope)) {
snprintf(reply, size, "ERR: interval needs path, region and 5..90 days"); return true;
}
// Compatibility shorthand: configure both schedules together.
direct_days = n; flood_days = n < 21 ? 21 : n;
schedule.direct = direct_days * DAY; schedule.flood = flood_days * DAY;
} else { snprintf(reply, size, "ERR: unknown management setting"); return true; }
// Only accepted changes abandon a frozen report. Invalid commands must not
// discard its remaining pages after the multi-day budget was reserved.
// Failed persistence still stops reporting; no uncommitted state leaves.
cancel();
if (!save()) { healthy = false; snprintf(reply, size, "ERR: save failed; reporting stopped"); return true; }
if (!enabled) { delete work; work = nullptr; }
snprintf(reply, size, "OK"); return true;
}
}