Add admin firmware upload support

Implement authenticated HTTPS OTA uploads with bounded streaming, image
validation, reboot coordination, and lifecycle exclusion. Add the admin
UI,
regression tests, and Phase 10 acceptance documentation.
This commit is contained in:
2026-09-18 22:22:11 +02:00
parent 4f628a4098
commit 31a22eba06
31 changed files with 1442 additions and 88 deletions
+93
View File
@@ -0,0 +1,93 @@
# Firmware upload backend host tests
Run from the repository root:
```sh
python3 tests/web_firmware_update/run.py
python3 tests/web_cookie_auth/run.py
```
The first compiles the unchanged production upload source, production auth
admission helpers/parser, and production server upload reservation/typed reboot
functions with `-Wall -Wextra -Werror` and trapping undefined-behavior checks.
It uses installed ESP-IDF image-layout headers (`IDF_PATH`, otherwise the normal
PlatformIO framework directory). It performs no firmware build or device action.
HTTP IO, private header-adapter admission, database, OTA/flash and FreeRTOS
scheduling are doubles. The existing cookie-auth suite separately exercises
production private-adapter framing/duplicate-header handling and session storage.
The 61 cases cover:
- Cookie, Origin, CSRF, admin, query/method and readiness rejection before reads.
- Raw content type, nonempty body, actual partition capacity and inactive OTA app
selection; bytewise partial image prefix and bounded/exact streaming writes.
- Wrong magic/chip/segment/descriptor/hash-header rejection before OTA erase.
- Service/identity busy, allocation/task creation and OTA begin/write/end/metadata/
boot-selection failures; handle consumption versus abort cleanup.
- Parsed SDK image length mismatch, final session revocation/currentness failure.
- EOF before/after OTA begin, stalled receive and total slow-drip deadlines.
- Reservation rejection of competing upload/typed reboot, including during
receive and boot selection; response failure at every response stage.
- Successful commit followed by delayed reboot-owner action; failed response
retains selection but does not reboot and releases reservations/resources.
These are deterministic failure-injection tests, not real SDK image-integrity,
flash, concurrent scheduler, TLS, network, power-loss or hardware test evidence.
Stop/start/rotation guard use is source-checked, not dynamically executed here.
## Parent integration contract
`POST /api/firmware`, raw `application/octet-stream`, known `Content-Length`,
existing cookie + same-origin `Origin` + `X-CSRF-Token`, admin only. Use the
existing browser CORS-mode/same-origin-credentials request pattern. No multipart,
chunked transfer, `Expect`, query parameters, signature/version policy or filename
validation. Standard ESP32-S3 application image with appended SDK SHA-256 digest
is required. A filename alone is never trusted.
`200 {"ok":true,"rebooting":true}` means SDK validation and boot selection
succeeded. After synchronous send success, the preallocated owner task waits
500 ms and calls `esp_restart()` without stopping HTTPD or retaining a request
or socket. It keeps service/identity reservations until reset. Send success is
not peer receipt. Send failure after selection schedules **no reboot**, releases
reservations, and leaves the accepted image selected for a later reset. A lost
acknowledgement is therefore uncertain; never automatically retry.
All errors use `{"error":"code"}`:
| HTTP | Codes |
| --- | --- |
| 400 | `invalid_request`, `invalid_firmware`, `firmware_incomplete` |
| 401 | `authentication_required` |
| 403 | `origin`, `csrf`, `admin_required` |
| 408 | `firmware_timeout` |
| 413 | `firmware_too_large` |
| 415 | `firmware_content_type` |
| 500 | `firmware_write_failed`, `firmware_commit_failed` |
| 503 | `unavailable`, `busy`, `firmware_unavailable`, `firmware_resources` |
Rejections with unread bodies close rather than asking HTTPD to discard the
remaining upload. Responses are JSON, no-store, nosniff and no-referrer. Errors
contain no SDK diagnostic text or secret material. A boot-selection API failure
is not retried or followed by reboot; flash/power failures at metadata commit
cannot offer transactional certainty beyond SDK guarantees.
The synchronous handler may block other HTTPD work for the upload duration.
A 4 KiB heap buffer and a 2 KiB reboot-owner stack are prepared before flashing;
no whole-image allocation. Receive checks enforce 10 seconds without progress
and 120 seconds total, using the server's existing one-second receive timeout.
These checks do not preempt SDK flash/validation calls or scheduler delays.
Only the inactive app and SDK OTA metadata are written, not NVS/data partitions.
### Integration work outside backend ownership
- Server URI capacity is now **40**; `server_lifecycle.py` currently fails its
hardcoded 36-initializer extraction check (now 37). Its old 39-handler capacity,
required-registration positions 116 and optional positions 1718 also need
adjustment to 40, 117 and 1819, respectively, plus a firmware-handler fake.
That existing fixture is intentionally not edited here.
- HTTPS stop/start/rotation and typed reboot share the upload reservation; direct
identity mutation is separately reserved. **Direct `esp_restart()` callers in
`system_console.c`, `ssh_transport.c`, `web_admin_transport.c`, and
`local_status_ui.c` do not share this fence.** Those files were outside the
backend's allowed ownership. The parent must coordinate those reboot paths
for global software-reboot exclusion; do not claim that property yet.