ota: configurable hop limit (ota config hops) + accept-gate + forward RAM guard

Bound OTA-over-LoRa duty cycle across repeaters with one runtime-tunable,
persisted limit (OtaManager::max_hops, `ota config hops <0..8>`, default 3):

- Accept-gate: a node ignores OTA that arrived from more than max_hops hops
  away (neither processes nor relays it). 0 = direct only.
- Forward-cap: relay a flood only while still under max_hops, appending this
  node's path-hash (hop count increments like the mesh flood routing).
- RAM guard: relay an OTA flood only while more than OTA_FWD_MIN_FREE packet-
  pool slots stay free, so heavy OTA (best-effort, lowest-priority) can never
  monopolise the shared pool and starve real traffic — a dropped relay is
  re-requested by the source.

Persisted in NodePrefs (CommonCLI) and shown in `ota config`. Docs updated.
This commit is contained in:
Valentin Kivachuk Burda committed 2026-06-30 20:35:48 +02:00
1 parent 9ff1c883c2
commit 259802b706
8 files changed
+59 -15

No files matched your search

+5
View File
@@ -304,6 +304,11 @@ Message types:
- **Relay:** replies are flooded, so transparent relay needs no per-requester addressing, and the transfer
is trustless (the fetcher verifies every block against the signed root). Any neighbor may serve any
fragment it has.
- **Hop limit + duty cycle:** OTA floods accumulate one path-hash per relay (the mesh's flood routing). A
node *accepts* a packet only if it arrived within `ota config hops` hops (default 3; `0` = direct only)
and *relays* it only while still under that limit, appending its own hash. Relays are lowest-priority and
are skipped when the packet pool runs low (the source retries), so heavy OTA can never monopolise a
repeater's RAM or starve real traffic.
### 8.1 Two-tier discovery
+3
View File
@@ -132,6 +132,9 @@ ota config autoinstall off # never auto-install (default)
ota config advert 1440 # re-advertise this node every N minutes (default 1440 = 24h)
ota config advert 0 # disable periodic re-advertise (still advertises briefly at boot)
ota config hops 3 # how far OTA travels: accept from / relay up to N repeater hops (default 3)
ota config hops 0 # only exchange OTA with directly-connected nodes (never relay)
ota config # show the current settings
```
+16 -6
View File
@@ -31,6 +31,10 @@ void Mesh::otaSendAdapter(void* ctx, const uint8_t* msg, uint16_t len, bool /*fl
Packet* p = m->createOtaPacket(msg, len);
if (p) m->sendOtaFlood(p);
}
// Runtime OTA flood reach (`ota config hops`, persisted in NodePrefs): accept packets up to N hops away and
// relay those still under N hops. 0 = direct only. Overridable per-role by subclassing.
uint8_t Mesh::getOtaHopLimit() const { return ota::ota_ctx().manager.max_hops(); }
#endif
void Mesh::begin() {
@@ -423,21 +427,27 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
#if defined(ENABLE_OTA)
case PAYLOAD_TYPE_OTA: {
// ALWAYS process every received copy: OTA handlers are idempotent, and "eventually reliable"
// retries deliberately re-send IDENTICAL requests — if we gated processing on hasSeen(), the
// dedup would suppress those retries and the transfer could never recover from a lost reply.
// hasSeen() is used ONLY to avoid re-flooding the same packet more than once.
uint8_t n = pkt->getPathHashCount(); // hops travelled to reach us (flood path-hash count)
// Accept-gate (duty-cycle horizon): ignore OTA from further than our hop limit — neither process nor
// relay it. 0 = only directly-received OTA. Runtime-tunable via `ota config hops`.
if (n > getOtaHopLimit()) break;
// ALWAYS process every accepted copy: OTA handlers are idempotent, and "eventually reliable" retries
// deliberately re-send IDENTICAL requests — if we gated processing on hasSeen(), the dedup would
// suppress those retries and the transfer could never recover from a lost reply. hasSeen() is used
// ONLY to avoid re-flooding the same packet more than once.
bool seen = _tables->hasSeen(pkt);
ota::ota_ctx().manager.set_clock(_ms->getMillis()); // discovery jitter/ages
ota::ota_ctx().manager.on_message(pkt->payload, pkt->payload_len); // central OTA receive (beacon/query/
// have/manifest/data/proof; all roles)
ota::ota_ctx().track_session(ota::ota_ctx().manager.fetchState(), _ms->getMillis());
onOtaRecv(pkt); // optional per-example hook
// Re-flood with a hop cap and the LOWEST priority, so OTA never competes with mesh traffic.
uint8_t n = pkt->getPathHashCount();
// Re-flood at the LOWEST priority and only while still under the hop limit, so OTA never competes with
// mesh traffic. The free-pool guard keeps heavy OTA from monopolising the shared packet pool — dropping
// a relay is safe (OTA is best-effort; the source retries).
if (!seen && pkt->isRouteFlood() && !pkt->isMarkedDoNotRetransmit()
&& n < getOtaHopLimit()
&& (n + 1) * pkt->getPathHashSize() <= MAX_PATH_SIZE
&& _mgr->getFreeCount() > OTA_FWD_MIN_FREE
&& allowPacketForward(pkt)) {
self_id.copyHashTo(&pkt->path[n * pkt->getPathHashSize()], pkt->getPathHashSize());
pkt->setPathHashCount(n + 1);
+8 -5
View File
@@ -3,12 +3,15 @@
#include <Dispatcher.h>
#if defined(ENABLE_OTA)
// OTA-over-LoRa: lowest TX priority (selected only after all real traffic) + default hop cap.
// OTA-over-LoRa: lowest TX priority (selected only after all real traffic). The hop limit is runtime-
// tunable (OtaManager::max_hops(), `ota config hops`); the default lives in OtaManager.h.
#ifndef OTA_TX_PRIORITY
#define OTA_TX_PRIORITY 250
#endif
#ifndef OTA_HOP_LIMIT_DEFAULT
#define OTA_HOP_LIMIT_DEFAULT 3
// An OTA flood is relayed only while this many pool slots stay free, so heavy OTA (best-effort, low-
// priority) can never monopolise the shared packet pool and starve real traffic.
#ifndef OTA_FWD_MIN_FREE
#define OTA_FWD_MIN_FREE 4
#endif
#endif
@@ -161,8 +164,8 @@ protected:
*/
virtual void onOtaRecv(Packet* packet) { }
/** \returns the max hop count for forwarding OTA flood packets (default 3). */
virtual uint8_t getOtaHopLimit() const { return OTA_HOP_LIMIT_DEFAULT; }
/** \returns max OTA flood reach in hops — accept up to N, relay while < N (0=direct). `ota config hops`. */
virtual uint8_t getOtaHopLimit() const;
// OTA mesh-integration is centralized in Mesh::begin()/loop()/dispatch, so every role (repeater,
// companion, room, sensor, ...) gets fetch/serve/apply without per-example wiring.
+8 -2
View File
@@ -53,6 +53,7 @@ void CommonCLI::syncOtaConfigFromPrefs() {
c.manager.set_autofetch(_prefs->ota_autofetch);
c.manager.set_checkpoint_blocks(_prefs->ota_checkpoint_blocks);
c.manager.set_advert_mins(_prefs->ota_advert_interval);
c.manager.set_max_hops(_prefs->ota_max_hops);
c.autoinstall = _prefs->ota_autoinstall;
c.allow.clear();
for (uint8_t i = 0; i < _prefs->ota_signer_count && i < MAX_OTA_SIGNERS; i++)
@@ -121,13 +122,15 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
_prefs->ota_autofetch = 0; _prefs->ota_autoinstall = 0; _prefs->ota_signer_count = 0;
_prefs->ota_checkpoint_blocks = 4; // = OTA_CHECKPOINT_BLOCKS; older prefs lack it -> stays at default
_prefs->ota_advert_interval = 1440; // = OTA_ADVERT_INTERVAL_MINS (24h); older prefs lack it -> stays default
_prefs->ota_max_hops = 3; // = OTA_HOP_LIMIT_DEFAULT; older prefs lack it -> stays default
file.read((uint8_t *)&_prefs->ota_autofetch, sizeof(_prefs->ota_autofetch)); // 295
file.read((uint8_t *)&_prefs->ota_autoinstall, sizeof(_prefs->ota_autoinstall)); // 296
file.read((uint8_t *)&_prefs->ota_signer_count, sizeof(_prefs->ota_signer_count)); // 297
file.read((uint8_t *)_prefs->ota_signers, sizeof(_prefs->ota_signers)); // 298
file.read((uint8_t *)&_prefs->ota_checkpoint_blocks, sizeof(_prefs->ota_checkpoint_blocks)); // 426
file.read((uint8_t *)&_prefs->ota_advert_interval, sizeof(_prefs->ota_advert_interval)); // 428
// next: 430
file.read((uint8_t *)&_prefs->ota_max_hops, sizeof(_prefs->ota_max_hops)); // 430
// next: 431
// sanitise bad pref values
_prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f);
@@ -163,6 +166,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
_prefs->ota_autoinstall = constrain(_prefs->ota_autoinstall, 0, 1);
if (_prefs->ota_checkpoint_blocks > 4096) _prefs->ota_checkpoint_blocks = 4; // 0=never; cap absurd
if (_prefs->ota_advert_interval > 10080) _prefs->ota_advert_interval = 1440; // 0=off; cap at 7 days
if (_prefs->ota_max_hops > 8) _prefs->ota_max_hops = 3; // 0=direct only; cap absurd reach
if (_prefs->ota_signer_count > 4) _prefs->ota_signer_count = 0; // corrupt count -> drop keys
file.close();
@@ -235,7 +239,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) {
file.write((uint8_t *)_prefs->ota_signers, sizeof(_prefs->ota_signers)); // 298
file.write((uint8_t *)&_prefs->ota_checkpoint_blocks, sizeof(_prefs->ota_checkpoint_blocks)); // 426
file.write((uint8_t *)&_prefs->ota_advert_interval, sizeof(_prefs->ota_advert_interval)); // 428
// next: 430
file.write((uint8_t *)&_prefs->ota_max_hops, sizeof(_prefs->ota_max_hops)); // 430
// next: 431
file.close();
}
@@ -365,6 +370,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
_prefs->ota_autofetch = c.manager.autofetch();
_prefs->ota_checkpoint_blocks = c.manager.checkpoint_blocks();
_prefs->ota_advert_interval = c.manager.advert_mins();
_prefs->ota_max_hops = c.manager.max_hops();
_prefs->ota_autoinstall = c.autoinstall;
_prefs->ota_signer_count = c.allow.count();
for (uint8_t i = 0; i < c.allow.count() && i < MAX_OTA_SIGNERS; i++)
+1
View File
@@ -72,6 +72,7 @@ struct NodePrefs { // persisted to file
uint8_t ota_signers[4][32]; // trusted Ed25519 signer pubkeys (== MAX_OTA_SIGNERS)
uint16_t ota_checkpoint_blocks; // resume checkpoint cadence (blocks); 0=never. Default 4 (runtime-tunable)
uint16_t ota_advert_interval; // OTA beacon re-advertise cadence (mins); 0=off. Default 1440 (24h, tunable)
uint8_t ota_max_hops; // OTA flood reach in hops; 0=direct only. Default 3 (runtime-tunable)
};
class CommonCLICallbacks {
+8 -2
View File
@@ -277,12 +277,18 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
if (m < 0 || m > 10080) { strcpy(reply, "ERR usage: ota config advert <0..10080> (minutes; 0=disable)"); return true; }
c.manager.set_advert_mins((uint16_t)m); c.config_dirty = true;
sprintf(reply, "OK re-advertise every %ld min (saved)%s", m, m == 0 ? " — periodic advert disabled" : "");
} else if (strncmp(p, "hops ", 5) == 0) { // OTA flood reach in hops (0 = direct only)
long h = atol(p + 5);
if (h < 0 || h > 8) { strcpy(reply, "ERR usage: ota config hops <0..8> (hops; 0 = direct only)"); return true; }
c.manager.set_max_hops((uint8_t)h); c.config_dirty = true;
sprintf(reply, "OK OTA reach = %ld hop%s (saved)%s", h, h == 1 ? "" : "s", h == 0 ? " — direct only" : "");
} else { // show current policy
uint8_t af = c.manager.autofetch();
sprintf(reply, "ota config: autofetch=%s autoinstall=%s checkpoint=%u advert=%umin keys=%u (persisted)",
sprintf(reply, "ota config: autofetch=%s autoinstall=%s checkpoint=%u advert=%umin hops=%u keys=%u (persisted)",
af == OtaManager::AUTOFETCH_ANY ? "any" : af == OtaManager::AUTOFETCH_SIGNED ? "signed" : "off",
c.autoinstall == OtaContext::AUTOINSTALL_TRUSTED ? "trusted" : "off",
(unsigned)c.manager.checkpoint_blocks(), (unsigned)c.manager.advert_mins(), (unsigned)c.allow.count());
(unsigned)c.manager.checkpoint_blocks(), (unsigned)c.manager.advert_mins(),
(unsigned)c.manager.max_hops(), (unsigned)c.allow.count());
}
// ---- trusted signer allowlist (security config; persisted): `ota key add|rm <hex>` / `ota key` lists ----
+10
View File
@@ -40,6 +40,9 @@ typedef bool (*ServeReadFn)(void* ctx, uint32_t off, uint8_t* buf, uint32_t len)
#ifndef OTA_ADVERT_INTERVAL_MINS
#define OTA_ADVERT_INTERVAL_MINS 1440 // re-advertise our served set every N minutes after the boot burst (24h)
#endif
#ifndef OTA_HOP_LIMIT_DEFAULT
#define OTA_HOP_LIMIT_DEFAULT 3 // default OTA flood reach in hops: accept <= N, relay while < N (0=direct)
#endif
#ifndef OTA_MF_FRAG
#define OTA_MF_FRAG 176 // manifest bytes per OTA_MANIFEST fragment (<= MAX_PACKET_PAYLOAD - header)
#endif
@@ -213,6 +216,12 @@ public:
// (boot burst only). Default OTA_ADVERT_INTERVAL_MINS (24h).
void set_advert_mins(uint16_t m) { _advert_mins = m; }
uint16_t advert_mins() const { return _advert_mins; }
// Max OTA flood reach in HOPS (runtime-tunable, persisted): a node accepts OTA from up to N hops away and
// relays packets still under N hops; 0 = direct only (accept path_count 0, never relay). Bounds duty-cycle
// when crossing repeaters. Default OTA_HOP_LIMIT_DEFAULT. Set via `ota config hops`.
void set_max_hops(uint8_t h) { _max_hops = h; }
uint8_t max_hops() const { return _max_hops; }
bool fetched_is_signed() const { return (_fflags & MFLAG_SIGNED) != 0; } // flags of the fetched manifest
// This node's id (pubkey[0:4]), stamped into adverts we send so receivers can count distinct seeders.
@@ -335,6 +344,7 @@ private:
uint8_t _autofetch = AUTOFETCH_OFF; // auto-fetch policy (persisted in NodePrefs)
uint16_t _checkpoint_blocks = OTA_CHECKPOINT_BLOCKS; // resume checkpoint cadence (persisted)
uint16_t _advert_mins = OTA_ADVERT_INTERVAL_MINS; // beacon re-advertise cadence, minutes; 0=off (persisted)
uint8_t _max_hops = OTA_HOP_LIMIT_DEFAULT; // OTA flood reach in hops; 0=direct only (persisted)
uint8_t _fflags = 0; // flags of the manifest currently being fetched
// multi-fragment reassembly of the current block (per-block 2-phase: fetch data, then its proof)
uint32_t _reasm_block = NO_BLOCK; // block being reassembled / awaiting proof (none)