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.
229 lines
11 KiB
C
229 lines
11 KiB
C
/* 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, ¤t) != 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
|
|
}
|