mirror of
https://github.com/ratspeak/ratdeck.git
synced 2026-08-28 23:00:51 +00:00
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:
+5
-2
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user