diff --git a/src/config/Config.h b/src/config/Config.h index d446e7f..a99ddf4 100644 --- a/src/config/Config.h +++ b/src/config/Config.h @@ -6,8 +6,8 @@ #define RATDECK_VERSION_MAJOR 1 #define RATDECK_VERSION_MINOR 5 -#define RATDECK_VERSION_PATCH 6 -#define RATDECK_VERSION_STRING "1.5.6" +#define RATDECK_VERSION_PATCH 7 +#define RATDECK_VERSION_STRING "1.5.7" // --- Feature Flags --- #define HAS_DISPLAY true @@ -46,6 +46,9 @@ #define TCP_RECONNECT_INTERVAL_MS 15000 #define TCP_CONNECT_TIMEOUT_MS 5000 +// --- Announce Flood Defense --- +#define RATDECK_MAX_ANNOUNCES_PER_SEC 5 // Transport-level rate limit (before Ed25519 verify) + // --- Limits --- #define RATDECK_MAX_NODES 200 // PSRAM allows more #define RATDECK_MAX_MESSAGES_PER_CONV 100 diff --git a/src/reticulum/AnnounceManager.cpp b/src/reticulum/AnnounceManager.cpp index 7393df8..ff29da6 100644 --- a/src/reticulum/AnnounceManager.cpp +++ b/src/reticulum/AnnounceManager.cpp @@ -124,6 +124,16 @@ void AnnounceManager::received_announce( // Filter out own announces if (_localDestHash.size() > 0 && destination_hash == _localDestHash) return; + // Layer 3: Global announce rate limit — cap application-layer processing + { + unsigned long now = millis(); + if (now - _globalAnnounceWindowStart >= 1000) { + _globalAnnounceWindowStart = now; + _globalAnnounceCount = 0; + } + if (++_globalAnnounceCount > MAX_GLOBAL_ANNOUNCES_PER_SEC) return; + } + std::string destHex = destination_hash.toHex(); Serial.printf("[ANNOUNCE] From: %s name=\"%s\"\n", destHex.c_str(), name.c_str()); diff --git a/src/reticulum/AnnounceManager.h b/src/reticulum/AnnounceManager.h index aff77b8..1e5c837 100644 --- a/src/reticulum/AnnounceManager.h +++ b/src/reticulum/AnnounceManager.h @@ -65,6 +65,9 @@ private: unsigned long _lastContactSave = 0; unsigned long _lastAnnounceProcessed = 0; std::map _nameCache; // hexHash → displayName + unsigned long _globalAnnounceWindowStart = 0; + unsigned int _globalAnnounceCount = 0; + static constexpr unsigned int MAX_GLOBAL_ANNOUNCES_PER_SEC = 3; static constexpr int MAX_NODES = 30; static constexpr unsigned long CONTACT_SAVE_INTERVAL_MS = 30000; static constexpr unsigned long ANNOUNCE_MIN_INTERVAL_MS = 200; // Rate-limit announce processing diff --git a/src/reticulum/LXMFManager.cpp b/src/reticulum/LXMFManager.cpp index 8b75210..059bb86 100644 --- a/src/reticulum/LXMFManager.cpp +++ b/src/reticulum/LXMFManager.cpp @@ -28,9 +28,14 @@ void LXMFManager::loop() { Serial.printf("[LXMF] Queue drain: status=%s dest=%s\n", msg.statusStr(), msg.destHash.toHex().substr(0, 8).c_str()); + // Persist updated status to disk so reloads don't revert to QUEUED + std::string peerHex = msg.destHash.toHex(); + if (_store) { + _store->updateMessageStatus(peerHex, msg.timestamp, false, msg.status); + } + // Fire status callback so UI can refresh if (_statusCb) { - std::string peerHex = msg.destHash.toHex(); _statusCb(peerHex, msg.timestamp, msg.status); } _outQueue.pop_front(); diff --git a/src/reticulum/ReticulumManager.cpp b/src/reticulum/ReticulumManager.cpp index 663263d..37555d6 100644 --- a/src/reticulum/ReticulumManager.cpp +++ b/src/reticulum/ReticulumManager.cpp @@ -105,6 +105,18 @@ bool ReticulumManager::begin(SX1262* radio, FlashStore* flash) { _reticulum.start(); Serial.printf("[RNS] Reticulum started (%s)\n", _transportEnabled ? "Transport Node" : "Endpoint"); + // Layer 1: Transport-level announce rate limiter — filters BEFORE Ed25519 verify + RNS::Transport::set_filter_packet_callback([](const RNS::Packet& packet) -> bool { + if (packet.packet_type() == RNS::Type::Packet::ANNOUNCE) { + static unsigned long windowStart = 0; + static unsigned int count = 0; + unsigned long now = millis(); + if (now - windowStart >= 1000) { windowStart = now; count = 0; } + if (++count > RATDECK_MAX_ANNOUNCES_PER_SEC) return false; + } + return true; + }); + // Load persisted known destinations so Identity::recall() works // immediately after reboot for previously-seen nodes. RNS::Identity::load_known_destinations(); diff --git a/src/storage/MessageStore.cpp b/src/storage/MessageStore.cpp index ea042a5..e10fa48 100644 --- a/src/storage/MessageStore.cpp +++ b/src/storage/MessageStore.cpp @@ -530,6 +530,74 @@ void MessageStore::markConversationRead(const std::string& peerHex) { _summaries[peerHex].unreadCount = 0; } +bool MessageStore::updateMessageStatus(const std::string& peerHex, double timestamp, bool incoming, LXMFStatus newStatus) { + char suffix = incoming ? 'i' : 'o'; + + auto updateInDir = [&](auto openFn, auto readFn, auto writeFn, const String& dir) -> bool { + File d = openFn(dir.c_str()); + if (!d || !d.isDirectory()) return false; + + // Collect matching files (by direction suffix) + std::vector candidates; + File entry = d.openNextFile(); + while (entry) { + if (!entry.isDirectory() && isJsonFile(entry.name())) { + String name = entry.name(); + int len = name.length(); + if (len >= 7 && name[len - 6] == suffix) { + candidates.push_back(name); + } + } + entry = d.openNextFile(); + } + + // Search newest-first for the matching timestamp + std::sort(candidates.begin(), candidates.end(), [](const String& a, const String& b) { return a > b; }); + + for (const auto& fname : candidates) { + String path = dir + "/" + fname; + String json = readFn(path.c_str()); + if (json.length() == 0) continue; + + JsonDocument doc; + if (deserializeJson(doc, json)) continue; + + double ts = doc["ts"] | 0.0; + if (ts == timestamp) { + doc["status"] = (int)newStatus; + String updated; + serializeJson(doc, updated); + writeFn(path.c_str(), updated); + return true; + } + } + return false; + }; + + bool updated = false; + + if (_sd && _sd->isReady()) { + String sdDir = sdConversationDir(peerHex); + updated = updateInDir( + [&](const char* p) { return _sd->openDir(p); }, + [&](const char* p) { return _sd->readString(p); }, + [&](const char* p, const String& d) { _sd->writeString(p, d); }, + sdDir); + } + + if (_flash) { + String dir = conversationDir(peerHex); + bool flashUpdated = updateInDir( + [](const char* p) { return LittleFS.open(p); }, + [this](const char* p) { return _flash->readString(p); }, + [this](const char* p, const String& d) { _flash->writeString(p, d); }, + dir); + updated = updated || flashUpdated; + } + + return updated; +} + void MessageStore::buildSummaries() { _summaries.clear(); diff --git a/src/storage/MessageStore.h b/src/storage/MessageStore.h index 0172ecd..fcc2179 100644 --- a/src/storage/MessageStore.h +++ b/src/storage/MessageStore.h @@ -27,6 +27,7 @@ public: int messageCount(const std::string& peerHex) const; bool deleteConversation(const std::string& peerHex); void markConversationRead(const std::string& peerHex); + bool updateMessageStatus(const std::string& peerHex, double timestamp, bool incoming, LXMFStatus newStatus); const ConversationSummary* getSummary(const std::string& peerHex) const; int totalUnreadCount() const; diff --git a/src/transport/TCPClientInterface.cpp b/src/transport/TCPClientInterface.cpp index e0df6da..ccbe17a 100644 --- a/src/transport/TCPClientInterface.cpp +++ b/src/transport/TCPClientInterface.cpp @@ -71,8 +71,9 @@ void TCPClientInterface::loop() { return; // Will reconnect on next loop iteration } - // Drain multiple incoming frames per loop (up to 10) - for (int i = 0; i < 10 && _client.available(); i++) { + // Drain multiple incoming frames per loop (up to 10, time-boxed) + unsigned long tcpStart = millis(); + for (int i = 0; i < 10 && _client.available() && (millis() - tcpStart < TCP_LOOP_BUDGET_MS); i++) { unsigned long rxStart = millis(); int len = readFrame(); if (len > 0) { diff --git a/src/transport/TCPClientInterface.h b/src/transport/TCPClientInterface.h index 5959812..7e13bf9 100644 --- a/src/transport/TCPClientInterface.h +++ b/src/transport/TCPClientInterface.h @@ -45,6 +45,7 @@ private: static constexpr uint8_t FRAME_ESC = 0x7D; static constexpr uint8_t FRAME_XOR = 0x20; static constexpr unsigned long TCP_KEEPALIVE_TIMEOUT_MS = 300000; // 5 min + static constexpr unsigned long TCP_LOOP_BUDGET_MS = 15; public: unsigned long lastRxTime() const { return _lastRxTime; } diff --git a/src/ui/screens/LvNodesScreen.cpp b/src/ui/screens/LvNodesScreen.cpp index 588bb6d..2f01bf3 100644 --- a/src/ui/screens/LvNodesScreen.cpp +++ b/src/ui/screens/LvNodesScreen.cpp @@ -46,9 +46,12 @@ void LvNodesScreen::onEnter() { void LvNodesScreen::refreshUI() { if (!_am) return; + unsigned long now = millis(); + if (now - _lastRebuild < REBUILD_INTERVAL_MS) return; int contacts = 0; for (const auto& n : _am->nodes()) { if (n.saved) contacts++; } if (_am->nodeCount() != _lastNodeCount || contacts != _lastContactCount) { + _lastRebuild = now; rebuildList(); } } diff --git a/src/ui/screens/LvNodesScreen.h b/src/ui/screens/LvNodesScreen.h index 4ae8604..efae3c5 100644 --- a/src/ui/screens/LvNodesScreen.h +++ b/src/ui/screens/LvNodesScreen.h @@ -43,6 +43,9 @@ private: int _onlineHeaderIdx = -1; // Row index of "Online" header std::vector _rowToNodeIdx; // Maps row index -> node index in _am->nodes(), -1 for headers + unsigned long _lastRebuild = 0; + static constexpr unsigned long REBUILD_INTERVAL_MS = 2000; + lv_obj_t* _list = nullptr; lv_obj_t* _lblEmpty = nullptr; std::vector _rows;