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
+228
View File
@@ -0,0 +1,228 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_firmware_update.h"
#include <stdlib.h>
#include <stdatomic.h>
#include <string.h>
#include "esp_app_desc.h"
#include "esp_app_format.h"
#include "esp_image_format.h"
#include "esp_ota_ops.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "secure_random.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
#include "web_security.h"
#define BUFFER_SIZE 4096U
#define STALL_US 10000000LL
#define TOTAL_US 120000000LL
#define PREFIX_SIZE (sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t) + sizeof(esp_app_desc_t))
/* A successful reservation is retained through reset, not a check-then-reset.
* Only the upload owner accesses the selected latch while holding this gate. */
static atomic_bool s_reboot_gate;
static bool s_firmware_selected;
esp_err_t web_firmware_update_reserve_reboot(void)
{
bool expected = false;
return atomic_compare_exchange_strong(&s_reboot_gate, &expected, true) ?
ESP_OK : ESP_ERR_INVALID_STATE;
}
/* Allocated before boot selection. No HTTPD stop, queue allocation, captured
* request or socket in this owner. A successful send is not proof of receipt. */
static void reboot_owner(void *argument)
{
(void)argument;
uint32_t decision = 0;
xTaskNotifyWait(0, UINT32_MAX, &decision, portMAX_DELAY);
if (decision == 1) {
vTaskDelay(pdMS_TO_TICKS(500));
esp_restart();
}
vTaskDelete(NULL);
}
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
/* Reject without HTTPD's unbounded discard of the remaining request body. */
return web_httpd_unread_body(request) ? ESP_FAIL : error;
}
static esp_err_t content_length(httpd_req_t *request, size_t limit)
{
/* Auth's adapter already rejects duplicate headers. IDF 5.5.0 parses a
* uint64_t length then narrows it to size_t; never trust that value alone.
* Its getters strip leading spaces only. Require 1..20 decimal digits. */
char raw[21];
size_t length = httpd_req_get_hdr_value_len(request, "Content-Length");
if (!length || length >= sizeof(raw) ||
httpd_req_get_hdr_value_str(request, "Content-Length", raw, sizeof(raw)) != ESP_OK ||
strspn(raw, "0123456789") != length)
return ESP_ERR_INVALID_ARG;
size_t value = 0;
for (size_t i = 0; i < length; ++i) {
unsigned digit = (unsigned)(raw[i] - '0');
if (value > limit / 10 || (value == limit / 10 && digit > limit % 10))
return ESP_ERR_INVALID_SIZE;
value = value * 10 + digit;
}
return value == request->content_len ? ESP_OK : ESP_ERR_INVALID_ARG;
}
static bool application_prefix(const uint8_t *buffer)
{
esp_image_header_t header;
esp_image_segment_header_t segment;
uint32_t magic;
memcpy(&header, buffer, sizeof(header));
memcpy(&segment, buffer + sizeof(header), sizeof(segment));
memcpy(&magic, buffer + sizeof(header) + sizeof(segment), sizeof(magic));
/* App descriptor distinguishes app images from bootloader/full-flash data.
* SDK end validation remains authoritative for revision, segments and hash. */
return header.magic == ESP_IMAGE_HEADER_MAGIC &&
header.chip_id == ESP_CHIP_ID_ESP32S3 &&
header.segment_count > 0 && header.segment_count <= ESP_IMAGE_MAX_SEGMENTS &&
header.hash_appended == 1 && segment.data_len >= sizeof(esp_app_desc_t) &&
magic == ESP_APP_DESC_MAGIC_WORD;
}
esp_err_t web_firmware_update_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false, reserved = false, active = false, gate_owned = false;
uint32_t identity_token = 0;
uint8_t *buffer = NULL;
TaskHandle_t reboot_task = NULL;
esp_ota_handle_t ota = 0;
const char *status = "500 Internal Server Error";
const char *body = "{\"error\":\"firmware_write_failed\"}";
/* Auth rejects ambiguous headers/transfer encoding before any body IO.
* The actual inactive partition, not a compiled slot size, bounds the body. */
esp_err_t error = web_cookie_auth_require_body(request, SIZE_MAX, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
#define REJECT(s, b) do { status = (s); body = "{\"error\":\"" b "\"}"; goto failed; } while (0)
if (view.principal.role != USER_ROLE_ADMIN)
REJECT("403 Forbidden", "admin_required");
char type[40] = {0};
if (httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) != ESP_OK ||
strcmp(type, "application/octet-stream"))
REJECT("415 Unsupported Media Type", "firmware_content_type");
const esp_partition_t *running = esp_ota_get_running_partition();
const esp_partition_t *target = esp_ota_get_next_update_partition(NULL);
if (!running || !target || target->type != ESP_PARTITION_TYPE_APP ||
target->subtype < ESP_PARTITION_SUBTYPE_APP_OTA_0 ||
target->subtype > ESP_PARTITION_SUBTYPE_APP_OTA_15 ||
target->address == running->address)
REJECT("503 Service Unavailable", "firmware_unavailable");
esp_err_t length_error = content_length(request, target->size);
if (length_error == ESP_ERR_INVALID_SIZE)
REJECT("413 Payload Too Large", "firmware_too_large");
if (length_error != ESP_OK)
REJECT("400 Bad Request", "invalid_request");
if (request->content_len < PREFIX_SIZE)
REJECT("400 Bad Request", "invalid_firmware");
if (web_firmware_update_reserve_reboot() != ESP_OK)
REJECT("503 Service Unavailable", "busy");
gate_owned = true;
if (s_firmware_selected)
REJECT("409 Conflict", "firmware_selected_reboot_required");
if (web_firmware_update_reserve(request->handle) != ESP_OK)
REJECT("503 Service Unavailable", "busy");
reserved = true;
/* Also exclude direct canonical identity mutations which bypass server. */
if (web_security_reserve_identity(0, false, &identity_token) != ESP_OK)
REJECT("503 Service Unavailable", "busy");
buffer = malloc(BUFFER_SIZE);
if (!buffer || xTaskCreate(reboot_owner, "fw_reboot", 2048, NULL, 5, &reboot_task) != pdPASS)
REJECT("503 Service Unavailable", "firmware_resources");
size_t received = 0, prefix = 0;
int64_t started = esp_timer_get_time(), last_progress = started;
while (received < request->content_len) {
int64_t now = esp_timer_get_time();
if (now - started >= TOTAL_US || now - last_progress >= STALL_US)
REJECT("408 Request Timeout", "firmware_timeout");
/* Accumulate the entire prefix, even when TLS gives one byte at a time. */
size_t want = active ? request->content_len - received : PREFIX_SIZE - prefix;
if (want > BUFFER_SIZE) want = BUFFER_SIZE;
int count = httpd_req_recv(request, (char *)buffer + (active ? 0 : prefix), want);
now = esp_timer_get_time();
if (now - started >= TOTAL_US || now - last_progress >= STALL_US)
REJECT("408 Request Timeout", "firmware_timeout");
if (count == HTTPD_SOCK_ERR_TIMEOUT) continue;
if (count <= 0 || (size_t)count > want)
REJECT("400 Bad Request", "firmware_incomplete");
received += (size_t)count;
last_progress = now;
size_t write_size = (size_t)count;
if (!active) {
prefix += (size_t)count;
if (prefix < PREFIX_SIZE) continue;
if (!application_prefix(buffer))
REJECT("400 Bad Request", "invalid_firmware");
esp_err_t begin_error = esp_ota_begin(target, request->content_len, &ota);
/* IDF 5.5.0 can publish a live handle before an erase failure. */
active = ota != 0;
if (begin_error != ESP_OK) goto failed;
write_size = prefix;
}
if (esp_ota_write(ota, buffer, write_size) != ESP_OK) goto failed;
}
if (esp_timer_get_time() - started >= TOTAL_US)
REJECT("408 Request Timeout", "firmware_timeout");
/* esp_ota_end consumes the handle even on validation failure. */
active = false;
if (esp_ota_end(ota) != ESP_OK)
REJECT("400 Bad Request", "invalid_firmware");
/* SDK end validates flash, but does not compare its parsed image length to
* our HTTP length. Reject truncation into old flash and appended garbage. */
_Static_assert(sizeof(esp_image_metadata_t) <= BUFFER_SIZE, "metadata fits upload buffer");
esp_image_metadata_t *metadata = (esp_image_metadata_t *)buffer;
esp_partition_pos_t position = {.offset = target->address, .size = target->size};
if (esp_image_get_metadata(&position, metadata) != ESP_OK || metadata->image_len != received)
REJECT("400 Bad Request", "invalid_firmware");
bool current = false;
if (web_session_store_check_principal(view.id, &view.principal, &current) != ESP_OK || !current)
REJECT("401 Unauthorized", "authentication_required");
if (esp_ota_set_boot_partition(target) != ESP_OK)
REJECT("500 Internal Server Error", "firmware_commit_failed");
s_firmware_selected = true;
/* Commit is irreversible here. Failed send leaves the selected image for a
* later reboot, but deliberately does not schedule this upload's reboot.
* Never retry an upload automatically after a lost acknowledgement. */
error = respond(request, "200 OK", "{\"ok\":true,\"rebooting\":true}");
if (error == ESP_OK) {
xTaskNotify(reboot_task, 1, eSetValueWithOverwrite);
reboot_task = NULL;
reserved = false; /* Retain service/identity reservations until reset. */
identity_token = 0;
gate_owned = false; /* Reboot owner retains exclusion until reset. */
}
goto done;
failed:
if (active) { esp_ota_abort(ota); active = false; }
error = respond(request, status, body);
done:
if (reboot_task) xTaskNotify(reboot_task, 2, eSetValueWithOverwrite);
if (identity_token) web_security_release_identity(identity_token);
if (reserved) web_firmware_update_release();
free(buffer);
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
if (gate_owned) atomic_store(&s_reboot_gate, false);
return error;
#undef REJECT
}