Rollback-enabled nodes (CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y) boot an
OTA'd image as PENDING_VERIFY, but nothing called
esp_ota_mark_app_valid_cancel_rollback(), so every OTA reverted at the next
reset, a second OTA was refused until then, and a bad image was never
deliberately rolled back.
- main/ota_health.{c,h}: at boot, if the running image is PENDING_VERIFY,
poll for STA got-IP and >=1 CSI frame accepted by the stream sender.
Confirm after CONFIG_OTA_HEALTH_MIN_UPTIME_S (30 s) once both are in;
roll back via esp_ota_mark_app_invalid_rollback_and_reboot() if they are
not in by CONFIG_OTA_HEALTH_TIMEOUT_S (120 s from boot). VALID, UNDEFINED,
NEW and no-otadata/factory images are left alone. The decision is a pure
header function, host-tested in test/test_ota_health.c (make host_tests).
- CONFIG_OTA_HEALTH_FORCE_FAIL (test only) rolls back where it would have
confirmed, for proving rollback on hardware.
- GET /ota/status reports ota_state so durability is visible remotely.
- RUNBOOK: replace the nonexistent ota_rollback_boot_check() claim and
document the behaviour and the hardware check (2.1).
Hardware-verified on an ESP32-S3 (push, confirm, hard reset, forced
rollback) and an ESP32-C6 (push, confirm, hard reset); see ADR-379, next in
this series. Host tests: make host_tests passes, including run_ota_health.
Co-Authored-By: claude-flow <ruv@ruv.net>
MEASURED 2026-09-29 on an ESP32-S3 (v0.8.12, 16 MB): every POST /ota of a
valid 1,228,672 B image died with "stack overflow in task httpd" and
RTC_SW_CPU_RST, so no OTA had ever completed on this firmware.
- ota_update.c: set config.stack_size = CONFIG_OTA_HTTPD_STACK_SIZE (new
Kconfig, default 8192; HTTPD_DEFAULT_CONFIG gave 4096). Move the 1 KB
receive buffer off the stack into a static (handlers are serialised on
the one httpd task). Integer progress percent instead of %f.
recv_wait_timeout stays 30 s.
- Log the httpd stack high-water mark after every POST /ota and
POST /wasm/upload so the size rests on device data.
- wasm_upload.c: GET /wasm/list builds its 2 KB JSON on the heap.
- RUNBOOK §3 (over the air) records the failure and the high-water-mark
log line.
Device check after the fix (same S3): "httpd stack after POST /ota: 4900 of
8192 bytes never used", i.e. about 3.3 KB peak. The ADR with the full
hardware evidence follows in this series (ADR-379).
This overlaps PR #1594, which raises the same stack. If #1594 lands first,
drop this commit; the rest of the series does not depend on it.
Co-Authored-By: claude-flow <ruv@ruv.net>
Three fixes wrapped for the v0.6.5-esp32 release tag:
1. **`sdkconfig.defaults` adds `CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=8192`**.
The fix was already in `sdkconfig.defaults.template` (ADR-081, prevents
"stack overflow in task Tmr Svc" bootloop when adaptive_controller emits
feature_state from inside a Timer Svc callback). It was MISSING from the
canonical `sdkconfig.defaults` file used by the build, so any fresh
build picked up the 2 KiB FreeRTOS default and bootlooped on hardware.
Verified on COM7: with the fix, no panics in 30 s of operation; without
it, "***ERROR*** A stack overflow in task Tmr Svc has been detected."
followed by sustained bootloop.
2. **`ota_update.c` extracts `ota_load_psk_from_nvs()` and calls it from
both `ota_update_init()` and `ota_update_init_ex()`.** `main.c:230` uses
the `_ex` variant, but only `ota_update_init()` was loading the PSK
from NVS. Result: `s_ota_psk` stayed empty regardless of NVS contents,
so the RuView#596 fail-closed posture rejected every request — but the
diagnostic warning never printed at boot, leaving operators no signal
about why their OTA uploads were 403'ing. Verified on COM7:
W (3126) ota_update: NVS namespace 'security' not found —
OTA upload endpoint will REJECT all requests until provisioned.
Fail-closed per RuView#596.
3. **`version.txt`: 0.6.4 → 0.6.5**, paired with the v0.6.5-esp32 tag so the
firmware-ci version-guard job (RuView#505 fix-marker) stays happy.
Both validations done end-to-end on hardware (COM7, ESP32-S3 8MB,
provisioned with --edge-tier 2 to also incidentally re-verify #438 is not
reproducible on current main).
ota_check_auth() previously returned true when s_ota_psk[0] == '\0'
("permissive for dev"). A freshly-flashed node — or any node where
nobody had provisioned an OTA PSK yet — accepted attacker-controlled
firmware over plain HTTP on port 8032 from any host on the WiFi. No
Secure Boot V2, no signed-image verification, no transport encryption.
Single LAN call could brick or backdoor a node.
This was flagged in the deep security review of PR #596 but was a
PRE-EXISTING bug in main, not new code from that PR — so it stood as
a critical-severity production issue until this commit.
Fix:
- ota_check_auth() now returns false when no PSK is provisioned, with
ESP_LOGW("OTA rejected: no PSK in NVS …") at the call site so the
operator can diagnose the rejection from serial logs
- ota_update_init() ESP_LOGW message updated to surface the new posture
at boot ("upload endpoint will REJECT all requests until provisioned")
- Doc comment on ota_check_auth() rewritten to make the contract
explicit and reference the audit
The OTA HTTP server itself still starts even when no PSK is set. That
lets the operator run `provision.py --ota-psk <hex>` over USB-CDC to
write the NVS key without reflashing the firmware. The upload endpoint
just refuses every request in the meantime.
Breaking change for any deployment that depended on the unauthenticated
OTA path working out of the box. Documented in CHANGELOG under
[Unreleased] / Security so it's visible at the next release cut.
Fix-marker RuView#596-ota-fail-closed (scripts/fix-markers.json)
requires the new behaviour and forbids the old "permissive for dev"
fallback strings, so a future revert fails CI.