fix(companion): scopes, room bot backlog, DM flood fallback, atomic trail saves

- Removing a scope moves the channels that used it to the default, not to
  unscoped.
- The room bot ignores posts older than 5 minutes: the backlog a room
  replays after login no longer gets answers.
- Adverts take the default scope from the scope list, as messages do; the
  legacy fields always mirror it, and CMD_SET_DEFAULT_FLOOD_SCOPE with a
  full list answers TABLE_FULL.
- The last DM resend drops the direct path and floods.
- /trail and /waypoints are written through a temp file.
- An admin login timeout keeps the saved password.
- History records read from the SD card are sanitised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-12 17:54:38 +02:00
co-authored by Claude Opus 5.5
parent fd45c2219b
commit cd294613ea
8 changed files with 63 additions and 43 deletions
+19 -12
View File
@@ -708,6 +708,9 @@ void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint3
} }
} }
// Oldest room post the bot still answers (see onSignedMessageRecv()).
#define BOT_ROOM_MAX_AGE_SECS 300
void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
const uint8_t *sender_prefix, const char *text) { const uint8_t *sender_prefix, const char *text) {
markConnectionActive(from); markConnectionActive(from);
@@ -729,7 +732,12 @@ void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uin
// Room-server auto-reply bot — only ever fires for the room server contact // Room-server auto-reply bot — only ever fires for the room server contact
// itself (signed posts are how a room relays its members' messages back to // itself (signed posts are how a room relays its members' messages back to
// us); a defensive type check lives in tryBotReplyRoom/tryBotRoomCommand too. // us); a defensive type check lives in tryBotReplyRoom/tryBotRoomCommand too.
if (from.type == ADV_TYPE_ROOM) { // After login the room replays posts we missed: answer only fresh ones, or
// the bot would reply to (and run commands from) the whole backlog. A clock
// behind the room's makes every post look new, which keeps today's behaviour.
uint32_t now = getRTCClock()->getCurrentTime();
bool replayed = now > sender_timestamp && now - sender_timestamp > BOT_ROOM_MAX_AGE_SECS;
if (from.type == ADV_TYPE_ROOM && !replayed) {
uint8_t hops = pkt ? pkt->getPathHashCount() : 0; uint8_t hops = pkt ? pkt->getPathHashCount() : 0;
if (!tryBotRoomCommand(from, sender_prefix, text, hops)) // commands take priority if (!tryBotRoomCommand(from, sender_prefix, text, hops)) // commands take priority
tryBotReplyRoom(from, sender_prefix, text, hops); tryBotReplyRoom(from, sender_prefix, text, hops);
@@ -1796,8 +1804,7 @@ void MyMesh::handleCmdFrame(size_t len) {
if (pkt) { if (pkt) {
if (len >= 2 && cmd_frame[1] == 1) { // optional param (1 = flood, 0 = zero hop) if (len >= 2 && cmd_frame[1] == 1) { // optional param (1 = flood, 0 = zero hop)
unsigned long delay_millis = 0; unsigned long delay_millis = 0;
TransportKey default_scope; TransportKey default_scope = _scope_list.key(_scope_list.default_idx); // what messages use too
memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key));
sendFloodScoped(default_scope, pkt, delay_millis); sendFloodScoped(default_scope, pkt, delay_millis);
} else { } else {
sendZeroHop(pkt); sendZeroHop(pkt);
@@ -2512,17 +2519,18 @@ void MyMesh::handleCmdFrame(size_t len) {
// fields: since the scope list landed, every send and the relay filter // fields: since the scope list landed, every send and the relay filter
// resolve through _scope_list, so writing default_scope_name/key alone // resolve through _scope_list, so writing default_scope_name/key alone
// would leave the app's request with nothing reading it. // would leave the app's request with nothing reading it.
setPrimaryScope((char *) &cmd_frame[1]); if (!setPrimaryScope((char *) &cmd_frame[1])) {
writeErrFrame(ERR_CODE_TABLE_FULL); // no room for a new entry: default unchanged
return;
}
// Honour the key the app derived rather than the one setPrimaryScope() // Honour the key the app derived rather than the one setPrimaryScope()
// re-derived from the name. They agree today (same "#name" -> SHA256), // re-derived from the name. They agree today (same "#name" -> SHA256),
// but the app is the authority on its own regions, and a silent // but the app is the authority on its own regions, and a silent
// mismatch here would be an on-air difference nothing surfaces. // mismatch here would be an on-air difference nothing surfaces.
memcpy(_prefs.default_scope_key, &cmd_frame[1+31], 16); memcpy(_scope_list.entries[_scope_list.default_idx - 1].key, &cmd_frame[1+31], 16);
if (_scope_list.default_idx >= 1) { syncLegacyDefaultScope();
memcpy(_scope_list.entries[_scope_list.default_idx - 1].key, &cmd_frame[1+31], 16); if (_store) _store->saveScopeList(_scope_list);
if (_store) _store->saveScopeList(_scope_list); rebuildRepeatScopes(); // slot 0 of the relay filter tracks this key
rebuildRepeatScopes(); // slot 0 of the relay filter tracks this key
}
savePrefs(); savePrefs();
writeOKFrame(); writeOKFrame();
} else { } else {
@@ -2924,8 +2932,7 @@ bool MyMesh::advertFlood() {
mesh::Packet* pkt; mesh::Packet* pkt;
pkt = createOwnAdvert(); pkt = createOwnAdvert();
if (pkt) { if (pkt) {
TransportKey default_scope; TransportKey default_scope = _scope_list.key(_scope_list.default_idx);
memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key));
sendFloodScoped(default_scope, pkt, 0); sendFloodScoped(default_scope, pkt, 0);
return true; return true;
} else { } else {
+1 -1
View File
@@ -485,7 +485,7 @@ public:
// device's own list too, not just in the two legacy fields. Empty name // device's own list too, not just in the two legacy fields. Empty name
// clears the default back to list index 0 ("*"). Calls // clears the default back to list index 0 ("*"). Calls
// rebuildRepeatScopes() and persists the list. // rebuildRepeatScopes() and persists the list.
void setPrimaryScope(const char* name); bool setPrimaryScope(const char* name); // false: list full, default unchanged
// Mirrors the list's current default entry back into the legacy // Mirrors the list's current default entry back into the legacy
// NodePrefs::default_scope_name/key pair. Those two fields are the only // NodePrefs::default_scope_name/key pair. Those two fields are the only
+13 -21
View File
@@ -4,41 +4,33 @@
// Not part of upstream MyMesh.cpp: included at its bottom, after all class // Not part of upstream MyMesh.cpp: included at its bottom, after all class
// definitions (as MyMeshBot.h and MyMeshCli.h). // definitions (as MyMeshBot.h and MyMeshCli.h).
void MyMesh::setPrimaryScope(const char* name) { bool MyMesh::setPrimaryScope(const char* name) {
// Keep the legacy fields in sync too -- inert for rebuildRepeatScopes() bool ok = true;
// once /scopes1 exists, but still what a pre-list save file round-trips, if (!name || name[0] == '\0') {
// and cheap to maintain.
strncpy(_prefs.default_scope_name, name, sizeof(_prefs.default_scope_name) - 1);
_prefs.default_scope_name[sizeof(_prefs.default_scope_name) - 1] = '\0';
if (_prefs.default_scope_name[0] == '\0') {
memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key));
} else {
char hashtag[1 + sizeof(_prefs.default_scope_name)];
snprintf(hashtag, sizeof(hashtag), "#%s", _prefs.default_scope_name);
TransportKeyStore temp;
TransportKey key;
temp.getAutoKeyFor(0, hashtag, key);
memcpy(_prefs.default_scope_key, key.key, sizeof(key.key));
}
if (_prefs.default_scope_name[0] == '\0') {
_scope_list.default_idx = 0; // "*" _scope_list.default_idx = 0; // "*"
} else { } else {
// Find-or-create a matching named entry (e.g. the app driving this // Find-or-create a matching named entry (e.g. the app driving this
// remotely via CMD_SET_DEFAULT_FLOOD_SCOPE), then mark it default. // remotely via CMD_SET_DEFAULT_FLOOD_SCOPE), then mark it default.
char nm[sizeof(_prefs.default_scope_name)];
StrHelper::strncpy(nm, name, sizeof(nm));
uint8_t idx = 0; uint8_t idx = 0;
for (uint8_t i = 0; i < _scope_list.count; i++) { for (uint8_t i = 0; i < _scope_list.count; i++) {
if (strcmp(_scope_list.entries[i].name, _prefs.default_scope_name) == 0) { idx = i + 1; break; } if (strcmp(_scope_list.entries[i].name, nm) == 0) { idx = i + 1; break; }
} }
if (idx == 0) idx = _scope_list.add(_prefs.default_scope_name); if (idx == 0) idx = _scope_list.add(nm);
// add() returns 0 only when the list is full. Keep the previous default // add() returns 0 only when the list is full. Keep the previous default
// rather than taking that as "*": silently dropping to unscoped would put // rather than taking that as "*": silently dropping to unscoped would put
// traffic on the air outside any region, which is a louder failure than // traffic on the air outside any region, which is a louder failure than
// ignoring a request we had no room to honour. // ignoring a request we had no room to honour.
if (idx != 0) _scope_list.default_idx = idx; if (idx != 0) _scope_list.default_idx = idx;
else ok = false;
} }
// The legacy fields always mirror the list's default, so a refused request
// can't leave them naming a scope nothing else sends with.
syncLegacyDefaultScope();
if (_store) _store->saveScopeList(_scope_list); if (_store) _store->saveScopeList(_scope_list);
rebuildRepeatScopes(); rebuildRepeatScopes();
return ok;
} }
void MyMesh::syncLegacyDefaultScope() { void MyMesh::syncLegacyDefaultScope() {
@@ -91,7 +83,7 @@ void MyMesh::removeScope(uint8_t idx) {
for (uint8_t i = 0; i < NodePrefs::MAX_SCOPED_CHANNELS; i++) { for (uint8_t i = 0; i < NodePrefs::MAX_SCOPED_CHANNELS; i++) {
uint8_t ci = _prefs.ch_scope_idx[i]; uint8_t ci = _prefs.ch_scope_idx[i];
if (ci == NodePrefs::CH_SCOPE_DEFAULT) continue; // follows the default, fixed up above if (ci == NodePrefs::CH_SCOPE_DEFAULT) continue; // follows the default, fixed up above
if (ci == idx) _prefs.ch_scope_idx[i] = 0; if (ci == idx) _prefs.ch_scope_idx[i] = NodePrefs::CH_SCOPE_DEFAULT; // back to the default, not to unscoped "*"
else if (ci > idx) _prefs.ch_scope_idx[i] = ci - 1; else if (ci > idx) _prefs.ch_scope_idx[i] = ci - 1;
} }
// Live Share's own scope (stored as list index + 1; 0 = follow the target). // Live Share's own scope (stored as list index + 1; 0 = follow the target).
@@ -336,7 +336,8 @@ public:
if ((_state != LOGGING_IN && _state != WAITING) || (int32_t)(millis() - _deadline_ms) < 0) return; if ((_state != LOGGING_IN && _state != WAITING) || (int32_t)(millis() - _deadline_ms) < 0) return;
if (_state == LOGGING_IN) { if (_state == LOGGING_IN) {
the_mesh.cancelUiPendingLogin(_target.id.pub_key); // a late answer mustn't land elsewhere the_mesh.cancelUiPendingLogin(_target.id.pub_key); // a late answer mustn't land elsewhere
the_mesh.forgetRoomPassword(_target.id.pub_key); // No answer says nothing about the password (out of range, busy mesh):
// keep a saved one, as RoomSessions does. Only a rejection forgets it.
_state = IDLE; _state = IDLE;
_outcome = LOGIN_TIMEOUT; _outcome = LOGIN_TIMEOUT;
return; return;
@@ -467,7 +467,8 @@ public:
// Periodic resend driver for outgoing DMs whose ACK deadline lapsed with no // Periodic resend driver for outgoing DMs whose ACK deadline lapsed with no
// ACK: resend with the next attempt# (reusing the original timestamp so the // ACK: resend with the next attempt# (reusing the original timestamp so the
// recipient dedups) while resends remain, else mark it failed (✗). // recipient dedups) while resends remain, else mark it failed (✗). The last
// resend floods.
void tickDmResends() { void tickDmResends() {
uint32_t now = millis(); uint32_t now = millis();
for (int i = 0; i < _dm_hist_count; i++) { for (int i = 0; i < _dm_hist_count; i++) {
@@ -477,6 +478,13 @@ public:
if (e.resends_left == 0) { failDm(e); continue; } if (e.resends_left == 0) { failDm(e); continue; }
ContactInfo c; ContactInfo c;
if (!contactByPrefix(e.prefix, c)) { failDm(e); continue; } if (!contactByPrefix(e.prefix, c)) { failDm(e); continue; }
// Last attempt over a direct path that went unanswered every time: drop
// the path and flood it, as the phone app does -- the route may be gone,
// and the flood finds a new one (the ACK's return path re-learns it).
if (e.resends_left == 1 && c.out_path_len != OUT_PATH_UNKNOWN &&
the_mesh.resetContactPath(c.id.pub_key)) {
c.out_path_len = OUT_PATH_UNKNOWN;
}
uint32_t expected_ack = 0, est_timeout = 0; uint32_t expected_ack = 0, est_timeout = 0;
uint8_t next_attempt = e.attempt + 1; uint8_t next_attempt = e.attempt + 1;
if (the_mesh.sendMessage(c, e.msg_ts, next_attempt, e.text, if (the_mesh.sendMessage(c, e.msg_ts, next_attempt, e.text,
@@ -63,10 +63,7 @@ public:
// trail can't wipe a previously saved one. // trail can't wipe a previously saved one.
void onShutdown() { void onShutdown() {
if (!_prefs || !_prefs->trail_autosave_lowbatt || _trail.count() == 0) return; if (!_prefs || !_prefs->trail_autosave_lowbatt || _trail.count() == 0) return;
DataStore* ds = the_mesh.getDataStore(); save();
if (!ds) return;
File f = ds->openWrite("/trail");
if (f) { _trail.writeTo(f); f.close(); }
} }
TrailStore& store() { return _trail; } TrailStore& store() { return _trail; }
@@ -74,6 +71,7 @@ public:
// ── Actions ──────────────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────────────
enum FileResult : uint8_t { FILE_OK, FILE_NO_FS, FILE_MISSING, FILE_FAILED }; enum FileResult : uint8_t { FILE_OK, FILE_NO_FS, FILE_MISSING, FILE_FAILED };
static constexpr const char* TRAIL_FILE = "/trail"; static constexpr const char* TRAIL_FILE = "/trail";
static constexpr const char* TRAIL_TMP = "/trail.tmp";
bool isActive() const { return _trail.isActive(); } bool isActive() const { return _trail.isActive(); }
void setActive(bool on) { _trail.setActive(on); } void setActive(bool on) { _trail.setActive(on); }
@@ -82,10 +80,14 @@ public:
FileResult save() { FileResult save() {
DataStore* ds = the_mesh.getDataStore(); DataStore* ds = the_mesh.getDataStore();
if (!ds) return FILE_NO_FS; if (!ds) return FILE_NO_FS;
File f = ds->openWrite(TRAIL_FILE); // Through a temp file: a write cut short (flash full, power lost mid-save)
// keeps the previous saved trail instead of leaving a torn /trail.
File f = ds->openWrite(TRAIL_TMP);
if (!f) return FILE_FAILED; if (!f) return FILE_FAILED;
bool ok = _trail.writeTo(f); bool ok = _trail.writeTo(f);
f.close(); f.close();
if (ok) ok = ds->commitFile(TRAIL_TMP, TRAIL_FILE);
else ds->removeFile(TRAIL_TMP);
return ok ? FILE_OK : FILE_FAILED; return ok ? FILE_OK : FILE_FAILED;
} }
FileResult load() { FileResult load() {
@@ -26,10 +26,12 @@ public:
void save() { void save() {
DataStore* ds = the_mesh.getDataStore(); DataStore* ds = the_mesh.getDataStore();
if (!ds) return; if (!ds) return;
File f = ds->openWrite("/waypoints"); File f = ds->openWrite("/waypoints.tmp"); // temp + rename: a cut-short write keeps the old file
if (!f) return; if (!f) return;
_store.writeTo(f); bool ok = _store.writeTo(f);
f.close(); f.close();
if (ok) ds->commitFile("/waypoints.tmp", "/waypoints");
else ds->removeFile("/waypoints.tmp");
} }
// `label` cut to what a waypoint holds (WAYPOINT_LABEL_LEN - 1 bytes) at a // `label` cut to what a waypoint holds (WAYPOINT_LABEL_LEN - 1 bytes) at a
@@ -193,11 +193,19 @@ private:
uint32_t run = m - got < h.cap - slot ? m - got : h.cap - slot; uint32_t run = m - got < h.cap - slot ? m - got : h.cap - slot;
if (fseek(f, sizeof(Hdr) + (long)slot * sizeof(E), SEEK_SET) != 0) break; if (fseek(f, sizeof(Hdr) + (long)slot * sizeof(E), SEEK_SET) != 0) break;
size_t r = fread(&out[got], sizeof(E), run, f); size_t r = fread(&out[got], sizeof(E), run, f);
for (size_t i = 0; i < r; i++) sanitize(out[got + i]);
got += (uint32_t)r; got += (uint32_t)r;
if (r != run) break; if (r != run) break;
} }
return got; return got;
} }
// The card comes out and goes on a computer: a damaged or edited record
// must not leave the text unterminated or a path longer than its buffer.
template <typename E>
static void sanitize(E& e) {
e.text[sizeof(e.text) - 1] = '\0';
if (((e.path_len >> 6) + 1) * (e.path_len & 63) > (int)sizeof(e.path)) e.path_len = 0;
}
static bool writeHdr(FILE* f, const Hdr& h) { static bool writeHdr(FILE* f, const Hdr& h) {
return fseek(f, 0, SEEK_SET) == 0 && fwrite(&h, sizeof(h), 1, f) == 1; return fseek(f, 0, SEEK_SET) == 0 && fwrite(&h, sizeof(h), 1, f) == 1;
} }