Fix announce flood from TCP hub overwhelming device (v1.5.7)

4-layer defense against 100+ node announce storms from rns.ratspeak.org:
- Transport-level rate limiter (5/sec) via filter_packet callback, before Ed25519 verify
- TCP frame processing time-boxed to 15ms per loop iteration
- Global announce rate limit (3/sec) in AnnounceManager
- UI rebuild throttled to once per 2 seconds

Also fixes message status not persisting to disk on queue drain.
This commit is contained in:
DeFiDude
2026-03-10 00:08:56 -06:00
parent 9c48530ec8
commit 6f272596c8
11 changed files with 115 additions and 5 deletions
+5 -2
View File
@@ -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
+10
View File
@@ -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());
+3
View File
@@ -65,6 +65,9 @@ private:
unsigned long _lastContactSave = 0;
unsigned long _lastAnnounceProcessed = 0;
std::map<std::string, std::string> _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
+6 -1
View File
@@ -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();
+12
View File
@@ -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();
+68
View File
@@ -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<String> 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();
+1
View File
@@ -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;
+3 -2
View File
@@ -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) {
+1
View File
@@ -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; }
+3
View File
@@ -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();
}
}
+3
View File
@@ -43,6 +43,9 @@ private:
int _onlineHeaderIdx = -1; // Row index of "Online" header
std::vector<int> _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<lv_obj_t*> _rows;