From d2000afe1d38570e6ed7c89b45418ce414b648bb Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Sat, 27 Jun 2026 20:01:54 +0200 Subject: [PATCH] Rename cli + fixes --- docs/ota_protocol.md | 29 ++-- docs/ota_user_guide.md | 222 ++++++++++++++++++++++++++++++ src/helpers/ota/MotaContainer.cpp | 3 +- src/helpers/ota/OtaCli.cpp | 153 +++++++++++++------- src/helpers/ota/OtaManager.cpp | 25 +++- src/helpers/ota/OtaManager.h | 20 ++- test/test_ota/test_ota_core.cpp | 83 +++++++++++ 7 files changed, 461 insertions(+), 74 deletions(-) create mode 100644 docs/ota_user_guide.md diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index 9c8a0fe9..bf58a8b0 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -5,6 +5,9 @@ written for developers who want to implement an interoperable peer (server, fetc in another codebase or project. Everything below is implemented and hardware-verified in this repository; where a section names a source file, that file is the authoritative reference for byte-level details. +> **Just want to update your node?** See the plain-language [OTA user guide](ota_user_guide.md) — this +> document is the technical/wire specification. + **Design goals** - Distribute firmware over LoRa as a **self-verifying, resumable, BitTorrent-style block transfer** that @@ -498,17 +501,23 @@ Heltec V3 over one USB cable, every block merkle-checked. User-facing OTA data should travel via `CMD_OTA_*` companion binary frames; the text CLI below is debug/operator oriented and replies are `snprintf`-bounded into a 160-byte buffer. +Commands take intuitive aliases (matched by the first word; see `is_cmd` in `OtaCli.cpp`) so they're easy +to type and read — `status`/`neighbors`/`pull`/`drop`/`applydelta` are the canonical names, the aliases are +the recommended user-facing forms. Output is plain-language (a user-facing guide lives at +[ota_user_guide.md](ota_user_guide.md)). + ``` -ota status session + self-fw summary -ota neighbors discovered mOTAs (queries sources; rows arrive async via OTA_HAVE) -ota announce serve self + send a beacon now -ota pull <#|mid8> fetch a chosen mOTA (manual; works regardless of autofetch) -ota drop drop the current fetch session (free the slot) -ota folder on|off attach/detach an external .mota folder (host daemon) ; bare = list -ota self print this firmware's EndF (body/image size, base_hash) -ota applydelta verify + approve + (ESP32) apply / (nRF52) reboot-to-bootloader -ota config [autofetch|autoinstall|checkpoint] ... show/set persisted policy -ota key add|list|rm trusted signer allowlist +ota help | ? list the commands +ota status | st (or bare `ota`) plain-language: running fw, the one fetch session (state/%/id), serving, keys +ota ls | neighbors | nbrs | updates | n discovered updates (queries sources; rows arrive async via OTA_HAVE) +ota get | pull | download <#|mid8> fetch a chosen mOTA (manual; works regardless of autofetch) +ota install | apply | applydelta verify + approve + (ESP32) apply / (nRF52) reboot-to-bootloader +ota cancel | drop | stop drop the current fetch session (frees the slot; stops re-seeding) +ota announce | adv serve self + send a beacon now +ota self | id print this firmware's EndF (body/image size, base_hash) +ota folder | fold [on|off] attach/detach an external .mota folder (host daemon) ; bare = list +ota config | cfg | set [autofetch|autoinstall|checkpoint] ... show/set persisted policy +ota key | keys [add|rm ] trusted signer allowlist ; bare = list ota dev ... bring-up helpers (stage/recv/serve/verify) ``` diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md new file mode 100644 index 00000000..76d0a2bb --- /dev/null +++ b/docs/ota_user_guide.md @@ -0,0 +1,222 @@ +# Updating your node over the air (OTA) — user guide + +This guide is for **node operators**: how to update your MeshCore device's firmware over the radio, in +plain language. No cables, no programmer — your node can download a new firmware from a neighbour and +install it. (For the technical wire format, see [the OTA protocol spec](ota_protocol.md).) + +> **Is my node supported?** OTA works on **ESP32** boards (e.g. Heltec V3) and on the **RAK4631** (nRF52, +> which needs the special MeshCore bootloader). Other boards build fine but can't self-update yet. + +--- + +## The important part first: it's safe + +- **Nothing installs by itself.** Your node can *discover* and *download* an update in the background, but + it only **installs** when you say so (unless you deliberately turn on auto-install — see below). +- **Bad downloads can't sneak in.** Every piece of the firmware is checked against a cryptographic + fingerprint as it arrives, and the whole image is verified again before install. A corrupt or tampered + download is rejected, not installed. +- **You choose who to trust.** Updates can be *signed* by their author. You can tell your node to only + auto-install firmware signed by keys you've added. +- **It won't disrupt your mesh.** OTA traffic is always the **lowest priority** — your node only spends + spare airtime on it. Messages and routing always come first; a busy node simply updates later. Think of + it as *"eventually upgradable."* +- **It can recover.** If an install ever fails, the node falls back to a safe recovery mode (you can + re-flash a known-good firmware over USB) — it won't be left bricked. + +--- + +## How to talk to your node + +Connect to your node's **console** — usually a USB serial terminal at **115200 baud** (or whatever tool +you already use to manage the node). You type `ota ...` commands and the node replies in plain words. + +The commands have short, friendly names (and most accept aliases, so you don't have to remember exact +spelling): type **`ota help`** any time to see the list, or just **`ota`** for a status summary. + +--- + +## Common tasks + +### 1. See what I'm running and whether anything is going on + +``` +ota status +``` + +Shows your current firmware version, your node's update "target" (its hardware/role id), and whether a +download is in progress. + +### 2. Find updates available near me + +``` +ota ls +``` + +Your node asks around and lists the firmware updates other nodes nearby are offering, in plain words — +each with a **number**, its version, whether it's a full image or a small delta, how many nodes have it, +and how recently it was seen. For example: + +``` +Updates nearby (2 src) — `ota get <#>` to download: + 1) v1.2.3 delta 3 nodes 5s + 2) v1.2.0 full 1 node 12s [downloading] +``` + +Run it again after a few seconds — discovery happens in the background, so the list fills in. Nothing is +downloaded yet; this is just looking around. (`ota neighbors` / `ota updates` also work.) + +### 3. Download an update + +Pick one from the list by its **number**: + +``` +ota get 1 +``` + +The node starts fetching it in the background, **at low priority**, a piece at a time — possibly from +several neighbours at once. Check progress any time with `ota status` (you'll see it climb, e.g. +`download: downloading 120/525 (23%)`). You can keep using your node normally meanwhile. + +To **stop** a download you no longer want: + +``` +ota cancel +``` + +### 4. Install a downloaded update + +Once `ota status` shows the download is **ready to install**: + +``` +ota install +``` + +The node verifies the firmware one last time, and if everything checks out it installs it and **reboots +into the new version**. If the check fails, it tells you why and does **not** install. (If you haven't +added the signer's key, an unsigned/untrusted image will only install with this explicit command — never +automatically.) + +After it reboots, run `ota status` to confirm the new version. + +### 5. If something goes wrong + +- A download that stalls or gets interrupted just **resumes** later, or you can `ota cancel` and try again. +- If an **install** fails, the node won't boot a broken image — it lands in **recovery mode**: + - **RAK4631 / nRF52:** it appears as a USB drive; drag a known-good firmware `.uf2` onto it to recover. + - **ESP32:** it keeps the previous firmware in the other slot and rolls back. +- When in doubt, you can always re-flash over USB the normal way. + +--- + +## Optional: let it update automatically + +By default your node only *discovers* updates — it won't download or install on its own. If you want more +automation (e.g. for a remote node you can't easily reach), you can opt in. These settings are saved. + +``` +ota config autofetch any # auto-DOWNLOAD any compatible update for this node (still won't install) +ota config autofetch signed # auto-download only signed updates +ota config autofetch off # back to manual (default) + +ota config autoinstall trusted # auto-INSTALL a downloaded update IF it's signed by a key you trust +ota config autoinstall off # never auto-install (default) + +ota config # show the current settings +``` + +Recommended for most people: leave both **off** and update by hand. Use `autoinstall trusted` only once +you've added the signer's key (next section) and you trust them to push updates unattended. + +--- + +## Optional: only trust updates from specific people + +If you'll use auto-install, tell your node which signing keys to trust. The firmware author shares their +**public** key (a hex string); you add it: + +``` +ota key add # trust this signer +ota key list # show trusted signers +ota key rm # stop trusting one +``` + +Only updates signed by a trusted key are eligible for auto-install. Manual `ota install` still lets you +install anything yourself, on your own responsibility. + +--- + +## Sharing updates with others (advanced) + +### Relay a folder of firmware from a computer + +If your node is connected to a computer (e.g. a gateway on a Raspberry Pi), it can **hand out** a whole +folder of firmware files to the mesh — without storing them itself. Useful for seeding a new release to a +remote area. + +1. Put the firmware files (`.mota` files — see below) in a folder on the computer. +2. Run the helper daemon, pointing it at your node's USB port and the folder: + ``` + pip install pyserial + ./tools/mota/mota_seeder.py --port /dev/ttyACM0 --dir ./my_firmware/ -v + ``` + It turns the relay on for you and then answers the node's requests. Your node now advertises those + updates to neighbours, who can `ota get` them like any other. (Details: [tools/mota/README.md](../tools/mota/README.md).) + +To turn it off, stop the daemon (or run `ota folder off` on the node). `ota folder` on its own lists what +your node is currently offering. + +### Everyone helps share + +You don't have to be a gateway to help. Once **any** node finishes downloading an update, it automatically +offers it to *its* neighbours too. So a new firmware spreads outward node-to-node, instead of everyone +hammering the one node that had it first — and no node is ever overloaded, because all of this stays +lowest-priority. + +--- + +## Where firmware files come from + +OTA distributes **`.mota`** files — a packaged, verifiable firmware image (full image or a small "delta" +that only contains what changed). You get them by: + +- **Downloading a build.** This fork publishes a rolling **`dev-latest`** release on GitHub with the + current firmware for many boards, each accompanied by a `.full.mota` and a tiny `.delta.mota`. Grab the + one for your board to test. +- **Building your own** with the `mota` packaging tool — see [tools/mota/README.md](../tools/mota/README.md) + (this is for people distributing updates, not everyday operators). + +--- + +## Quick reference + +| I want to… | Command | +|---|---| +| List all commands | `ota help` | +| See my firmware + any download | `ota status` (or just `ota`) | +| Find updates nearby | `ota ls` | +| Download update #1 | `ota get 1` | +| Cancel a download | `ota cancel` | +| Install a finished download | `ota install` | +| Turn on auto-download | `ota config autofetch any` | +| Turn on auto-install (trusted only) | `ota config autoinstall trusted` | +| Trust a signer | `ota key add ` | +| Relay a folder (gateway) | `ota folder on` + the seeder daemon | +| List what I'm offering | `ota folder` | + +(Older names still work too: `neighbors`/`updates` = `ls`, `pull` = `get`, `applydelta`/`apply` = `install`, `drop`/`stop` = `cancel`.) + +--- + +## A few terms + +- **Firmware** — the software running your node. Updating it can add features or fix bugs. +- **`.mota`** — a packaged firmware update file, with built-in integrity checks. +- **Target** — your node's hardware + role identity. Your node only auto-fetches updates built for the + same target, so it won't grab firmware meant for a different board. +- **Delta** — a small update containing only the changes from your current firmware (faster to send than a + full image). Your node rebuilds the complete firmware from it and verifies the result before installing. +- **Signed** — the update carries the author's cryptographic signature, so you can verify who made it. + +For the full technical details (the file format and the radio protocol), see +[the OTA protocol spec](ota_protocol.md). diff --git a/src/helpers/ota/MotaContainer.cpp b/src/helpers/ota/MotaContainer.cpp index c27c084a..d2133b0b 100644 --- a/src/helpers/ota/MotaContainer.cpp +++ b/src/helpers/ota/MotaContainer.cpp @@ -45,7 +45,8 @@ static bool parse_manifest_fields(ByteReader& r, uint32_t signed_off, MotaManife if (!r.ok) return false; if (out.block_size_log2 == 0 || out.block_size_log2 > 24 || out.payload_size == 0) return false; out.block_count = (out.payload_size + out.block_size() - 1) / out.block_size(); - return out.block_count != 0; + // block_idx is uint16 on the wire; capping here also keeps block_count*4 (leaves length) from overflowing. + return out.block_count != 0 && out.block_count <= 0xFFFFu; } bool mota_parse(const uint8_t* buf, uint32_t len, MotaManifest& out) { diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 9b20ec39..3a73ff3d 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -28,9 +28,42 @@ static char fstate_char(OtaManager::FetchState s) { } } -static const char* codec_name(uint8_t c) { - return c == CODEC_FULL ? "full" : (c == CODEC_DETOOLS_SEQUENTIAL ? "seq" - : (c == CODEC_DETOOLS_INPLACE ? "inpl" : "?")); +// For users, the only distinction that matters is full image vs. delta (which delta codec is internal). +static const char* codec_kind(uint8_t c) { return c == CODEC_FULL ? "full" : "delta"; } + +// A plain-language word for the fetch state (shown in `ota status`). +static const char* state_word(OtaManager::FetchState s) { + switch (s) { + case OtaManager::IDLE: return "idle"; + case OtaManager::WANT_MANIFEST: return "starting"; + case OtaManager::FETCHING: return "downloading"; + case OtaManager::COMPLETE: return "ready to install"; + case OtaManager::FAILED: return "failed"; + default: return "?"; + } +} + +// Decode the packed fw_version (MAJOR<<24 | MINOR<<16 | PATCH<<8 | pre) into "v1.2.3" (or ".pre"). +static void ver_str(char* out, size_t cap, uint32_t v) { + unsigned maj = v >> 24, min = (v >> 16) & 0xFF, pat = (v >> 8) & 0xFF, pre = v & 0xFF; + if (pre) snprintf(out, cap, "v%u.%u.%u.%u", maj, min, pat, pre); + else snprintf(out, cap, "v%u.%u.%u", maj, min, pat); +} + +// Match the first word of `a` against any of the '|'-separated names (so commands have intuitive aliases +// and short forms); on a match, point `*rest` at the argument text. Keeps the dispatch table readable. +static bool is_cmd(const char* a, const char* names, const char** rest) { + size_t tlen = 0; while (a[tlen] && a[tlen] != ' ') tlen++; + for (const char* s = names; *s; ) { + const char* d = s; while (*d && *d != '|') d++; + if ((size_t)(d - s) == tlen && tlen && strncmp(a, s, tlen) == 0) { + const char* r = a + tlen; while (*r == ' ') r++; + if (rest) *rest = r; + return true; + } + s = (*d == '|') ? d + 1 : d; + } + return false; } // The everyday OTA surface is BitTorrent-shaped: `ota` shows what you're holding (your running firmware @@ -45,54 +78,68 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board if (*a != 0 && *a != ' ') return false; while (*a == ' ') a++; OtaContext& c = ota_ctx(); + const char* rest = a; // ---- raw / internal primitives, tucked under `ota dev ...` ---- - if (strncmp(a, "dev", 3) == 0 && (a[3] == 0 || a[3] == ' ')) { - const char* d = a + 3; while (*d == ' ') d++; - return handle_dev(d, reply, c); + if (is_cmd(a, "dev", &rest)) { + return handle_dev(rest, reply, c); } + // ---- help: list the commands in plain words (aliases in parentheses) ---- + if (is_cmd(a, "help|?|h", &rest)) { + snprintf(reply, 160, + "OTA: status | ls=find updates | get <#>=download | install | cancel | announce | self | " + "folder | config | key. Try `ota ls`."); + // ---- inventory dashboard: running fw (self), the one fetch session, serving state ---- - if (*a == 0 || strncmp(a, "status", 6) == 0) { + } else if (*a == 0 || is_cmd(a, "status|st", &rest)) { SelfFwInfo fi; bool s = ota_self_firmware(fi); char selfhx[9]; if (s && fi.valid) mesh::Utils::toHex(selfhx, fi.body_hash, 4); else strcpy(selfhx, "?"); OtaManager::FetchState fs = c.manager.fetchState(); - char midhx[9]; strcpy(midhx, "-"); - if (fs != OtaManager::IDLE) mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4); - unsigned age = (fs != OtaManager::IDLE && c.session_started_ms) ? (unsigned)((millis() - c.session_started_ms) / 1000) : 0; - sprintf(reply, "OTA tgt=%08X fw=%s | self:%s%uK | sess:%c %u/%u mid=%s age=%us | serv:%s keys=%u", - (unsigned)board.getOtaTargetId(), selfhx, s ? "full " : "?", - (unsigned)((s ? fi.image_len : 0) / 1024), fstate_char(fs), - (unsigned)c.manager.blocksHave(), (unsigned)c.manager.blocksTotal(), - midhx, age, c.serving ? "on" : "off", (unsigned)c.allow.count()); + char dl[80]; + if (fs == OtaManager::IDLE) { + strcpy(dl, "no download"); + } else { + char midhx[9]; mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4); + unsigned have = (unsigned)c.manager.blocksHave(), tot = (unsigned)c.manager.blocksTotal(); + unsigned pct = tot ? (unsigned)((uint64_t)have * 100 / tot) : 0; + unsigned age = c.session_started_ms ? (unsigned)((millis() - c.session_started_ms) / 1000) : 0; + snprintf(dl, sizeof dl, "download: %s %u/%u (%u%%) id=%s %us", state_word(fs), have, tot, pct, midhx, age); + } + snprintf(reply, 160, "OTA | this fw %s (%uK) | %s | serving:%s (%u) | trusted keys:%u | target %08X", + selfhx, (unsigned)((s ? fi.image_len : 0) / 1024), dl, + c.serving ? "on" : "off", (unsigned)c.manager.servedCount(), + (unsigned)c.allow.count(), (unsigned)board.getOtaTargetId()); // ---- what's available around me (catalogued from beacons + OTA_HAVE), best/most-recent first ---- - } else if (strncmp(a, "neighbors", 9) == 0 || strncmp(a, "nbrs", 4) == 0) { + } else if (is_cmd(a, "neighbors|nbrs|updates|ls|n", &rest)) { // Kick a fresh round of catalog queries (async — rows arrive over the next seconds); render what we - // have now. The reply buffer is 160 B (serial / one LoRa packet for remote-admin) so writes are bounded. + // have now in plain words. The reply buffer is 160 B (serial / one LoRa packet for remote-admin), so + // writes are bounded and extra rows collapse to "+N more". c.manager.queryAll(); const int CAP = 160; - int n = snprintf(reply, CAP, "nbrs #:mid t=tgt codec seed age *=cur (src=%u)", (unsigned)c.manager.sourceCount()); + int n = snprintf(reply, CAP, "Updates nearby (%u src) — `ota get <#>` to download:", + (unsigned)c.manager.sourceCount()); const uint8_t* cur = (c.manager.fetchState() != OtaManager::IDLE) ? c.manager.fetchManifestId() : nullptr; uint32_t now = millis(); int shown = 0, more = 0; for (uint8_t i = 0; i < c.manager.catalogCount(); i++) { const OtaManager::CatRow* h = c.manager.catalogRow(i); - if (CAP - n < 60) { more++; continue; } - char midhx[9]; mesh::Utils::toHex(midhx, h->mid, 4); + if (CAP - n < 40) { more++; continue; } bool on = cur && memcmp(cur, h->mid, 4) == 0; uint32_t age = (now - h->last_ms) / 1000; if (age > 99999) age = 99999; - n += snprintf(reply + n, CAP - n, "\n %d:%s t=%08X %s seed=%u %us%s", shown + 1, midhx, - (unsigned)h->target_id, codec_name(h->codec), (unsigned)h->n_seeders, - (unsigned)age, on ? "*" : ""); + char ver[20]; ver_str(ver, sizeof ver, h->fw_version); + n += snprintf(reply + n, CAP - n, "\n %d) %s %s %u node%s %us%s", shown + 1, ver, + codec_kind(h->codec), (unsigned)h->n_seeders, h->n_seeders == 1 ? "" : "s", + (unsigned)age, on ? " [downloading]" : ""); shown++; } if (more && n < CAP) snprintf(reply + n, CAP - n, "\n +%d more", more); - if (shown == 0) strcpy(reply, "nbrs: none yet — sources beacon periodically; re-run in a few s (queried now)"); + if (shown == 0) strcpy(reply, "No updates seen yet — re-run `ota ls` in a few seconds (just asked around)."); // ---- start fetching a specific catalogued mOTA (by list index or manifest_id) ---- - } else if (strncmp(a, "pull", 4) == 0 && (a[4] == 0 || a[4] == ' ')) { - const char* p = a + 4; while (*p == ' ') p++; - if (*p == 0) { strcpy(reply, "usage: ota pull <#|mid8> (see `ota neighbors`)"); return true; } + } else if (is_cmd(a, "pull|get|download", &rest)) { + const char* p = rest; + if (*p == 0) { strcpy(reply, "usage: ota get <#> (see the numbers in `ota ls`)"); return true; } const OtaManager::CatRow* sel = nullptr; uint8_t mid[4]; if (*p == '#' || (p[0] >= '1' && p[0] <= '9' && (p[1] == 0 || p[1] == ' '))) { // index among catalogue int idx = atoi(*p == '#' ? p + 1 : p); @@ -101,7 +148,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board for (uint8_t i = 0; i < c.manager.catalogCount(); i++) if (memcmp(c.manager.catalogRow(i)->mid, mid, 4) == 0) { sel = c.manager.catalogRow(i); break; } } - if (!sel) { strcpy(reply, "ERR no such neighbor (see `ota neighbors`)"); return true; } + if (!sel) { strcpy(reply, "ERR no such update (see the numbers in `ota ls`)"); return true; } if (c.apply_pending) { strcpy(reply, "ERR busy applying"); return true; } uint8_t selmid[4]; uint32_t seltgt = sel->target_id; memcpy(selmid, sel->mid, 4); // sel may move on reset c.manager.reset_session(); c.fetch_store.clear(); @@ -110,7 +157,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board sprintf(reply, "OK pulling mid=%s target=%08X (low priority)", midhx, (unsigned)seltgt); // ---- discard the current session (e.g. a stalled old fetch) to free the slot ---- - } else if (strncmp(a, "drop", 4) == 0) { + } else if (is_cmd(a, "drop|cancel|stop", &rest)) { OtaManager::FetchState fs = c.manager.fetchState(); char midhx[9]; strcpy(midhx, "-"); if (fs != OtaManager::IDLE) mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4); @@ -120,19 +167,19 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board // ---- broadcast our tiny beacon so peers discover us. If not already serving, set up flash-backed // self-serve first (so we're a real, fetchable source of our own running firmware). ---- - } else if (strncmp(a, "announce", 8) == 0) { + } else if (is_cmd(a, "announce|adv", &rest)) { if (!c.serving) c.serving = ota_serve_self(c, 0); c.manager.announce(); sprintf(reply, "OK beacon sent (serving=%s)", c.serving ? "self fw" : "nothing"); // ---- running firmware identity (compare against a delta's base_hash) ---- - } else if (strncmp(a, "self", 4) == 0) { + } else if (is_cmd(a, "self|id", &rest)) { SelfFwInfo fi; if (!ota_self_firmware(fi) || !fi.valid) { strcpy(reply, "ERR no EndF (firmware lacks the trailer?)"); return true; } char hx[17]; mesh::Utils::toHex(hx, fi.body_hash, 8); sprintf(reply, "self body=%u image=%u base_hash=%s", (unsigned)fi.body_len, (unsigned)fi.image_len, hx); - } else if (strncmp(a, "applydelta", 10) == 0) { + } else if (is_cmd(a, "install|apply|applydelta", &rest)) { // Apply the fetched update. Destructive (reflashes + reboots) and GATED, not interactive (no "type // yes" round-trip — unreliable over LoRa): refuse unless the fetch is COMPLETE, then the apply path // validates in order (payload hash -> built-for-this-firmware -> signature/trust) and returns the @@ -151,8 +198,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board // ---- external folder relay: advertise + serve `.mota` from a host daemon over the seeder UART, so the // node hosts MANY images (any architecture) it doesn't hold in flash. Trustless (fetchers verify). -- - } else if (strncmp(a, "folder", 6) == 0) { - const char* p = a + 6; while (*p == ' ') p++; + } else if (is_cmd(a, "folder|fold", &rest)) { + const char* p = rest; if (strncmp(p, "on", 2) == 0) { #if defined(OTA_FOLDER_SERIAL) if (!c.serving) c.serving = ota_serve_self(c, 0); // keep serving our own fw alongside the folder @@ -176,8 +223,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board } // ---- policy config (persisted via NodePrefs). conservative defaults: autofetch/autoinstall off ---- - } else if (strncmp(a, "config", 6) == 0) { - const char* p = a + 6; while (*p == ' ') p++; + } else if (is_cmd(a, "config|cfg|set", &rest)) { + const char* p = rest; if (strncmp(p, "autofetch ", 10) == 0) { const char* v = p + 10; uint8_t pol = strncmp(v, "any", 3) == 0 ? OtaManager::AUTOFETCH_ANY @@ -204,24 +251,28 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board (unsigned)c.manager.checkpoint_blocks(), (unsigned)c.allow.count()); } - // ---- trusted signer allowlist (security config; persisted) ---- - } else if (strncmp(a, "key add ", 8) == 0) { - uint8_t pub[32]; - if (mesh::Utils::fromHex(pub, 32, a + 8) && c.allow.add(pub)) { c.config_dirty = true; strcpy(reply, "OK key added (saved)"); } - else strcpy(reply, "ERR key"); - } else if (strncmp(a, "key list", 8) == 0) { - int n = sprintf(reply, "keys=%u:", (unsigned)c.allow.count()); - for (uint8_t i = 0; i < c.allow.count() && n < 140; i++) { - char hx[17]; mesh::Utils::toHex(hx, c.allow.get(i), 8); - n += sprintf(reply + n, " %s", hx); + // ---- trusted signer allowlist (security config; persisted): `ota key add|rm ` / `ota key` lists ---- + } else if (is_cmd(a, "key|keys", &rest)) { + const char* p = rest; + if (strncmp(p, "add ", 4) == 0) { + uint8_t pub[32]; + if (mesh::Utils::fromHex(pub, 32, p + 4) && c.allow.add(pub)) { c.config_dirty = true; strcpy(reply, "OK key added (saved)"); } + else strcpy(reply, "ERR key"); + } else if (strncmp(p, "rm ", 3) == 0 || strncmp(p, "remove ", 7) == 0) { + uint8_t pub[32]; const char* h = p + (p[0] == 'r' && p[1] == 'm' ? 3 : 7); + if (mesh::Utils::fromHex(pub, 32, h) && c.allow.remove(pub)) { c.config_dirty = true; strcpy(reply, "OK removed (saved)"); } + else strcpy(reply, "ERR"); + } else { // bare `ota key` (or `key list`) -> show them + int n = snprintf(reply, 160, "trusted signer keys (%u):", (unsigned)c.allow.count()); + for (uint8_t i = 0; i < c.allow.count() && n < 140; i++) { + char hx[17]; mesh::Utils::toHex(hx, c.allow.get(i), 8); + n += snprintf(reply + n, 160 - n, " %s", hx); + } + if (c.allow.count() == 0) strcpy(reply, "no trusted signer keys yet (add one with `ota key add `)"); } - } else if (strncmp(a, "key rm ", 7) == 0) { - uint8_t pub[32]; - if (mesh::Utils::fromHex(pub, 32, a + 7) && c.allow.remove(pub)) { c.config_dirty = true; strcpy(reply, "OK removed (saved)"); } - else strcpy(reply, "ERR"); } else { - strcpy(reply, "ota: status|neighbors|announce|pull <#|mid>|drop|folder|config|self|applydelta|key|dev"); + strcpy(reply, "Unknown OTA command. Type `ota help`."); } return true; } diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp index 7793ef91..a85cb98c 100644 --- a/src/helpers/ota/OtaManager.cpp +++ b/src/helpers/ota/OtaManager.cpp @@ -432,10 +432,15 @@ void OtaManager::handleHave(const uint8_t* m, uint16_t n) { if (slot < 0) { slot = (_n_cat < OTA_MAX_CATALOG) ? _n_cat++ : lru; _catalog[slot] = CatRow{}; - memcpy(_catalog[slot].mid, mid, 4); memcpy(_catalog[slot].seeder0, hv.seeder_id, 4); + memcpy(_catalog[slot].mid, mid, 4); + memcpy(_catalog[slot].seeders[0], hv.seeder_id, 4); _catalog[slot].n_seeders = 1; - } else if (memcmp(_catalog[slot].seeder0, hv.seeder_id, 4) != 0 && _catalog[slot].n_seeders < 255) { - _catalog[slot].n_seeders++; // another distinct source has it + } else { + CatRow& cc = _catalog[slot]; // count DISTINCT sources (no double-count) + bool known = false; + for (uint8_t k = 0; k < cc.n_seeders; k++) + if (memcmp(cc.seeders[k], hv.seeder_id, 4) == 0) { known = true; break; } + if (!known && cc.n_seeders < OTA_CAT_SEEDERS) memcpy(cc.seeders[cc.n_seeders++], hv.seeder_id, 4); } CatRow& c = _catalog[slot]; c.target_id = target; c.fw_version = fwver; c.codec = codec; c.flags = flags; c.last_ms = _now_ms; @@ -465,7 +470,7 @@ void OtaManager::startFetch(const uint8_t* mid, uint32_t target) { memcpy(_fid, mid, 4); _rng = (rd_u32(_seeder_id) ^ rd_u32(_fid)) | 1u; // per-node block-pick/jitter sequence (distinct per node) _fstate = WANT_MANIFEST; - _mf_total = 0; _mf_mask = 0; _mf_len = 0; // fresh manifest reassembly + _mf_total = 0; _mf_mask = 0; _mf_len = 0; _mf_retries = 0; // fresh manifest reassembly GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4); uint8_t b[16]; emit(b, encode_get_manifest(b, sizeof(b), gm), false); @@ -497,6 +502,7 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) { // a block must fit our reassembly buffer (and be non-empty) — reject an oversized block_size up front if (bs == 0 || bs > OTA_MAX_BLOCK || payload_size == 0) { _fstate = FAILED; return; } uint32_t bc = (payload_size + bs - 1) / bs; + if (bc > 0xFFFFu) { _fstate = FAILED; return; } // block_idx is uint16 on the wire — can't address more memcpy(_froot, mf + 20, 4); uint32_t leaves_off = 8 + mfl; @@ -576,7 +582,12 @@ bool OtaManager::resumeStaged(const uint8_t* want_mid) { return true; } _fstate = FETCHING; // resume fetching the holes - requestMissing(); + // De-sync the first REQ exactly like a fresh fetch, so a coordinated reboot (whole site power-cycle) + // doesn't make every resuming node REQ in lockstep. loop() fires the first REQ once the hold elapses. + _rng = (rd_u32(_seeder_id) ^ rd_u32(_fid)) | 1u; + _req_hold_at = _now_ms + (rngNext() % OTA_REQ_SPREAD_MS); + _peer_req_block = 0xFFFFFFFFu; _peer_req_at = 0; + _loop_last_have = _have; _loop_last_mask = _reasm_mask; // "no progress yet" -> loop will request after the hold return true; } @@ -726,7 +737,9 @@ void OtaManager::loop() { sendQuery(_pq_seeder, _pq_digest, 0); // unfiltered: one broadcast HAVE serves everyone } if (_fstate == WANT_MANIFEST) { - // the MANIFEST reply may have been lost on a marginal link — retry GET_MANIFEST + // the MANIFEST reply may have been lost on a marginal link — retry GET_MANIFEST, but give up after a + // cap so an unreachable mid doesn't pin the single fetch slot (or emit) forever. + if (++_mf_retries > OTA_MANIFEST_MAX_RETRY) { _fstate = FAILED; return; } GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4); uint8_t b[16]; emit(b, encode_get_manifest(b, sizeof(b), gm), false); diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index 43a9569e..f55e72d9 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -42,6 +42,9 @@ typedef bool (*ServeReadFn)(void* ctx, uint32_t off, uint8_t* buf, uint32_t len) #ifndef OTA_MF_MAXFRAG #define OTA_MF_MAXFRAG 4 // max manifest fragments (a signed v2 manifest is ~2) #endif +#ifndef OTA_MANIFEST_MAX_RETRY +#define OTA_MANIFEST_MAX_RETRY 20 // give up (FAILED) after this many GET_MANIFEST retries — frees the slot +#endif #ifndef OTA_MAX_SOURCES #define OTA_MAX_SOURCES 12 // heard OTA sources (beacon senders) tracked (LRU); ~12 B each #endif @@ -204,12 +207,15 @@ public: void on_message(const uint8_t* msg, uint16_t len); // feed one received OTA message void loop(); // drive fetch (re-request missing blocks) - // Drop the current fetch session back to IDLE (so a fresh `ota pull` / advert starts a new one). + // Drop the current fetch session back to IDLE (so a fresh `ota pull` / advert starts a new one). Also + // stops re-seeding a previously-completed download — callers clear the staging store right after, so the + // re-seed view would otherwise advertise a mota we can no longer serve. void reset_session() { - _fstate = IDLE; _have = 0; _req_count = 0; + _fstate = IDLE; _have = 0; _req_count = 0; _mf_retries = 0; _reasm_block = 0xFFFFFFFFu; _reasm_mask = 0; _reasm_need = 0; _awaiting_proof = false; _loop_last_have = 0; _loop_last_mask = 0; _mf_total = 0; _mf_mask = 0; _mf_len = 0; + unserveFetched(); } FetchState fetchState() const { return _fstate; } @@ -218,13 +224,14 @@ public: const uint8_t* fetchManifestId() const { return _fid; } // --- discovery catalog (for `ota neighbors`): mOTAs heard around us via OTA_HAVE, deduped by mid --- + static const uint8_t OTA_CAT_SEEDERS = 4; // distinct sources tracked per catalog row (for "N nodes have it") struct CatRow { uint8_t mid[4]; uint32_t target_id, fw_version; uint8_t codec, flags; - uint8_t seeder0[4]; // first source that advertised this mid - uint8_t n_seeders; // distinct sources advertising it (saturates) — "N nodes have it" - uint32_t have_max; // best block-count any source reported (== total when a full copy exists) + uint8_t seeders[OTA_CAT_SEEDERS][4]; // distinct sources advertising this mid (deduped; capped) + uint8_t n_seeders; // count of the above (capped at OTA_CAT_SEEDERS) — "N+ nodes have it" + uint32_t have_max; // best block-count any source reported (== total when a full copy exists) uint32_t last_ms; }; uint8_t catalogCount() const { return _n_cat; } @@ -319,7 +326,8 @@ private: uint16_t _loop_last_mask = 0; // fragment-level stall detection in loop() uint8_t _reasm_buf[OTA_MAX_BLOCK]; // multi-fragment manifest reassembly (a signed v2 manifest exceeds one packet) - uint8_t _mf_buf[256]; + uint8_t _mf_buf[OTA_MF_MAXFRAG * OTA_MF_FRAG]; // sized to the fragment cap so no valid manifest is silently dropped + uint16_t _mf_retries = 0; // GET_MANIFEST retries while WANT_MANIFEST (give up after a cap) uint8_t _mf_total = 0; // frag_total of the manifest being reassembled (0 = none) uint16_t _mf_mask = 0; // received manifest-fragment bitmap uint32_t _mf_len = 0; // assembled manifest length (set by the last fragment) diff --git a/test/test_ota/test_ota_core.cpp b/test/test_ota/test_ota_core.cpp index 32e7899b..75f357f8 100644 --- a/test/test_ota/test_ota_core.cpp +++ b/test/test_ota/test_ota_core.cpp @@ -70,6 +70,24 @@ TEST(OtaParse, RejectsTampering) { EXPECT_FALSE(mota_parse(b.data(), b.size(), m)); } +// block_idx is a uint16 on the wire, so a manifest needing > 65535 blocks can't be addressed and must be +// rejected at parse (this also keeps block_count*4 from overflowing the leaves-length computation). +TEST(OtaParse, RejectsTooManyBlocks) { + auto manifest = [](uint32_t payload_size, uint8_t bsl) { // minimal unsigned-full manifest (93 B) + std::vector m(93, 0); + m[0] = MOTA_FORMAT_VER; m[1] = MFLAG_FULL; m[2] = 0x12; + m[15] = payload_size; m[16] = payload_size >> 8; m[17] = payload_size >> 16; m[18] = payload_size >> 24; + m[19] = bsl; + return m; + }; + MotaManifest mm; + auto over = manifest(65536u * 1024u, 10); // 65536 blocks -> rejected + EXPECT_FALSE(mota_parse_manifest(over.data(), over.size(), mm)); + auto ok = manifest(65535u * 1024u, 10); // 65535 blocks -> allowed + EXPECT_TRUE(mota_parse_manifest(ok.data(), ok.size(), mm)); + EXPECT_EQ(mm.block_count, 65535u); +} + TEST(OtaMerkle, RootMatchesVectorAndLeaves) { MotaManifest m; ASSERT_TRUE(mota_parse(MOTA_VEC, MOTA_VEC_LEN, m)); @@ -752,6 +770,71 @@ TEST(OtaTransfer, RejectsIncompatibleCodec) { g_q.clear(); } +// Encode a 1-row OTA_HAVE from a specific seeder, carrying have_count (Phase-2 awareness). +static uint16_t make_have_row(uint8_t* buf, uint16_t cap, const uint8_t mid[4], uint32_t target, + uint32_t fwver, uint8_t codec, uint8_t flags, + const uint8_t seeder[4], uint16_t have_count) { + uint8_t row[OTA_HAVE_ROW_BYTES]; + memcpy(row, mid, 4); + row[4]=target; row[5]=target>>8; row[6]=target>>16; row[7]=target>>24; + row[8]=fwver; row[9]=fwver>>8; row[10]=fwver>>16; row[11]=fwver>>24; + row[12]=codec; row[13]=flags; + row[14]=(uint8_t)(have_count & 0xFF); row[15]=(uint8_t)(have_count >> 8); + HaveMsg hv; memcpy(hv.seeder_id, seeder, 4); memset(hv.set_digest, 0, 4); + hv.frag_idx=0; hv.frag_total=1; hv.n_rows=1; hv.rows=row; + return encode_have(buf, cap, hv); +} + +// Catalog accounting: "N nodes have it" must count DISTINCT seeders (a repeated HAVE from one node must +// not inflate it), and have_max tracks the best progress any source reported. +TEST(OtaCatalog, DistinctSeederCountAndHaveCount) { + OtaManager m; SendTo none{&m}; m.begin(SIM_TARGET_ID, sim_send, &none); + uint8_t b[64]; uint8_t mid[4]={9,9,9,9}; + uint8_t s1[4]={1,0,0,0}, s2[4]={2,0,0,0}; + m.on_message(b, make_have_row(b, sizeof b, mid, SIM_TARGET_ID, 0x01020300, CODEC_FULL, 0, s1, 5)); + m.on_message(b, make_have_row(b, sizeof b, mid, SIM_TARGET_ID, 0x01020300, CODEC_FULL, 0, s1, 7)); // same seeder + ASSERT_EQ(m.catalogCount(), 1); + EXPECT_EQ(m.catalogRow(0)->n_seeders, 1); // counted once despite two HAVEs + EXPECT_EQ(m.catalogRow(0)->have_max, 7u); // max progress seen + m.on_message(b, make_have_row(b, sizeof b, mid, SIM_TARGET_ID, 0x01020300, CODEC_FULL, 0, s2, 3)); // new seeder + EXPECT_EQ(m.catalogRow(0)->n_seeders, 2); + EXPECT_EQ(m.catalogRow(0)->have_max, 7u); // still the max, not overwritten by the lower one + g_q.clear(); +} + +// An unanswered GET_MANIFEST must not pin the fetch slot forever: after OTA_MANIFEST_MAX_RETRY ticks with +// no manifest, the session gives up (FAILED) so a new pull can take the slot. (Lowest-priority + bounded.) +TEST(OtaTransfer, ManifestGiveUpAfterRetries) { + g_q.clear(); + OtaManager client; OtaStoreRam<4096> store; SendTo to_none{&client}; + client.begin(SIM_TARGET_ID, sim_send, &to_none); + client.set_fetch_store(&store); + uint8_t mid[4]={7,7,7,7}; + client.pull(mid, SIM_TARGET_ID); // no server -> stuck WANT_MANIFEST + EXPECT_EQ(client.fetchState(), OtaManager::WANT_MANIFEST); + for (int i = 0; i < OTA_MANIFEST_MAX_RETRY + 2; i++) { g_clk += 5000; client.set_clock(g_clk); client.loop(); g_q.clear(); } + EXPECT_EQ(client.fetchState(), OtaManager::FAILED); +} + +// Re-seeding a completed download must stop when the session is dropped (the staging store is cleared right +// after), so the node never advertises a mota it can no longer serve. +TEST(OtaSwarm, ReSeedStopsAfterDrop) { + g_q.clear(); + OtaManager server, client; OtaStoreRam<4096> store; + SendTo to_client{&client}, to_server{&server}; + server.begin(0, sim_send, &to_client); + client.begin(SIM_TARGET_ID, sim_send, &to_server); + client.set_fetch_store(&store); + client.set_autofetch(OtaManager::AUTOFETCH_ANY); + ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN)); + server.announce(); + pump(client); + ASSERT_EQ(client.fetchState(), OtaManager::COMPLETE); + EXPECT_EQ(client.servedCount(), 1); // re-seeding the completed download to peers + client.reset_session(); + EXPECT_EQ(client.servedCount(), 0); // dropped -> stops advertising it +} + // --- detools delta decode (vendored detools C decoder, CRLE-only build) ---------------------- // Mirrors the device apply path (src/helpers/ota/OtaApply.cpp): base read via from_read/from_seek, // patch streamed via patch_read, output written via to_write. Proves the on-device delta apply uses