From 259802b70645a66e512f9a454244f5b1b43b71d6 Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Tue, 30 Jun 2026 20:35:48 +0200 Subject: [PATCH] ota: configurable hop limit (`ota config hops`) + accept-gate + forward RAM guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/ota_protocol.md | 5 +++++ docs/ota_user_guide.md | 3 +++ src/Mesh.cpp | 22 ++++++++++++++++------ src/Mesh.h | 13 ++++++++----- src/helpers/CommonCLI.cpp | 10 ++++++++-- src/helpers/CommonCLI.h | 1 + src/helpers/ota/OtaCli.cpp | 10 ++++++++-- src/helpers/ota/OtaManager.h | 10 ++++++++++ 8 files changed, 59 insertions(+), 15 deletions(-) diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index cc5583fe..4e7f5a28 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -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 diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index 7d4434ee..0235be23 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -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 ``` diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 167a314b..788c0f0c 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -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); diff --git a/src/Mesh.h b/src/Mesh.h index 0d7215d4..04fbcfe1 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -3,12 +3,15 @@ #include #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. diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 163971bf..56201e99 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -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++) diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index e0359fc1..bf9a1861 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -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 { diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 465f42ba..f514dea2 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -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 ` / `ota key` lists ---- diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index 79f22ebb..a0c8cf04 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -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)