Expand post-merge regressions and document qualification gaps

This commit is contained in:
mikecarper committed 2026-10-17 21:41:47 -07:00
1 parent 80023f79e0
commit a4a0b97c3f
20 files changed
+536 -74

No files matched your search

+21 -8
View File
@@ -753,14 +753,27 @@ Optionally set a WPA2 password for the setup AP at build time with
`-D WEBCONFIG_AP_PASSWORD='"yourpassword"'`.
#### Modes and authentication
- **Setup AP**: unauthenticated. Trust is based on physical proximity to the
open/PSK AP. Only the SoftAP interface serves the API -- on `start webconfig
ap` the STA is explicitly disassociated so the API is **not** exposed on the
LAN the node was attached to.
- **LAN**: requires the admin password (same one used for remote CLI admin).
Sessions use a cookie with a sliding idle expiry (default 20 minutes); five
failed logins from one address lock that address for 30 seconds. Failed
logins from all addresses together are capped at six, then one per 6 seconds.
- **Setup AP**: first-time provisioning with no saved WiFi SSID does not require
an admin login. With a saved SSID, both a recovery AP and `start webconfig ap`
require the existing admin password, when configured, before settings can be
changed. Joining the AP does not grant permission to replace that password.
Only the SoftAP interface serves the setup API, including while saved-network
recovery retries the station connection. `WEBCONFIG_AP_PASSWORD` protects
joining the WiFi network separately from the portal's admin login.
- **LAN**: requires the configured admin password (same one used for remote CLI
admin). Images deliberately configured without an admin password retain their
no-password policy. Sessions use a cookie with a sliding idle expiry (default
20 minutes).
When an admin login is required, `/api/login` applies the same limits on LAN and
the recovery AP. Five failed logins from one address lock that address for 30
seconds after its fifth failure. A shared budget allows a burst of six failures
across all addresses and refills one allowance every 6 seconds. When it is
exhausted, even correct credentials receive HTTP 429 until an allowance returns.
A successful login clears only that address's failure count, not the shared
budget. The [room browser](docs/room_services.md#local-wi-fi-chat-on-esp32) has a
separate password budget; signing in through one endpoint does not clear the
other's failures.
> **Security note:** the open setup AP transports WiFi/MQTT credentials over
> plain HTTP. Provision on a trusted, non-public frequency/location, set
+14 -4
View File
@@ -154,10 +154,20 @@ fresh device with no saved SSID:
4. Select **Save & Reboot**.
5. After reboot, check `get wifi.status` and `get mqtt.status`.
The setup AP is unauthenticated unless the firmware was built with
`WEBCONFIG_AP_PASSWORD`. It uses plain HTTP, so provision it in a trusted
location. When WebConfig is running on the normal LAN, repeater and room-server
builds require the node's admin password.
First-time provisioning with no saved SSID does not require a portal admin
login. A setup AP opened with a saved SSID, including saved-network recovery,
requires the existing admin password before changing settings. Repeater and
room-server builds also require that password on the normal LAN; deliberately
passwordless images retain their no-password policy. `WEBCONFIG_AP_PASSWORD`
controls access to the WiFi network separately. The portal uses plain HTTP, so
use a trusted network.
Admin login limits apply on both LAN and recovery AP: five failures lock the
failing address for 30 seconds after its fifth failure. All addresses also share
a budget of six failures, refilling one allowance every 6 seconds; while empty,
even correct credentials receive HTTP 429. A success clears only its own
address's count. The [room browser](room_services.md#local-wi-fi-chat-on-esp32)
uses a separate password budget; a WebConfig session does not bypass it.
On an expanded FULL profile with no saved SSID, this automatic setup AP has an
absolute 30-minute window. Browser activity or a phone left associated with the
+44 -21
View File
@@ -7,10 +7,12 @@
The partition-migration bridge lets a supported ESP32 node move from a small
dual-OTA layout to a verified larger dual-OTA layout without cable flashing or
a full-chip erase. It fits a legacy 1.25 MiB slot. The Wi-Fi version exposes an
updater for the Full image after migration. The LoRa version preserves a
verified old OTA receiver so it can fetch the Full image into the expanded
inactive slot. On 8/16 MiB flash the bridge restores identity before handing
off; the 4 MiB route keeps identity staged until the Full image's first boot.
updater for the Full image after migration. The older identity-only LoRa
handoff preserves a verified old OTA receiver so it can fetch the Full image
into the expanded inactive slot. On 8/16 MiB flash that bridge restores identity
before handing off; its 4 MiB route keeps identity staged until the Full image's
first boot. Configuration-preserving Partition Expanders instead receive the
Full image themselves after restoring identity and supported settings.
The distinct USB version performs verified identity/configuration restoration
before the host writes the final app. It keeps application USB CDC disabled
@@ -42,6 +44,30 @@ These are the supported migration plans. The 8/16 MiB tables retain more
filesystem storage and a coredump partition; their application slots are not
the absolute maximum permitted by the physical flash capacity.
## Bridge Wi-Fi credentials
Join `MeshCore-Migrate` using the admin password saved on the old node. This
applies to the Wi-Fi bridge and the recovery AP of LoRa bridges and Partition
Expanders. The firmware stores at most 15 password characters; the bridge can
use a saved password of **8 to 15 printable ASCII characters** for WPA2.
If the saved password is shorter than 8 characters, contains other characters,
or cannot be read, the bridge uses the published fallback `meshcore-migrate`.
Missing saved preferences, including a node still using an unsaved built-in
default, also cause fallback. Anyone nearby who knows that published password
can then join and upload firmware when the updater is enabled. A saved default
or other public admin password is also usable, so choose a strong, nonpublic
password before migration. On the old node, use `password <new password>` and
verify that it remains valid after a normal reboot before uploading the bridge.
The Wi-Fi bridge and configuration-preserving Partition Expanders restore the
saved password before starting their AP in the expanded layout. The older
identity-only LoRa handoff does not restore saved settings, so its Wi-Fi
recovery AP after the table change normally uses the fallback. Follow the exact
route in the package README. If a device remembers `MeshCore-Migrate` from an
earlier bridge, forget that saved network or update its password before joining.
Bridge packages built before this credential change use `meshcore-migrate`.
## Ordinary Full releases and installation
Ordinary ESP32 node releases use Full for each exact board and firmware role.
@@ -212,9 +238,11 @@ inclusion does **not** override either check.
Every listed board/role has Wi-Fi and LoRa bridge recipes. Heltec V4 and XIAO
S3 WIO repeaters retain their board-specific bridge names; the other entries
use matching original-ESP32, ESP32-S3, or ESP32-C3 chip-family bridges. LoRa
still requires a verified same-target old receiver, and 4 MiB boards require
the slot-B-only sequence below. A 4 MiB Full build offers 1,984 KiB per slot.
use matching original-ESP32, ESP32-S3, or ESP32-C3 chip-family bridges. The older
identity-only LoRa handoff requires a verified same-target old receiver, and
its 4 MiB route requires the slot-B-only sequence below. Configuration-preserving
Partition Expanders use the route described in their package README instead.
A 4 MiB Full build offers 1,984 KiB per slot.
The packager verifies both the Full application's slot fit and its mOTA
staging overhead. It supports at most 4096 blocks of 2 KiB for the Full
transfer, and records the seeder proof-scratch requirement in the manifest.
@@ -234,7 +262,8 @@ followed by its 64-byte private key), stages them in an NVS namespace that is
unchanged by the source and target layouts, then changes the table. The Wi-Fi
bridge and 8/16 MiB LoRa bridge restore the staged value into expanded SPIFFS,
read it back byte-for-byte, and only then clear the NVS staging record. On the
4 MiB LoRa route, the Full application's first boot performs this restoration.
4 MiB identity-only LoRa handoff, the Full application's first boot performs
this restoration.
The Wi-Fi bridge also stages and verifies its supported saved configuration:
node name and preferences, radio profiles, ACL and login replay state, region
@@ -284,27 +313,21 @@ Use `--board xiao-s3-wio` with the recipe above. Its Wi-Fi bridge is a
legacy-slot application image; its Full image keeps the old repeater's exact
LoRa OTA target ID. Upload `wifi-bridge.bin` from the ZIP through the legacy
node's existing Wi-Fi browser updater.
Then join `MeshCore-Migrate` and wait for **Expanded layout ready**. Its Wi-Fi
password is the admin password saved on the old node, so only someone who can
already administer the node can join and upload firmware. Upload
`full-application.bin` at `/update`.
Then join `MeshCore-Migrate` using the node's saved admin password, following
the [shared credential and fallback rules](#bridge-wi-fi-credentials), and wait
for **Expanded layout ready**. Upload `full-application.bin` at `/update`.
Do not upload an `-merged.bin`: merged images include
bootloader and table offsets for cable flashing, not browser OTA.
WPA2 needs a password of at least 8 printable ASCII characters. If the saved
admin password is shorter, uses other characters, or was never saved (the node
still runs on its built-in default), the bridge falls back to
`meshcore-migrate`. That password is published here, so anyone nearby could
then join and upload firmware. Before uploading the bridge, set an admin
password of 8 to 15 characters with `password <new password>`. A LoRa
first-stage bridge does not carry saved settings into the expanded layout, so
its Wi-Fi recovery page after the table change normally uses the fallback.
`xiao_s3_partition_legacy_seed` is a disposable-hardware test image only. It
models the legacy table and an identity file; it is not a deployable repeater.
## LoRa-only migration of a listed role
This section describes the older identity-only LoRa handoff. A package whose
LoRa first stage is a configuration-preserving Partition Expander, including
the Waveshare observer packages, uses its package README's self-hosted route.
The exact old board/role must already support MeshCore LoRa mOTA and have a valid
EndF image identity. The LoRa bridge checks the old app's target ID and body
hash before any partition-table write; an unsupported or damaged receiver is
+13 -7
View File
@@ -20,16 +20,22 @@ python3 -B scripts/build_esp32_partition_migration.py \
The ZIP contains the Wi-Fi bridge, LoRa bridge, and exact-target Full image.
Upload its `wifi-bridge.bin` through the
legacy browser Wi-Fi updater. Rejoin `MeshCore-Migrate` using the node's saved
admin password as the Wi-Fi password (see the shared guide for the published
fallback); when it reports **Expanded layout ready**, upload the ZIP's
admin password as the Wi-Fi password (see the shared
[credential and fallback rules](esp32_wifi_partition_migration.md#bridge-wi-fi-credentials));
when it reports **Expanded layout ready**, upload the ZIP's
`full-application.bin` at `/update`.
Do not use an `-merged.bin` in either browser uploader. Merged images contain
bootloader and table offsets for cable flashing. The bridge preserves the
private identity; other SPIFFS-backed settings may be recreated after the
Full image starts.
private identity and supported saved configuration, including node name,
radio profiles, ACL and login replay state. Files outside the supported
preservation set may need to be recreated after the Full image starts.
For an existing `heltec_v4_repeater` that already supports LoRa mOTA, use the
separate LoRa migration package described in the shared guide. It preserves
the old repeater across the partition change so the full image can also be
installed over LoRa without reaching the board's Wi-Fi network.
ZIP's `lora-bridge.mota` with the identity-only LoRa handoff described in the
shared guide. It preserves the old repeater across the partition change so the
Full image can also be installed over LoRa without reaching the board's Wi-Fi
network.
That route does not restore saved settings; its post-expansion Wi-Fi recovery
AP normally uses the published fallback. The optional Partition Expander
route instead restores supported settings and retains the saved admin password.
+2 -2
View File
@@ -736,7 +736,7 @@ for `ENABLE_OTA` builds using the shared raw-radio scheduler, except primary
ESP-NOW builds; `-DMESH_ENABLE_OTA_KISS_DIRECT=0` explicitly disables it. Enabling
the build feature alone does not bypass the runtime negotiation/session gates.
The separate host seeder's safe probe/serve examples and lab TX lock are in
[`tools/lora_ota/README.md`](../tools/lora_ota/README.md).
[`tools/lora_ota/README.md`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/lora_ota/README.md).
### 8.6 Temporary-radio and transfer boundary
@@ -954,7 +954,7 @@ deployment runner temporarily uses `0.3` on managed relays.
`ota folder on` command; that command confirms it can host and advertise the folder. A **KISS modem will not
work with `motatool serve --serial`**: KISS firmware exposes a TNC/KISS frame interface, not the MeshCore CLI
and `mota-seeder` request/response transport. The separate
[`mota_kiss_direct.py` source](../tools/lora_ota/README.md) implements that full KISS interface.
[`mota_kiss_direct.py` source](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/lora_ota/mota_kiss_direct.py) implements that full KISS interface.
An ESP32 WiFi companion or FULL ESP32 role with active WiFi is the alternative
source connection: use its dedicated seeder port with `motatool serve --tcp <host>:5001`.
An nRF52 Full Companion can instead pair with a phone or Linux host, subscribe
+6
View File
@@ -12,6 +12,12 @@ matching bootloader profiles. Earlier preview versions mentioned below
describe compatibility/migration history, not the current recommended download.
Full Companion is a MOTA source and normally updates itself over USB.
Before a Companion upgrade, export its contacts when possible. ESP32, RP2040
and STM32 can retain complete legacy records ahead of an interrupted final
record when no backup is reported present; nRF52 has separate migration rules.
See [legacy interrupted contact saves](research/companion_contact_cache.md#legacy-interrupted-contact-saves)
for the recovery limits and next-save behavior.
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
+36
View File
@@ -67,6 +67,36 @@ The path checksum also rejects
a cold path changed underneath a live handle; it does not add a new checksum
to the legacy ESP32 on-disk format.
### Legacy interrupted contact saves
ESP32, RP2040 and STM32 Companions can load the complete records before a
partial final record in a legacy `/contacts3` file. This includes cached and
uncached ESP32 builds. The file must contain at least one whole 152-byte
record, and every whole record must be readable.
The partial record is ignored; loading does not truncate the source. The next
successful ordinary contact save writes the complete loaded contacts through
the platform's existing transaction writer.
On ESP32 and RP2040, this salvage applies only when the presence check reports
`/contacts3.bak` absent. A readable complete backup takes precedence over the
incomplete primary. An unreadable backup keeps the existing bounded retry and
recovery behavior; the partial primary is not used to declare that backup
expendable. ESP32 also defers salvage when backup presence is uncertain.
RP2040's filesystem API does not distinguish a metadata I/O error from a
missing file. STM32 does not select a contact backup.
This recovers a readable prefix from an interrupted older in-place save, not
arbitrary corruption. Legacy records have no file checksum, and the existing
route validation and repair still apply. A file with no complete record, or
one that remains unreadable after recovery retries, can still be discarded by
contact-only recovery. Failure to remove damaged data leaves contacts volatile
and explicit saves unavailable, without erasing identity or unrelated settings.
nRF52 retains its separate legacy-size validation and paged-store migration;
this partial-tail salvage does not apply to nRF52. The existing
[ExtraFS recovery guide](../test-results/nrf52_companion_storage_recovery.md)
describes its separate mount and metadata recovery boundaries.
## Shared secrets and power
Both platforms reuse the 16 most recent secrets from RAM. Cache misses use a
@@ -188,6 +218,8 @@ change that layout or enable WiFi OTA.
```sh
python3 -B test/test_contact_cache.py
python3 -B test/test_companion_uncached_storage.py
python3 -B test/test_companion_contact_partial_tail.py
python3 -B test/test_companion_contact_persistence_contract.py
python3 -B test/test_companion_contact_stream_contract.py
python3 -B test/test_nrf52_extrafs_contract.py
@@ -202,6 +234,10 @@ adapters. They exercise 350 routes, eviction, snapshot rollback, page
migration, identity changes, read/write/rename faults, handle exhaustion,
packet release on a failed path read, and recovery after a simulated reset
between file renames. They do not simulate a physical flash power cut.
The partial-tail tests also check readable-prefix preservation, backup
precedence, deferred normalization on save and reload on ESP32, RP2040 and
STM32 using filesystem adapters. Real flash power-loss and full-volume
qualification remains separate from these host checks.
`tools/hil/contact_cache_serial_stress.py` checks all 350 paths over USB,
replaces them with a different generation, and optionally exercises secret
+20
View File
@@ -548,6 +548,26 @@ G2/Wio L1 selections in this matrix and all four STM32 Companions were rebuilt
at `29787c0b2`.
The final 95 related host tests and artifact-hash checks pass.
### PR21 contact-recovery size recheck, 2026-10-18 UTC
The combined PR17–21 merge tree `a40b1f662` was linked with
`v1.18.0.1-halo-keymind-cascade-dev`, `usa-cascade-fixed`, and the Cascade
profile. All four unchanged 229,376-byte application limits and runtime RAM
gates passed. These are build results, not on-device qualification.
| USB Companion | Application bytes | Free application bytes | Static RAM bytes |
| --- | ---: | ---: | ---: |
| RAK3x72 | 228,744 | 632 | 40,220 |
| Wio-E5 Mini | 228,760 | 616 | 40,252 |
| Wio-E5 Dev | 228,884 | 492 | 40,212 |
| Tiny Relay | 228,940 | 436 | 40,220 |
The earlier 508/452-byte margins above are historical checkpoints. Continue
linking every small STM32 Companion at full release-version length: these
margins do not justify relaxing the application, filesystem or runtime-RAM
gates. Physical storage and boot checks remain in the
[hardware checklist](../test-results/hardware_validation_checklist.md#pr1721-follow-up-qualification-2026-10-18-utc).
Margins remain narrow: RAK4631 Full Companion has 932 flash bytes free;
canonical/unified RAK4631 Full Repeater have 548/540 bytes above their RAM
budgets. W12/G2/G3 Full MQTT have 2,208/2,176/1,816 bytes above their budgets.
+25 -5
View File
@@ -225,11 +225,13 @@ read/write sessions remain transient; password-created administrators remain
retained. The existing permission byte is unchanged.
Guest-password logins do not allocate new durable login replay records, so
guest churn cannot fill the bounded replay store. Any existing record from a
previous privileged role still applies and is advanced. After reboot or
transient client eviction, an identity without such a record can have captured
guest login and message traffic accepted again. Assign an explicit read/write
role with `setperm <key> 2` when durable login replay protection is required.
guest churn cannot fill the bounded replay store. Existing records remain
authoritative and are advanced, including guest-password records created by
older firmware. This change does not reclaim space in an already full
128-identity replay store. After reboot or transient client eviction, an
identity without such a record can have captured guest login and message
traffic accepted again. Assign an explicit read/write role with `setperm <key> 2`
when durable login replay protection is required.
Room-post deduplication and per-sender post replay timestamps remain in RAM:
captured old posts can also be accepted after a legitimate newer login restores
a lost session.
@@ -408,6 +410,24 @@ notices; only administrators can publish or delete them. Each operation
rechecks credentials and bans. Use private Wi-Fi: the local page uses HTTP,
and network access alone does not grant administrator permission.
Room-browser password checks share one budget across all browsers and tokens.
Five newly counted wrong passwords exhaust a 30-second window measured from its
first counted failure. Until that window ends, every request with a nonempty
password is refused, including correct guest/admin passwords and polls from an
already connected admin tab. Correct passwords do not refund failures, and
refused requests do not extend the window. The last four wrong passwords are
remembered; repeats among those four do not count again. A wrong nonempty
password still counts when read-only access admits it as a guest. An empty
password never consumes this budget and remains usable
for explicitly enabled public read-only access.
This limit belongs to `/api/room`. The WebConfig admin login at `/api/login`
has its own address limits and shared budget for the same admin password;
neither endpoint clears or bypasses the other's limit. The room page does not
require a WebConfig session, and this browser limit does not change radio
logins. If password requests are blocked, wait for the window to end or use
the local console to manage the node.
The browser generates a secret token and its own moderation identity. It
does not import a radio private key or inherit a claimed radio key's ACL role.
The Access and history panel shows the complete browser identity for banning.
@@ -14,6 +14,86 @@ when its log identifies the physical device, firmware artifact, artifact hash,
command result, and cold/warm boot outcome. Do not infer success from a tool's
exit code when the tool has a documented false-success mode.
## PR17–21 follow-up qualification, 2026-10-18 UTC
The merged source checkpoint is `0a24279a3`. This audit adds host regressions,
operator documentation, and a C6 GPIO capability fix; it is not a hardware
qualification. No device was flashed or contacted during the audit.
Completed host coverage includes guest replay-store capacity and live-client
eviction; per-address/shared WebConfig limits and room-password windows across
`millis()` rollover; existing WebConfig sessions during login throttling;
migration credential precedence and failed-identity upload denial; generated
package instructions; and cached/uncached partial-contact recovery. The real
Chromium room and WebConfig fixtures use mocked HTTP responses, so they do not
prove the actual network/firmware interaction below.
The merge was compiled for `Heltec_v3_room_server`,
`heltec_v4_companion_radio_usb`, `esp32_s3_4mb_partition_migrator`, and all four
small STM32 USB Companions at release-version length. Follow-up builds cover
`Xiao_rp2040_companion_radio_usb` and Arduino 3's
`LilyGo_Tlora_C6_room_server_`. The C6 build initially exposed unavailable
global deep-sleep GPIO hold APIs; the helper now follows the SDK's per-pad
capability, with a fixture that omits the unavailable declarations. The CI
`cross-platform-smoke` job retains both RP2040 and C6 build checks.
Additional migration builds passed for `esp32_8mb_partition_migrator`,
`esp32_c3_4mb_partition_migrator`,
`heltec_v4_partition_migrator_lora_repeater`, `heltec_v4_partition_expander`,
and `esp32_s3_4mb_partition_migrator_usb`, including their application-size and
runtime-RAM gates. This covers representative chip families and Wi-Fi,
identity-only LoRa, Expander and USB code paths, rather than every board alias.
### Remaining physical acceptance checks
Record the exact clean source revision, firmware SHA-256, device identity,
observed result, and cold/warm boot outcome for each check. Record password
classes and success/failure, never the passwords themselves.
- [ ] **Migration credentials:** on cable-recoverable test hardware, join with
a distinct saved 8–15-character printable ASCII admin password. Reject the
wrong password and published fallback. Repeat with unusable/missing saved
credentials and verify the documented fallback. Forget or update the host's
saved `MeshCore-Migrate` network between different credentials.
- [ ] **Migration handoff:** test both source OTA slots and the distinct
identity-only LoRa and configuration-preserving Expander routes. Verify
recovery-AP credentials after expansion, actual browser upload, unchanged
identity/supported settings, final version, and cold-boot persistence.
Exercise supported interruption/retry points on sacrificial hardware; the
partition-table sector write is not atomic.
- [ ] **WebConfig:** use two distinct source addresses on LAN and on a
saved-network recovery AP. One address's five failures must not lock the
other out; exhausting the shared budget must reject even a correct new
login. An existing authorized session must remain usable. Verify recovery
at the six-second refill and 30-second address-lock boundaries. Clients
sharing one address also share its lock.
- [ ] **Room browser:** use separate real browser tabs/tokens for five
different wrong passwords. Nonempty credentials, including an already-open
admin tab, must wait for the shared window to expire; public empty-password
read-only access must remain available when enabled. Repeated stale-tab
passwords must not consume a new failure on every poll. Check visible error,
retry and unsaved-edit behavior against the real board.
- [ ] **Guest sessions:** churn transient guest identities, then admit a new
admin and an explicitly assigned writer. Power-cycle and verify privileged
login replay floors remain authoritative. Document the expected captured
guest-traffic replay boundary after transient state is lost; no durable
guest record is added and an already-full older store is not reclaimed.
- [ ] **Contact storage:** on ESP32 cached/uncached, RP2040 and STM32, exercise
legacy interrupted saves, full-volume/read failures and complete-backup
precedence. Require complete records to survive, the partial source to stay
unchanged until ordinary save, and identity/settings to remain untouched.
RP2040's `exists()` cannot distinguish absence from every metadata error;
include actual storage faults. nRF52 uses separate legacy migration rules
and is not covered by the partial-tail salvage change.
- [ ] **C6 terminal sleep:** measure the selected radio/FEM output levels
through terminal deep sleep and wake/reset, and verify that runtime/light
sleep does not latch unrelated live pins. A successful build and simulated
pad retention do not establish physical latch or current behavior.
Remaining automated follow-ups are browser throttle-error/retry/draft cases
and an executed configuration-restore-to-AP credential handoff. The host AP
fixture supplies pre/post-restore files; it does not run an entire migration.
## ESP32 web console and browser OTA release gates
The [2026-10-05 V4/XIAO checkpoint](hardware_validation_esp32_web_console_ota_2026-10-05.md)
+1
View File
@@ -7,6 +7,7 @@
Completed validation reports, experiments, and test guides are collected here. Older page addresses redirect to these copies. Results are tied to the hardware and firmware versions named in each report; they do not automatically qualify a newer build.
- [1.17.1.8 Preview 1 qualification and raw reports](preview-1.17.1.8/index.md)
- [PR17–21 remaining hardware acceptance checks](hardware_validation_checklist.md#pr1721-follow-up-qualification-2026-10-18-utc)
## Radio and RF
+31 -4
View File
@@ -487,6 +487,8 @@ ready**, it listens on the saved primary radio profile and automatically
fetches only this package's exact-target Full application. Serve
`full-application.mota` on that profile; do not upload it to the browser.
The bridge's status page reports the receiver profile and block progress.
Its recovery AP uses the restored saved admin password when usable; the
shared credential rules above explain the fallback.
Keep only the intended Full build for this target on the seeder during the
migration; the bridge pins the hardware/role target, not an individual MID.
If installed on an already-expanded board, it returns to a verified Full image
@@ -508,10 +510,8 @@ installing the bridge; the bridge does not itself repeat normal traffic.
`Started:` URL returned by the command (the port can vary by old build).
2. Keep power stable. Join `MeshCore-Migrate` using the node's saved admin
password as the Wi-Fi password, open `http://192.168.4.1/`, and wait for
**Expanded layout ready**. If that password is shorter than 8 characters
or was never saved, the bridge falls back to the published
`meshcore-migrate`, which anyone nearby can use; set a longer admin
password before step 1.
**Expanded layout ready**. Follow the shared credential and fallback rules
above if the saved password is unusable.
3. At that page's `/update`, upload `full-application.bin`.
"""
usb_instructions = f"""## Host-managed USB route
@@ -555,6 +555,8 @@ it does not reboot into an old receiver with reset settings.
3. Serve this package's `full-application.mota` on that saved profile. The
expander automatically fetches only this exact successor target and installs
it after complete verification. Wi-Fi recovery remains available.
Its AP uses the restored saved admin password when usable, with the fallback
described above.
The final image has {full_mota_blocks} blocks; the seeder needs at least
{full_mota_blocks * 4} bytes of proof scratch. Confirm that a capable seeder
@@ -608,6 +610,11 @@ the old receiver's compiled default radio profile and arrange a seeder on
that profile for stage two; a remote node that cannot hear the seeder cannot
finish its LoRa migration.
This identity-only LoRa handoff does not restore saved settings. Its
post-expansion Wi-Fi recovery AP normally uses `meshcore-migrate`, the published
fallback, even when the old node had a usable saved admin password. This differs
from the configuration-preserving Partition Expander route.
1. Make `lora-bridge.mota` available from a compatible mOTA seeder. On the old
node use `ota ls`, `ota pull <id> flash`, then `ota install` after the
complete download. A manual install handles an equal-version bridge.
@@ -685,6 +692,26 @@ the table-sector erase/write can still require cable recovery. Do not use this
on an inaccessible node without accepting that risk and testing a sacrificial
example of the same board first.
## Bridge Wi-Fi credentials
Join `MeshCore-Migrate` using the old node's saved admin password when it is
usable. The firmware stores at most 15 characters; use **8 to 15 printable ASCII
characters**. Before installing the bridge, set a strong, nonpublic password
with `password <new password>` and verify it survives a normal reboot.
The bridge uses the published fallback `meshcore-migrate` if the saved password
is shorter than 8 characters, contains nonprintable or non-ASCII characters,
or cannot be read. Missing saved preferences, including an unsaved built-in
default, also cause fallback. Anyone nearby who knows the fallback can join
and upload firmware when the updater is enabled. A saved default or other
public admin password remains usable; it does not protect the bridge from
someone who already knows it.
The Wi-Fi bridge restores the saved password after expansion. The LoRa and
Partition Expander sections below explain their recovery AP credentials.
Forget or update any previously saved `MeshCore-Migrate` network before joining
with a different password. Older bridge packages use the published fallback.
{wifi_instructions}
{usb_instructions}
{expander_instructions}
+19
View File
@@ -333,6 +333,25 @@ static void passwordLimitCases() {
ticks = 31000; assert(result(m, request("status", "admin-secret"), sequence++, 25)["role"] == PERM_ACL_ADMIN);
ticks = later;
}
{
// A window beginning before UINT32_MAX stays locked through rollover,
// expires at its original boundary, and permits a full new failure window.
const uint32_t later = ticks, start = UINT32_MAX - 1000u; ticks = start;
MyMesh m; enable(m); m._prefs.allow_read_only = false;
const unsigned checks = password_checks + 5;
for (uint8_t owner = 20; owner < 25; ++owner) error(m, request("status", guess("wrap-", owner).c_str()), sequence++, "incorrect", owner);
assert(password_checks == checks);
ticks = UINT32_MAX; error(m, request("status", "admin-secret"), sequence++, "too many", 25);
ticks = 0; error(m, request("status", "admin-secret"), sequence++, "too many", 25);
ticks = start + 29999u; error(m, request("status", "admin-secret"), sequence++, "too many", 25);
assert(password_checks == checks);
ticks = start + 30000u;
assert(result(m, request("status", "admin-secret"), sequence++, 25)["role"] == PERM_ACL_ADMIN);
for (uint8_t owner = 30; owner < 35; ++owner) error(m, request("status", guess("after-wrap-", owner).c_str()), sequence++, "incorrect", owner);
assert(password_checks == checks + 6);
error(m, request("status", "admin-secret"), sequence++, "too many", 25);
ticks = later;
}
{
MyMesh m; enable(m); m._prefs.allow_read_only = false;
// Neither the guest nor the admin password refunds failed guesses.
+21
View File
@@ -96,6 +96,27 @@ int main() {
assert(login(portal, owner, "existing-secret")==200);
assert(login(portal, attacker, "existing-secret")==200);
}
// Real handler responses retain their exact boundaries when millis wraps.
{
WebConfigServer portal; const uint32_t start=UINT32_MAX-1000u; now_ms=start;
for (int i = 0; i < 5; i++) assert(login(portal, attacker, "guess")==401);
now_ms=0;
assert(login(portal, attacker, "existing-secret")==429 && last_reply==kLocked);
now_ms=start+29999u;
assert(login(portal, attacker, "existing-secret")==429 && last_reply==kLocked);
now_ms=start+30000u;
assert(login(portal, attacker, "existing-secret")==200);
}
{
WebConfigServer portal; const uint32_t start=UINT32_MAX-3000u; now_ms=start;
for (uint32_t address = 10; address < 16; address++) assert(login(portal, address, "guess")==401);
now_ms=0;
assert(login(portal, owner, "existing-secret")==429 && last_reply==kShared);
now_ms=start+5999u;
assert(login(portal, owner, "existing-secret")==429 && last_reply==kShared);
now_ms=start+6000u;
assert(login(portal, owner, "existing-secret")==200);
}
// LAN requests without a client object share one key and do not crash.
{
WebConfigServer portal; portal._mode=WebConfigServer::MODE_LAN;
+25
View File
@@ -84,6 +84,31 @@ int main() {
assert(t.check(A, now) == ALLOW);
assert(t.check(B, now) == ALLOW);
}
// A lock started just before UINT32_MAX remains active across rollover and
// expires at exactly thirty seconds, then gives the client a fresh count.
{
Tracker t; const uint32_t start = UINT32_MAX - 1000u;
fail(t, A, start, kClientFails);
assert(t.check(A, UINT32_MAX) == CLIENT_LOCKED);
assert(t.check(A, 0) == CLIENT_LOCKED);
assert(t.check(B, 0) == ALLOW);
assert(t.check(A, start + kClientLockMs - 1) == CLIENT_LOCKED);
fail(t, A, start + kClientLockMs, kClientFails);
assert(t.check(A, start + kClientLockMs) == CLIENT_LOCKED);
}
// The shared allowance also refills across rollover, once per six seconds.
{
Tracker t; const uint32_t start = UINT32_MAX - 3000u;
uint32_t ip = 1;
for (int i = 0; i < kSharedBudget; i++) fail(t, ip++, start);
assert(t.check(ip, UINT32_MAX) == SHARED_LOCKED);
assert(t.check(ip, 0) == SHARED_LOCKED);
assert(t.check(ip, start + kSharedRefillMs - 1) == SHARED_LOCKED);
fail(t, ip++, start + kSharedRefillMs);
assert(t.check(ip, start + 2 * kSharedRefillMs - 1) == SHARED_LOCKED);
fail(t, ip++, start + 2 * kSharedRefillMs);
assert(t.check(ip, start + 2 * kSharedRefillMs) == SHARED_LOCKED);
}
// Failures recorded without a check saturate instead of wrapping.
{
Tracker t; uint32_t now = 100000;
+25 -1
View File
@@ -22,7 +22,11 @@ struct Callback {
};
struct WCLock { explicit WCLock(int) {} };
struct WiFiMock { int softAPIP() const { return 4; } } WiFi;
struct Client { int local_ip=4; int localIP() const { return local_ip; } uint32_t getRemoteAddress() const { return 0; } };
struct Client {
int local_ip=4; uint32_t remote=0;
int localIP() const { return local_ip; }
uint32_t getRemoteAddress() const { return remote; }
};
struct Header { String cookies; const String& value() const { return cookies; } };
struct AsyncWebServerResponse {
int status; std::map<std::string, std::string> headers;
@@ -106,5 +110,25 @@ int main() {
// password management. Keep that explicit no-password deployment policy.
portal._initial_setup=false; portal.callback.node.admin_password[0]=0;
assert(portal.checkAuth(&attacker)); portal.handleLogin(&attacker); assert(attacker.status==200);
// A spent login budget blocks new logins without revoking an existing
// authenticated session, on either the saved-network AP or the LAN.
for (int mode : {WebConfigServer::MODE_SETUP, WebConfigServer::MODE_LAN}) {
WebConfigServer active; active._mode=mode;
AsyncWebServerRequest owner; owner.socket.remote=100;
owner.password("existing-secret"); active.handleLogin(&owner);
assert(owner.status==200);
const std::string token=active._session_token;
owner.header.cookies=std::string("wcs=")+token;
for (uint32_t address = 1; address <= 6; address++) {
AsyncWebServerRequest wrong; wrong.socket.remote=address;
wrong.password("wrong"); active.handleLogin(&wrong); assert(wrong.status==401);
}
AsyncWebServerRequest login; login.socket.remote=200;
login.password("existing-secret"); active.handleLogin(&login);
assert(login.status==429 && token==active._session_token);
assert(active.checkAuth(&owner));
active.checkConfigPost(&owner); assert(active.config_accepted);
assert(token==active._session_token);
}
puts("WebConfig recovery authentication regression checks passed");
}
+79 -19
View File
@@ -125,6 +125,9 @@ static void whole_record_read_errors_keep_bounded_recovery() {
static void a_complete_backup_still_wins() {
const auto image = tailImage();
for (unsigned fault = 0; fault < 3; ++fault) {
#if defined(RP2040_PLATFORM)
if (fault == 2) continue; // Arduino-Pico exists() hides metadata errors.
#endif
tailFs() = FakeFilesystem();
tailFs().files["/contacts3"] = partial(image, 1);
tailFs().files["/contacts3.bak"] = records(image, 2);
@@ -143,32 +146,77 @@ static void a_complete_backup_still_wins() {
assert(!tailFs().files.count("/contacts3.bak"));
}
}
#if defined(ESP32_PLATFORM)
static void unknown_backup_presence_preserves_sources() {
const auto image = tailImage();
tailFs() = FakeFilesystem();
tailFs().files["/contacts3"] = partial(image, 1);
tailFs().files["/contacts3.bak"] = records(image, 2);
tailFs().files["/channels2"] = {3, 4, 5};
tailFs().files["/identity/_main.id"] = {7, 8, 9};
const auto durable = tailFs().files;
{
failMetadata(true);
TailBoot b;
b.store.loadContacts(&b.host);
assert(b.store.contactRecoveryPending() && b.host.contacts.empty());
assert(!tailSave(b) && tailFs().files == durable);
b.store.recoverContacts(&b.host); // Presence remains unknown on the retry.
assert(!b.store.contactRecoveryPending() && b.store.contactsAreVolatile());
assert(b.host.contacts.empty() && tailFs().files == durable);
failMetadata(false);
assert(!tailSave(b) && tailFs().files == durable); // Volatile for this boot.
}
TailBoot rebooted;
boot(rebooted);
assert(!rebooted.store.hasIncompleteContactLoad() && !rebooted.store.contactsAreVolatile());
checkLoaded(rebooted.host, image, 2); // The complete backup still wins.
assert(tailFs().files.at("/contacts3") == records(image, 2));
assert(!tailFs().files.count("/contacts3.bak"));
assert(tailFs().files.at("/channels2") == durable.at("/channels2"));
assert(tailFs().files.at("/identity/_main.id") == durable.at("/identity/_main.id"));
}
#endif
#endif
#if !MESH_CONTACT_CACHE
static void salvaged_primary_open_failure_is_not_absence() {
const auto image = tailImage();
filesystem = FakeFilesystem();
filesystem.files["/contacts3"] = partial(image, 1);
tailFs() = FakeFilesystem();
tailFs().files["/contacts3"] = partial(image, 1);
{
TailBoot dry; // The last read open of a healthy load opens the image to load.
dry.store.loadContacts(&dry.host);
assert(dry.host.contacts.size() == 3);
}
const unsigned load_open = filesystem.read_opens;
filesystem = FakeFilesystem();
filesystem.files["/contacts3"] = partial(image, 1);
filesystem.fail_read_open = load_open;
TailBoot b;
b.store.loadContacts(&b.host);
assert(b.store.hasIncompleteContactLoad() && b.store.contactRecoveryPending());
const auto unchanged = filesystem.files;
assert(!tailSave(b) && filesystem.files == unchanged);
b.store.recoverContacts(&b.host);
assert(!b.store.hasIncompleteContactLoad() && !b.store.contactRecoveryPending());
checkLoaded(b.host, image, 3);
}
const unsigned load_open = tailFs().read_opens;
for (bool add_backup : {false, true}) {
#if defined(STM32_PLATFORM)
if (add_backup) continue; // STM32 does not select a contact backup.
#endif
tailFs() = FakeFilesystem();
tailFs().files["/contacts3"] = partial(image, 1);
tailFs().fail_read_open = load_open;
TailBoot b;
b.store.loadContacts(&b.host);
assert(tailFs().read_opens == load_open);
assert(b.store.hasIncompleteContactLoad() && b.store.contactRecoveryPending());
assert(b.host.contacts.empty());
const auto unchanged = tailFs().files;
const auto writes = tailFs().writes;
assert(!tailSave(b) && tailFs().files == unchanged && tailFs().writes == writes);
// A backup available at the recovery boundary takes precedence over the
// salvaged prefix, even though the first selection had admitted it.
if (add_backup) tailFs().files["/contacts3.bak"] = records(image, 2);
b.store.recoverContacts(&b.host);
assert(!b.store.hasIncompleteContactLoad() && !b.store.contactRecoveryPending());
checkLoaded(b.host, image, add_backup ? 2 : 3);
if (add_backup) {
assert(tailFs().files.at("/contacts3") == records(image, 2));
assert(!tailFs().files.count("/contacts3.bak"));
} else assert(tailFs().files == unchanged);
}
}
int main() {
partial_tail_keeps_whole_records();
@@ -176,10 +224,11 @@ int main() {
whole_record_read_errors_keep_bounded_recovery();
#if !defined(STM32_PLATFORM)
a_complete_backup_still_wins();
#if defined(ESP32_PLATFORM)
unknown_backup_presence_preserves_sources();
#endif
#endif
#if !MESH_CONTACT_CACHE
salvaged_primary_open_failure_is_not_absence();
#endif
printf("''' + MARKER + r'''\n");
return 0;
}
@@ -206,7 +255,18 @@ def uncached_fixture(work):
def cached_fixture(work):
fixtures = ROOT / "test/fixtures/contact_cache"
(work / "FakeFilesystem.h").write_text((fixtures / "FakeFilesystem.h").read_text())
filesystem = (fixtures / "FakeFilesystem.h").read_text()
# Match the uncached fixture's one-shot final-load-open failure without
# changing the shared filesystem adapter used by unrelated cache tests.
for old, new in ((" unsigned false_success_rename = 0, late_rename_error = 0;\n",
" unsigned false_success_rename = 0, late_rename_error = 0;\n"
" unsigned read_opens = 0, fail_read_open = 0;\n"),
(" auto failure = read_open_failures.find(path);\n",
" if (++read_opens == fail_read_open) return File();\n"
" auto failure = read_open_failures.find(path);\n")):
assert filesystem.count(old) == 1, old
filesystem = filesystem.replace(old, new)
(work / "FakeFilesystem.h").write_text(filesystem)
source = (fixtures / "test_contact_cache.cpp").read_text()
generated = work / "partial_tail.cpp"
generated.write_text("#include <cstdio>\n" + source[:source.index("int main() {")] + CHECKS)
@@ -147,6 +147,30 @@ class Esp32MigrationRecipeTest(unittest.TestCase):
self.assertEqual("esp32_s3_8mb_partition_migrator_usb", steps[3][0][3])
self.assertEqual("esp32_s3_8mb_partition_migrator_lora", steps[4][0][3])
def test_generated_guides_explain_credentials_for_each_recovery_route(self):
for board in ("heltec-v2-room-server", "heltec-v4",
"waveshare-s3-lr1121-observer-repeater"):
with self.subTest(board=board):
guide = readme(board, BOARDS[board], "v1.18.0-test", "01234567")
credentials = " ".join(guide.split("## Bridge Wi-Fi credentials\n", 1)[1]
.split("## Wi-Fi route\n", 1)[0].split())
for rule in ("saved admin password", "8 to 15 printable ASCII characters",
"shorter than 8", "nonprintable or non-ASCII", "cannot be read",
"Missing saved preferences", "meshcore-migrate",
"verify it survives a normal reboot"):
self.assertIn(rule, credentials)
lora = " ".join(guide.split("## LoRa route", 1)[1].split())
if BOARDS[board].get("expander_bridge") == BOARDS[board]["lora_bridge"]:
self.assertIn("uses the restored saved admin password", lora)
self.assertNotIn("post-expansion Wi-Fi recovery AP normally uses", lora)
else:
self.assertIn("post-expansion Wi-Fi recovery AP normally uses `meshcore-migrate`",
lora)
if BOARDS[board].get("expander_bridge"):
expander = " ".join(guide.split("## Partition Expander route", 1)[1]
.split("## LoRa route", 1)[0].split())
self.assertIn("uses the restored saved admin password", expander)
def test_all_six_usb_chip_capacity_profiles_are_built_once_and_wifi_retained(self):
steps = build_steps(list(BOARDS), "v1.17.1.7-test", "usa-cascadia", "cascade", 4)
utilities = [command[3] for command, full in steps if not full]
@@ -111,8 +111,9 @@ esp_err_t esp_wifi_get_max_tx_power(int8_t* power) { *power = 78; return ESP_OK;
void delay(unsigned) {}
struct AsyncWebServerRequest {
void send(int, const char*, const String&) {}
void send(int, const char*, const char*) {}
std::string body;
void send(int, const char*, const String& text) { body = text.s; }
void send(int, const char*, const char* text) { body = text; }
};
struct AsyncWebServer {
std::vector<std::string> routes;
@@ -136,6 +137,7 @@ struct File {
struct Filesystem {
std::map<std::string, std::vector<uint8_t>> files;
bool mountable = true, mounted = false, seek_fails = false, short_read = false;
std::string fail_open;
unsigned begins = 0, ends = 0, opens = 0, closes = 0;
// Before migration this is the legacy filesystem: it must never be formatted.
bool begin(bool format) { assert(!format); ++begins; mounted = mountable; return mounted; }
@@ -145,7 +147,9 @@ struct Filesystem {
assert(!strcmp(mode, "r"));
File file;
auto entry = files.find(path);
if (mounted && entry != files.end()) { file.data = &entry->second; ++opens; }
if (mounted && fail_open != path && entry != files.end()) {
file.data = &entry->second; ++opens;
}
return file;
}
} SPIFFS;
@@ -235,6 +239,18 @@ void expectFallback() {
balancedMount();
}
void expectUploadDisabled(const char* expected_password) {
startServer();
assert(WiFi.ap_calls == 1 && !WiFi.open && WiFi.password == expected_password);
assert(AsyncElegantOTA.begins == 0);
AsyncWebServerRequest request;
sendHome(&request);
assert(request.body.find("Private identity recovery did not complete") != std::string::npos);
assert(request.body.find("Firmware upload is disabled") != std::string::npos);
assert(request.body.find("href='/update'") == std::string::npos);
balancedMount();
}
int main(int argc, char** argv) {
assert(argc == 2);
const int scenario = atoi(argv[1]);
@@ -288,6 +304,19 @@ int main(int argc, char** argv) {
reset(); SPIFFS.short_read = true;
SPIFFS.files["/com_prefs"] = prefs(290, "Correct-Horse9");
expectFallback();
} else if (scenario == 7) {
reset(true); identity_ready = false;
SPIFFS.files["/com_prefs"] = prefs(290, "Correct-Horse9");
expectUploadDisabled("Correct-Horse9");
reset(true); identity_ready = false;
expectUploadDisabled(kFallback);
} else if (scenario == 8) {
// Existing /com_prefs wins even when it cannot be opened. Never use a
// potentially stale /node_prefs password to bypass that file precedence.
reset(); SPIFFS.fail_open = "/com_prefs";
SPIFFS.files["/com_prefs"] = prefs(290, "Com-prefs-pass");
SPIFFS.files["/node_prefs"] = prefs(290, "Node-prefs-pass");
expectFallback();
} else {
return 2;
}
@@ -361,6 +390,12 @@ class MigrationBridgeApPasswordTests(unittest.TestCase):
def test_failed_seek_or_partial_read_falls_back(self):
self.run_scenario(6)
def test_failed_identity_restore_disables_upload_with_either_ap_credential(self):
self.run_scenario(7)
def test_unopenable_common_prefs_does_not_use_legacy_password(self):
self.run_scenario(8)
def test_operator_instructions_name_the_admin_password(self):
# Extra guard only: the instructions must not present the published
# fallback as the bridge's normal Wi-Fi password.
+12
View File
@@ -120,6 +120,18 @@ static void guestChurnCannotLockOutNewIdentities() {
CHECK(!login(room, guest, "published", 1000 + i));
CHECK(login(room, guest, "published", 2000 + i));
}
// A guest without a durable record loses its exact live floor on eviction.
// Re-admission starts another transient writer session, whose immediate
// replay is rejected without allocating a permanent replay-store slot.
const auto evicted_guest = member(0, 0x6E);
CHECK(!room.acl.getClient(evicted_guest.pub_key, PUB_KEY_SIZE));
CHECK(storedIdentities(fs) == 0);
CHECK(login(room, evicted_guest, "published", 1000));
const auto* readmitted = room.acl.getClient(evicted_guest.pub_key, PUB_KEY_SIZE);
CHECK(readmitted && readmitted->permissions == PERM_ACL_READ_WRITE
&& !readmitted->permissions_are_explicit);
CHECK(!login(room, evicted_guest, "published", 1000));
CHECK(storedIdentities(fs) == 0);
// A new Admin device and an assigned writer's first login are still admitted.
CHECK(login(room, admin, "admin-secret", 3000));
CHECK(login(room, writer, "", 3000));