mirror of
https://github.com/agessaman/MeshCore.git
synced 2026-08-28 09:44:26 +00:00
feat(provision): .bin-appended provision trailer (Phase 4, ESP32 only)
tools/append_provision.py appends [magic MCPV1\0][uint16-LE len][payload] [uint32-LE CRC32] to a built app .bin (replacing any existing trailer); esptool flashes the whole file so the trailer lands in the app partition right after the image. At boot, if neither /provision nor /provision_done exists, the firmware walks the running image's segment table to find the image end, validates magic + CRC + the provision header, and writes the payload to /provision — the existing auto-apply then runs it. 'provision' status reports trailer presence. Consequences documented in PROVISIONING.md: 'provision remove' (or erase) + reboot re-provisions from the trailer, and the trailer does not survive an OTA to the other slot. nRF52/RP2040 are explicitly unsupported (signed DFU / no UF2 side channel). CRC32 is a local zlib-compatible implementation so the firmware doesn't depend on which ROM CRC variant a given Arduino core exposes. The image- end walk was verified byte-exact against real esptool output for both Heltec_v3 build flavors.
This commit is contained in:
@@ -103,6 +103,32 @@ Between `begin` and `end`, console lines are written to `/provision` instead
|
||||
of executed. The header line is validated first and the same size/line caps
|
||||
apply. `provision end` closes the file **without** applying it.
|
||||
|
||||
**Zero-interaction first flash (ESP32 only)** — bake the file into the `.bin`:
|
||||
|
||||
```
|
||||
tools/append_provision.py firmware.bin region_defaults.txt
|
||||
```
|
||||
|
||||
This appends a small trailer (magic + length + the text file + CRC32) after
|
||||
the app image; esptool flashes the whole file, so the trailer lands in the
|
||||
app partition right behind the image. On boot, if the node has neither
|
||||
`/provision` nor the applied-marker, the firmware validates the trailer
|
||||
(CRC and provision header) and copies the payload to `/provision` — the
|
||||
normal boot auto-apply then runs it. `provision` status reports
|
||||
`trailer: present` when the running image carries one.
|
||||
|
||||
Trailer notes:
|
||||
|
||||
- Re-running the tool on an already-trailered `.bin` **replaces** the trailer.
|
||||
- `provision remove` + reboot **re-provisions from the trailer** (both the
|
||||
file and the marker are gone, so extraction runs again). The same applies
|
||||
after `erase`. This makes the trailer behave like factory defaults.
|
||||
- The trailer does not survive an OTA update: the new image is written to the
|
||||
other OTA slot without a trailer. Settings already applied persist, of
|
||||
course — only the re-provision-on-remove behavior is lost.
|
||||
- ESP32-only: nRF52 DFU zips are signed against the exact image, and RP2040
|
||||
UF2 has no equivalent side channel. Use paste capture or `fetch` there.
|
||||
|
||||
### Interaction with newer-firmware config
|
||||
|
||||
If `/mqtt_prefs` on the node was written by a newer firmware version than the
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
#include <HTTPClient.h>
|
||||
#endif
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <esp_ota_ops.h>
|
||||
#include <esp_partition.h>
|
||||
#include <esp_image_format.h>
|
||||
#endif
|
||||
|
||||
#define PROVISION_FILE "/provision"
|
||||
#define PROVISION_MARKER "/provision_done"
|
||||
#define PROVISION_MAX_SIZE 4096
|
||||
@@ -147,6 +153,124 @@ static bool provisionReadLine(File& f, char* buf, size_t buf_size, bool* truncat
|
||||
return got_any;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// .bin-appended provision trailer (Phase 4, ESP32 only)
|
||||
//
|
||||
// tools/append_provision.py appends [magic "MCPV1\0"][uint16-LE payload len]
|
||||
// [payload][uint32-LE CRC32(payload)] to a built app .bin. esptool flashes the
|
||||
// whole file, so the trailer lands in the app partition immediately after the
|
||||
// image. At boot, if neither /provision nor /provision_done exists, the trailer
|
||||
// is validated and its payload written to /provision; the normal auto-apply
|
||||
// then takes over. ESP32-only by design: nRF52 DFU zips are signed against the
|
||||
// exact image and RP2040 UF2 has no equivalent side channel.
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
|
||||
static const uint8_t PROVISION_TRAILER_MAGIC[6] = { 'M', 'C', 'P', 'V', '1', 0 };
|
||||
|
||||
// Standard CRC-32 (zlib/binascii-compatible: poly 0xEDB88320, init/final ~0).
|
||||
// Local bitwise implementation so the firmware never depends on which ROM CRC
|
||||
// variant a given Arduino core exposes; payload is <=4KB and runs once at boot.
|
||||
static uint32_t provCrc32(const uint8_t* data, size_t len) {
|
||||
uint32_t crc = 0xFFFFFFFFu;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
crc ^= data[i];
|
||||
for (int b = 0; b < 8; b++) {
|
||||
crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u)));
|
||||
}
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
// Walk the segment table of the app image in `part` to find where the flashed
|
||||
// .bin ends: header + segments, padded so the checksum is the last byte of a
|
||||
// 16-byte boundary, plus the appended SHA256 if the image carries one.
|
||||
static bool provImageEnd(const esp_partition_t* part, uint32_t* end_out) {
|
||||
esp_image_header_t hdr;
|
||||
if (esp_partition_read(part, 0, &hdr, sizeof(hdr)) != ESP_OK) return false;
|
||||
if (hdr.magic != ESP_IMAGE_HEADER_MAGIC) return false;
|
||||
if (hdr.segment_count == 0 || hdr.segment_count > ESP_IMAGE_MAX_SEGMENTS) return false;
|
||||
uint32_t off = sizeof(esp_image_header_t);
|
||||
for (int i = 0; i < hdr.segment_count; i++) {
|
||||
esp_image_segment_header_t seg;
|
||||
if (esp_partition_read(part, off, &seg, sizeof(seg)) != ESP_OK) return false;
|
||||
off += sizeof(seg) + seg.data_len;
|
||||
if (off > part->size) return false;
|
||||
}
|
||||
off = (off + 16) & ~15u; // pad; checksum byte ends the 16-byte block
|
||||
if (hdr.hash_appended == 1) off += 32; // appended SHA256 digest
|
||||
if (off > part->size) return false;
|
||||
*end_out = off;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Locate a trailer after the running image. Returns the partition offset of the
|
||||
// 8-byte trailer header (magic+len) via *off_out, or false if absent/implausible.
|
||||
static bool provFindTrailer(const esp_partition_t** part_out, uint32_t* off_out, uint16_t* plen_out) {
|
||||
const esp_partition_t* part = esp_ota_get_running_partition();
|
||||
if (part == NULL) return false;
|
||||
uint32_t off;
|
||||
if (!provImageEnd(part, &off)) return false;
|
||||
uint8_t hdr[8];
|
||||
if (off + sizeof(hdr) > part->size) return false;
|
||||
if (esp_partition_read(part, off, hdr, sizeof(hdr)) != ESP_OK) return false;
|
||||
if (memcmp(hdr, PROVISION_TRAILER_MAGIC, sizeof(PROVISION_TRAILER_MAGIC)) != 0) return false;
|
||||
uint16_t plen = (uint16_t)hdr[6] | ((uint16_t)hdr[7] << 8);
|
||||
if (plen == 0 || plen > PROVISION_MAX_SIZE) return false;
|
||||
if (off + sizeof(hdr) + plen + 4 > part->size) return false;
|
||||
*part_out = part;
|
||||
*off_out = off;
|
||||
*plen_out = plen;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool provisionTrailerPresent() {
|
||||
const esp_partition_t* part;
|
||||
uint32_t off;
|
||||
uint16_t plen;
|
||||
return provFindTrailer(&part, &off, &plen);
|
||||
}
|
||||
|
||||
// Validate the trailer (CRC + provision header) and write its payload to
|
||||
// /provision. Returns true if /provision was written.
|
||||
static bool provisionExtractTrailer(FILESYSTEM* fs) {
|
||||
const esp_partition_t* part;
|
||||
uint32_t off;
|
||||
uint16_t plen;
|
||||
if (!provFindTrailer(&part, &off, &plen)) return false;
|
||||
|
||||
uint8_t* payload = (uint8_t*)malloc(plen + 1);
|
||||
if (!payload) return false;
|
||||
bool ok = false;
|
||||
uint8_t crcb[4];
|
||||
if (esp_partition_read(part, off + 8, payload, plen) == ESP_OK &&
|
||||
esp_partition_read(part, off + 8 + plen, crcb, sizeof(crcb)) == ESP_OK) {
|
||||
uint32_t crc_read = (uint32_t)crcb[0] | ((uint32_t)crcb[1] << 8) |
|
||||
((uint32_t)crcb[2] << 16) | ((uint32_t)crcb[3] << 24);
|
||||
if (provCrc32(payload, plen) == crc_read) {
|
||||
payload[plen] = 0;
|
||||
// Same guard as fetch: only seed /provision from a plausible package.
|
||||
const char* p = (const char*)payload;
|
||||
while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++;
|
||||
if (provParseHeader(p) == PROVISION_VERSION) {
|
||||
File f = provOpenWrite(fs, PROVISION_FILE);
|
||||
if (f) {
|
||||
f.write(payload, plen);
|
||||
f.close();
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
free(payload);
|
||||
if (ok) {
|
||||
MESH_DEBUG_PRINTLN("provision: seeded /provision from app image trailer (%u bytes)", (unsigned)plen);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
#endif // ESP_PLATFORM
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// core runner
|
||||
|
||||
@@ -227,6 +351,15 @@ void CommonCLI::runProvisionFile(uint32_t sender_timestamp, char* reply) {
|
||||
bool CommonCLI::autoApplyProvisionFile(char* reply) {
|
||||
reply[0] = 0;
|
||||
if (_fs == NULL) return false;
|
||||
#ifdef ESP_PLATFORM
|
||||
// Zero-interaction first flash: if the flashed .bin carries a provision
|
||||
// trailer and this node has neither a file nor the applied-marker, seed
|
||||
// /provision from the trailer so the auto-apply below runs it. Note this
|
||||
// means 'provision remove' + reboot re-provisions from the trailer.
|
||||
if (!_fs->exists(PROVISION_FILE) && !_fs->exists(PROVISION_MARKER)) {
|
||||
provisionExtractTrailer(_fs);
|
||||
}
|
||||
#endif
|
||||
if (!_fs->exists(PROVISION_FILE) || _fs->exists(PROVISION_MARKER)) return false;
|
||||
|
||||
// Marker FIRST: if a command below crashes or resets the device mid-apply, the
|
||||
@@ -456,9 +589,14 @@ bool CommonCLI::handleProvisionCommand(uint32_t sender_timestamp, char* command,
|
||||
if (*args == 0) {
|
||||
// status
|
||||
bool marker = _fs->exists(PROVISION_MARKER);
|
||||
#ifdef ESP_PLATFORM
|
||||
const char* trailer = provisionTrailerPresent() ? "; trailer: present" : "";
|
||||
#else
|
||||
const char* trailer = "";
|
||||
#endif
|
||||
File f = provOpenRead(_fs);
|
||||
if (!f) {
|
||||
sprintf(reply, "no /provision; marker: %s", marker ? "present" : "absent");
|
||||
sprintf(reply, "no /provision; marker: %s%s", marker ? "present" : "absent", trailer);
|
||||
} else {
|
||||
uint32_t size = f.size();
|
||||
// header version lives on the first non-blank line
|
||||
@@ -474,11 +612,11 @@ bool CommonCLI::handleProvisionCommand(uint32_t sender_timestamp, char* command,
|
||||
}
|
||||
f.close();
|
||||
if (ver >= 0) {
|
||||
sprintf(reply, "/provision: %u bytes, v%d; marker: %s", (unsigned)size, ver,
|
||||
marker ? "present" : "absent");
|
||||
sprintf(reply, "/provision: %u bytes, v%d; marker: %s%s", (unsigned)size, ver,
|
||||
marker ? "present" : "absent", trailer);
|
||||
} else {
|
||||
sprintf(reply, "/provision: %u bytes, BAD HEADER; marker: %s", (unsigned)size,
|
||||
marker ? "present" : "absent");
|
||||
sprintf(reply, "/provision: %u bytes, BAD HEADER; marker: %s%s", (unsigned)size,
|
||||
marker ? "present" : "absent", trailer);
|
||||
}
|
||||
}
|
||||
} else if (strcmp(args, "show") == 0 || memcmp(args, "show ", 5) == 0) {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Append (or replace) a MeshCore provision trailer on an ESP32 app .bin.
|
||||
|
||||
The trailer is appended verbatim to the end of the built image:
|
||||
|
||||
b"MCPV1\\x00" + uint16-LE payload length + payload + uint32-LE CRC32(payload)
|
||||
|
||||
esptool flashes the whole file, so the trailer lands in the app partition right
|
||||
after the image. On first boot (no /provision, no /provision_done marker) the
|
||||
firmware validates the trailer and copies the payload to /provision, then the
|
||||
normal boot auto-apply runs it. See PROVISIONING.md.
|
||||
|
||||
ESP32-only: nRF52 DFU zips are signed against the exact image and RP2040 UF2
|
||||
has no equivalent side channel.
|
||||
|
||||
Usage:
|
||||
tools/append_provision.py firmware.bin region_defaults.txt # in place
|
||||
tools/append_provision.py firmware.bin region_defaults.txt -o out.bin
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
|
||||
MAGIC = b"MCPV1\x00"
|
||||
MAX_SIZE = 4096
|
||||
MAX_LINE = 159
|
||||
HEADER_PREFIX = b"#meshcore-provision v"
|
||||
ESP_IMAGE_MAGIC = 0xE9
|
||||
|
||||
|
||||
def die(msg):
|
||||
print(f"error: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def validate_provision(data: bytes, name: str):
|
||||
if len(data) == 0:
|
||||
die(f"{name} is empty")
|
||||
if len(data) > MAX_SIZE:
|
||||
die(f"{name} is {len(data)} bytes; the firmware caps /provision at {MAX_SIZE}")
|
||||
lines = data.split(b"\n")
|
||||
first = next((l.strip() for l in lines if l.strip()), None)
|
||||
if first is None or not first.startswith(HEADER_PREFIX):
|
||||
die(f"{name}: first non-blank line must start with '{HEADER_PREFIX.decode()}1'")
|
||||
ver = first[len(HEADER_PREFIX):].split()[0] if len(first) > len(HEADER_PREFIX) else b""
|
||||
if not ver.isdigit() or int(ver) != 1:
|
||||
die(f"{name}: unsupported provision version '{ver.decode(errors='replace')}' (expected 1)")
|
||||
for i, l in enumerate(lines, 1):
|
||||
if len(l.rstrip(b"\r")) > MAX_LINE:
|
||||
die(f"{name}: line {i} is {len(l.rstrip(b'\r'))} chars; the firmware caps lines at {MAX_LINE}")
|
||||
|
||||
|
||||
def parse_trailer_at_end(blob: bytes):
|
||||
"""Return the trailer's start offset if blob ends with a valid trailer, else None."""
|
||||
# magic(6) + len(2) + payload + crc(4); search the tail window for candidates
|
||||
window_start = max(0, len(blob) - (len(MAGIC) + 2 + MAX_SIZE + 4))
|
||||
idx = blob.rfind(MAGIC, window_start)
|
||||
while idx != -1:
|
||||
cand = blob[idx:]
|
||||
if len(cand) >= len(MAGIC) + 2 + 4:
|
||||
(plen,) = struct.unpack_from("<H", cand, len(MAGIC))
|
||||
if len(cand) == len(MAGIC) + 2 + plen + 4:
|
||||
payload = cand[len(MAGIC) + 2:len(MAGIC) + 2 + plen]
|
||||
(crc,) = struct.unpack_from("<I", cand, len(MAGIC) + 2 + plen)
|
||||
if zlib.crc32(payload) & 0xFFFFFFFF == crc:
|
||||
return idx
|
||||
idx = blob.rfind(MAGIC, window_start, idx)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0],
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="\n".join(__doc__.split("\n")[1:]))
|
||||
ap.add_argument("bin", help="built ESP32 app image (.bin)")
|
||||
ap.add_argument("provision", help="provision text file (#meshcore-provision v1 header)")
|
||||
ap.add_argument("-o", "--output", help="write result here instead of modifying BIN in place")
|
||||
ap.add_argument("--force", action="store_true",
|
||||
help="skip the ESP32 image magic-byte sanity check")
|
||||
args = ap.parse_args()
|
||||
|
||||
with open(args.bin, "rb") as f:
|
||||
blob = f.read()
|
||||
with open(args.provision, "rb") as f:
|
||||
payload = f.read()
|
||||
|
||||
if not args.force and (len(blob) == 0 or blob[0] != ESP_IMAGE_MAGIC):
|
||||
die(f"{args.bin} does not look like an ESP32 app image "
|
||||
f"(first byte is not 0x{ESP_IMAGE_MAGIC:02X}); use --force to override")
|
||||
|
||||
validate_provision(payload, args.provision)
|
||||
|
||||
existing = parse_trailer_at_end(blob)
|
||||
if existing is not None:
|
||||
print(f"replacing existing trailer at offset {existing}")
|
||||
blob = blob[:existing]
|
||||
|
||||
trailer = MAGIC + struct.pack("<H", len(payload)) + payload + \
|
||||
struct.pack("<I", zlib.crc32(payload) & 0xFFFFFFFF)
|
||||
out_path = args.output or args.bin
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(blob + trailer)
|
||||
print(f"wrote {out_path}: image {len(blob)} bytes + trailer {len(trailer)} bytes "
|
||||
f"(payload {len(payload)} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user