Expand post-merge regressions and document qualification gaps
This commit is contained in:
1 parent
80023f79e0
commit
a4a0b97c3f
20 files changed
+536
-74
No files matched your search
+21
-8
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user