begin_edit_settings sets a bool that only the matching commit ever clears. If the commit never arrives -- the client dropped its link partway through a bulk config import, or went away entirely -- the transaction stays open indefinitely, and every later config write from any client is applied to RAM, acknowledged, and then never saved. The writes look successful and are visible in get_config, but the node reverts all of them at the next boot, and only a reboot clears it. Give the transaction a one minute idle timeout. Each deferred write restarts the clock, so the window bounds the gap between writes rather than the length of the edit; a bulk import sends them milliseconds apart. The next admin message after it lapses retires the transaction, persisting the segments it had deferred and flushing the warnings it was holding. The check runs after the auth gates and before the switch, so every case sees consistent state and the recovery's flash write happens in the main loop rather than a disconnect callback. It saves rather than rolls back because there is nothing to roll back to: each write already took effect in RAM and was acknowledged, so the transaction only ever deferred the save. That also makes an early expiry cheap -- the worst case for a client that really was still going is that its remaining writes are saved individually instead of batched. Closing on disconnect instead was tempting, but the reporter's captures show iOS dropping and reconnecting within half a second several times per session, which would abort imports that currently survive the blip. Also drops the file-scope hasOpenEditTransaction, shadowed by the class member at every use site and referenced nowhere else in the tree. Reported in #11245
177 lines
8.6 KiB
C++
177 lines
8.6 KiB
C++
#pragma once
|
|
#ifdef ESP_PLATFORM
|
|
#include <esp_ota_ops.h>
|
|
#endif
|
|
#include "ProtobufModule.h"
|
|
#include "meshUtils.h"
|
|
#include <sys/types.h>
|
|
#if HAS_WIFI
|
|
#include "mesh/wifi/WiFiAPClient.h"
|
|
#endif
|
|
|
|
/**
|
|
* Datatype passed to Observers by AdminModule, to allow external handling of admin messages
|
|
*/
|
|
struct AdminModule_ObserverData {
|
|
const meshtastic_AdminMessage *request;
|
|
meshtastic_AdminMessage *response;
|
|
AdminMessageHandleResult *result;
|
|
};
|
|
|
|
/**
|
|
* Admin module for admin messages
|
|
*/
|
|
class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Observable<AdminModule_ObserverData *>
|
|
{
|
|
friend class AdminModuleTestShim; // test/support/AdminModuleTestShim.h - native tests reach the private handlers/state
|
|
|
|
public:
|
|
/** Constructor
|
|
* name is for debugging output
|
|
*/
|
|
AdminModule();
|
|
|
|
protected:
|
|
/** Called to handle a particular incoming message
|
|
|
|
@return true if you've guaranteed you've handled this message and no other handlers should be considered for it
|
|
*/
|
|
virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *p) override;
|
|
|
|
private:
|
|
bool hasOpenEditTransaction = false;
|
|
// Each deferred write restarts the clock, so this bounds the gap between writes, not the length
|
|
// of the edit; a bulk import sends them milliseconds apart.
|
|
static constexpr uint32_t EDIT_TRANSACTION_IDLE_MS = 60 * 1000;
|
|
uint32_t editTransactionActivityMs = 0; // millis() of the last save this transaction deferred
|
|
int deferredEditSegments = 0; // segments that transaction has touched but not yet saved
|
|
/// Retire an open edit transaction whose client stopped talking, persisting what it applied.
|
|
void expireStaleEditTransaction();
|
|
#ifdef PIO_UNIT_TESTING
|
|
int lastSaveWhatForTest = 0;
|
|
#endif
|
|
|
|
uint8_t session_passkey[8] = {0};
|
|
uint32_t session_time = 0; // millis() when the current session passkey was issued
|
|
bool sessionPasskeyValid = false; // separate flag: millis() 0 at boot is a valid issue time
|
|
|
|
void saveChanges(int saveWhat, bool shouldReboot = true);
|
|
|
|
/**
|
|
* Getters
|
|
*
|
|
* Each of the NOINLINE ones below builds a whole meshtastic_AdminMessage (480 bytes) on the
|
|
* stack. They are only ever reached from one case of handleReceivedProtobuf()'s switch, but
|
|
* when the compiler inlines them the dispatcher's frame has to reserve a slot for every one of
|
|
* them at once - measured at 3008 bytes on ESP32-S3, 37% of the 8 KB Arduino loopTask stack
|
|
* that also has to carry PhoneAPI, the router, nanopb and LittleFS below it. Keeping them out
|
|
* of line means only the request actually being served pays for its response buffer.
|
|
* See issue #11237.
|
|
*/
|
|
void handleGetModuleConfigResponse(const meshtastic_MeshPacket &req, meshtastic_AdminMessage *p);
|
|
NOINLINE void handleGetOwner(const meshtastic_MeshPacket &req);
|
|
NOINLINE void handleGetConfig(const meshtastic_MeshPacket &req, uint32_t configType);
|
|
NOINLINE void handleGetModuleConfig(const meshtastic_MeshPacket &req, uint32_t configType);
|
|
NOINLINE void handleGetChannel(const meshtastic_MeshPacket &req, uint32_t channelIndex);
|
|
NOINLINE void handleGetDeviceMetadata(const meshtastic_MeshPacket &req);
|
|
NOINLINE void handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &req);
|
|
NOINLINE void handleGetNodeRemoteHardwarePins(const meshtastic_MeshPacket &req);
|
|
NOINLINE void handleGetDeviceUIConfig(const meshtastic_MeshPacket &req);
|
|
/**
|
|
* Setters
|
|
*/
|
|
void handleSetOwner(const meshtastic_User &o);
|
|
void handleSetChannel(const meshtastic_Channel &cc);
|
|
|
|
protected:
|
|
void handleSetConfig(const meshtastic_Config &c, bool fromOthers);
|
|
|
|
#ifdef PIO_UNIT_TESTING
|
|
protected:
|
|
#else
|
|
private:
|
|
#endif
|
|
bool handleSetModuleConfig(const meshtastic_ModuleConfig &c);
|
|
void handleSetChannel();
|
|
|
|
public:
|
|
void handleSetHamMode(const meshtastic_HamParameters &req);
|
|
|
|
/// Note an admin request leaving this node for a remote, so that remote's response is
|
|
/// accepted. Called from the client-to-mesh path (MeshService::handleToRadio).
|
|
void noteOutgoingAdminRequest(const meshtastic_MeshPacket &p);
|
|
|
|
private:
|
|
// An admin response has no session passkey and its sender need not hold an admin key, so a
|
|
// request we sent is the only thing vouching for it. Track each request independently (its own
|
|
// expiry and pinned key) so a later one can't extend or relax an earlier one.
|
|
static constexpr size_t kOutstandingAdminRequests = 8;
|
|
static constexpr uint32_t kOutstandingAdminRequestMs = 300 * 1000; // same window as the session passkey
|
|
struct OutstandingAdminRequest {
|
|
NodeNum to; // 0 = free slot
|
|
uint32_t requestId; // our request's packet id; the response must echo it as request_id
|
|
uint32_t sentAtMs; // millis() when this request went out
|
|
pb_size_t expectedResponse; // the one response variant this request authorizes
|
|
uint8_t moduleConfigType; // for get_module_config_request: which ModuleConfigType we asked
|
|
uint8_t key[32]; // pinned destination key when the request goes out over PKC
|
|
bool keyValid;
|
|
};
|
|
OutstandingAdminRequest outstandingAdminRequests[kOutstandingAdminRequests] = {};
|
|
|
|
/// Whether a response (variant responseVariant, module-config subtype moduleConfigTag or 0)
|
|
/// from mp.from answers a request we sent; consumes the matched request so it can't be replayed.
|
|
bool responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t responseVariant, pb_size_t moduleConfigTag);
|
|
|
|
/// Offer an admin message we have no case for to the module API, and let observers (e.g. the UI)
|
|
/// see every admin message. Both build a response on the stack, so like the getters above they
|
|
/// stay out of line to keep handleReceivedProtobuf()'s frame small.
|
|
NOINLINE void handleViaModuleApi(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *r);
|
|
NOINLINE void handleViaObservers(const meshtastic_AdminMessage *r);
|
|
|
|
void handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uicfg);
|
|
void handleSendInputEvent(const meshtastic_AdminMessage_InputEvent &inputEvent);
|
|
void reboot(int32_t seconds);
|
|
|
|
void setPassKey(meshtastic_AdminMessage *res);
|
|
bool checkPassKey(meshtastic_AdminMessage *res);
|
|
|
|
bool messageIsResponse(const meshtastic_AdminMessage *r);
|
|
bool messageIsRequest(const meshtastic_AdminMessage *r);
|
|
void sendWarning(const char *format, ...) __attribute__((format(printf, 2, 3)));
|
|
void sendWarningAndLog(const char *format, ...) __attribute__((format(printf, 2, 3)));
|
|
void warnOnLoraPresetChange(const meshtastic_Config_LoRaConfig &oldLora, const meshtastic_Config_LoRaConfig &newLora);
|
|
void warnOnChannelSet(const meshtastic_Channel &cc);
|
|
|
|
// Channel-configuration warnings are coalesced into a single client notification.
|
|
// queueChannelWarning() records one warning for a channel; while an edit transaction
|
|
// is open (begin/commit_edit_settings) the warnings accumulate and flushChannelWarnings()
|
|
// emits one combined message at commit. Outside a transaction the caller flushes
|
|
// immediately, so a single channel/preset edit still produces a single message.
|
|
void queueChannelWarning(uint8_t channelIndex, bool nameIssue, bool pskIssue, const char *format, ...)
|
|
__attribute__((format(printf, 5, 6)));
|
|
// Emit the "licensed mode activated" notice, deferring to commit during an edit transaction
|
|
// so repeated triggers (e.g. owner + several channels) produce a single message.
|
|
void warnLicensedMode();
|
|
void flushChannelWarnings();
|
|
|
|
char pendingWarningText[250] = {}; // the lone queued message, used verbatim when only one fired
|
|
uint32_t pendingWarningChannels = 0; // bitmask of channel indices with a queued warning
|
|
uint8_t pendingWarningCount = 0; // number of queued warnings this transaction
|
|
bool pendingWarningNameIssue = false; // any queued warning was about a channel name
|
|
bool pendingWarningPskIssue = false; // any queued warning was about a PSK
|
|
bool pendingLicenseWarning = false; // a licensed-mode notice is queued for this transaction
|
|
};
|
|
|
|
static constexpr const char *licensedModeMessage =
|
|
"Licensed mode activated, removing admin channel and encryption from all channels";
|
|
|
|
static constexpr const char *licensedIdentityMigrationMessage =
|
|
"Licensed signing requires an identity key; this node identity will change after key generation";
|
|
|
|
static constexpr const char *publicChannelPrecisionMessage =
|
|
"Precise position is not allowed on a public (open / known-key) channel; reduced to coarse precision";
|
|
|
|
extern AdminModule *adminModule;
|
|
|
|
void disableBluetooth();
|