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:
@@ -38,6 +38,7 @@ idf_component_register(
|
||||
"web_broker_settings.c"
|
||||
"web_ssh_settings.c"
|
||||
"web_lifecycle_settings.c"
|
||||
"web_firmware_update.c"
|
||||
"web_admin_tickets.c"
|
||||
"web_admin_transport.c"
|
||||
"web_assets_data.c"
|
||||
@@ -59,6 +60,7 @@ idf_component_register(
|
||||
"mdns_console.c"
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES
|
||||
app_update
|
||||
bootloader_support
|
||||
console
|
||||
esp_driver_gpio
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "esp_log.h"
|
||||
#include "esp_netif_ip_addr.h"
|
||||
#include "esp_system.h"
|
||||
#include "web_firmware_update.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/portmacro.h"
|
||||
#include "freertos/semphr.h"
|
||||
@@ -1101,7 +1102,8 @@ static void execute_action(local_status_ui_state_t *state,
|
||||
}
|
||||
break;
|
||||
case LOCAL_STATUS_ACTION_REBOOT:
|
||||
state->restart_pending = true;
|
||||
error = web_firmware_update_reserve_reboot();
|
||||
state->restart_pending = error == ESP_OK;
|
||||
break;
|
||||
default:
|
||||
error = ESP_ERR_INVALID_ARG;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_system.h"
|
||||
#include "web_firmware_update.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
@@ -265,6 +266,8 @@ static esp_err_t admin_console_perform(const admin_ssh_console_token_t *token,
|
||||
}
|
||||
switch (action) {
|
||||
case ADMIN_SSH_DEFER_REBOOT:
|
||||
if (web_firmware_update_reserve_reboot() != ESP_OK)
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
esp_restart();
|
||||
return ESP_OK;
|
||||
case ADMIN_SSH_DEFER_STOP:
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "esp_console.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_system.h"
|
||||
#include "web_firmware_update.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
@@ -57,6 +58,10 @@ static int command_reboot(int argc, char **argv)
|
||||
printf("Reboot scheduled after console output drains; unsaved changes will be lost.\n");
|
||||
return 0;
|
||||
}
|
||||
if (web_firmware_update_reserve_reboot() != ESP_OK) {
|
||||
printf("Reboot refused: firmware update or reboot in progress.\n");
|
||||
return 1;
|
||||
}
|
||||
printf("Rebooting now; unsaved RAM-only configuration changes will be lost.\n");
|
||||
fflush(stdout);
|
||||
/* Give the UART driver time to transmit the acknowledgement before reset. */
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_system.h"
|
||||
#include "web_firmware_update.h"
|
||||
#include "web_server.h"
|
||||
#include "web_security.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
@@ -114,6 +115,8 @@ static esp_err_t owner_perform(const admin_ssh_console_token_t *token,
|
||||
}
|
||||
if (action == ADMIN_CONSOLE_DEFER_WEB_STOP) return web_server_stop();
|
||||
if (action == ADMIN_SSH_DEFER_REBOOT) {
|
||||
if (web_firmware_update_reserve_reboot() != ESP_OK)
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
esp_restart();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -218,12 +218,18 @@ esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade,
|
||||
return require(r, mutation, upgrade, 0, view, allowed);
|
||||
}
|
||||
|
||||
esp_err_t web_cookie_auth_require_json(httpd_req_t *r, size_t body_limit,
|
||||
esp_err_t web_cookie_auth_require_body(httpd_req_t *r, size_t body_limit,
|
||||
web_session_view_t *view, bool *allowed)
|
||||
{
|
||||
return require(r, true, false, body_limit, view, allowed);
|
||||
}
|
||||
|
||||
esp_err_t web_cookie_auth_require_json(httpd_req_t *r, size_t body_limit,
|
||||
web_session_view_t *view, bool *allowed)
|
||||
{
|
||||
return web_cookie_auth_require_body(r, body_limit, view, allowed);
|
||||
}
|
||||
|
||||
static bool secret(char out[65])
|
||||
{
|
||||
uint8_t bytes[32];
|
||||
|
||||
@@ -17,6 +17,9 @@ esp_err_t web_cookie_auth_require(httpd_req_t *request, bool mutation,
|
||||
bool upgrade, web_session_view_t *view,
|
||||
bool *allowed);
|
||||
esp_err_t web_cookie_auth_handler(httpd_req_t *request);
|
||||
/* Same mutation policy for a bounded raw body; caller validates content type. */
|
||||
esp_err_t web_cookie_auth_require_body(httpd_req_t *request, size_t body_limit,
|
||||
web_session_view_t *view, bool *allowed);
|
||||
/* Same mutation policy, allowing a bounded body; caller validates JSON/content type. */
|
||||
esp_err_t web_cookie_auth_require_json(httpd_req_t *request, size_t body_limit,
|
||||
web_session_view_t *view, bool *allowed);
|
||||
|
||||
@@ -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, ¤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
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include "esp_http_server.h"
|
||||
|
||||
#define WEB_FIRMWARE_UPDATE_URI "/api/firmware"
|
||||
|
||||
esp_err_t web_firmware_update_handler(httpd_req_t *request);
|
||||
|
||||
/* Atomically exclude uploads until reset. Call immediately before an ordinary
|
||||
* reboot (and before any acknowledgement delay); failure means do not reset.
|
||||
* Independent of HTTPS initialization, so UART0 recovery remains available. */
|
||||
esp_err_t web_firmware_update_reserve_reboot(void);
|
||||
|
||||
/* Internal HTTPS owner reservation. HTTPD takes before flash and releases on
|
||||
* failure; successful response transfers lifetime to the reboot owner. No
|
||||
* service mutex is held during receive/flash/response. Implemented by server. */
|
||||
esp_err_t web_firmware_update_reserve(httpd_handle_t server);
|
||||
void web_firmware_update_release(void);
|
||||
@@ -31,6 +31,7 @@
|
||||
#include "web_broker_settings.h"
|
||||
#include "web_ssh_settings.h"
|
||||
#include "web_lifecycle_settings.h"
|
||||
#include "web_firmware_update.h"
|
||||
#include "web_admin_transport.h"
|
||||
#include "web_session_store.h"
|
||||
#include "web_cookie_auth.h"
|
||||
@@ -573,6 +574,12 @@ static const httpd_uri_t s_logo_uri = {
|
||||
.user_ctx = (void *)(uintptr_t)WEB_UI_RESOURCE_LOGO_PNG,
|
||||
};
|
||||
|
||||
static const httpd_uri_t s_firmware_uri = {
|
||||
.uri = WEB_FIRMWARE_UPDATE_URI,
|
||||
.method = HTTP_POST,
|
||||
.handler = web_firmware_update_handler,
|
||||
};
|
||||
|
||||
static const httpd_uri_t *const s_uri_handlers[] = {
|
||||
&s_root_uri,
|
||||
&s_status_uri,
|
||||
@@ -583,6 +590,7 @@ static const httpd_uri_t *const s_uri_handlers[] = {
|
||||
&s_addon_fit_js_uri,
|
||||
&s_app_js_uri,
|
||||
&s_logo_uri,
|
||||
&s_firmware_uri,
|
||||
};
|
||||
|
||||
static const httpd_uri_t s_auth_uris[] = {
|
||||
@@ -944,6 +952,29 @@ esp_err_t web_server_restart_current(uint32_t expected_generation)
|
||||
return stop_server(expected_generation, true, false);
|
||||
}
|
||||
|
||||
/* The upload retains the existing lifecycle fence, not the mutex. This also
|
||||
* excludes canonical stop/start and service-coordinated identity replacement. */
|
||||
esp_err_t web_firmware_update_reserve(httpd_handle_t server)
|
||||
{
|
||||
if (!s_server_mutex || xSemaphoreTake(s_server_mutex, 0U) != pdTRUE)
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
if (!server || s_server != server || s_transitioning || s_last_error != ESP_OK) {
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
s_transitioning = true;
|
||||
if (s_generation != UINT32_MAX) ++s_generation;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void web_firmware_update_release(void)
|
||||
{
|
||||
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||
s_transitioning = false;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
}
|
||||
|
||||
esp_err_t web_server_reboot_current(uint32_t expected_generation)
|
||||
{
|
||||
if (!expected_generation) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
+52
-2
@@ -192,6 +192,7 @@ static const char s_index_html[] =
|
||||
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button>"
|
||||
"<button id=\"settings-display\" class=\"button\" type=\"button\" aria-pressed=\"false\">Display</button><button id=\"settings-broker\" class=\"button\" type=\"button\" aria-pressed=\"false\">Broker</button><button id=\"settings-ssh\" class=\"button\" type=\"button\" aria-pressed=\"false\">SSH</button><button id=\"settings-lifecycle\" class=\"button\" type=\"button\" aria-pressed=\"false\">HTTPS / Reboot</button></div>"
|
||||
"<div id=\"lifecycle-settings\" hidden><h2>HTTPS identity, service and device reboot</h2>"
|
||||
"<section aria-label=\"Firmware upload\"><h3>Firmware update</h3><p class=\"connection-detail\">Select the firmware.bin built for this device (max. 4 MiB). Saved settings are kept. Keep power connected during the update.</p><div class=\"settings-edit\"><label>Application .bin <input id=\"firmware-file\" type=\"file\" accept=\".bin\"></label></div><button id=\"firmware-upload\" class=\"button\" type=\"button\">Upload and reboot…</button><progress id=\"firmware-progress\" max=\"100\" value=\"0\" aria-label=\"Firmware upload progress\"></progress><p id=\"firmware-detail\" class=\"connection-detail\" role=\"status\">Choose a firmware file to begin.</p><a href=\"/\">Reconnect after reboot</a></section>"
|
||||
"<p class=\"connection-detail\">Stop/Restart HTTPS closes ALL web logins and both browser terminal routes, including clients admitted before execution. Settings and certificate identity are unchanged; HTTPS restart preserves device working configuration. Save unsaved browser drafts first. Recover a stopped web service with <code>web start</code> through UART0 or still-running, reachable admin SSH. USB remains UART1 serial access, not a web administration console.</p>"
|
||||
"<p class=\"connection-detail\">Reboot interrupts ALL clients and the entire device, including SSH, USB and UART operation during restart. Unsaved RAM-only working configuration and browser drafts can be lost. Saved configuration and identities are not reset. After boot, restore network reachability, reload and sign in explicitly; inspect the outcome before another action.</p>"
|
||||
"<p class=\"connection-detail\">Rotate replaces and persists the HTTPS certificate AND private key, changes browser trust, and disconnects all web logins/terminals. No SSH identity or user/configuration change. Verify the NEW SHA-256 certificate fingerprint using trusted UART0 (<code>web certificate info</code>) before accepting browser trust; a certificate warning is not verification. Reload and sign in freshly. Native USB remains independent UART1 serial access, not administration. No browser TLS reset/recovery or key/certificate export.</p>"
|
||||
@@ -895,11 +896,59 @@ static const char s_app_js[] =
|
||||
" if (brokerSelection) brokerDetail.textContent = 'Explicit selection: ' + brokerLabel(client) + '. Confirm assignment separately; refresh never renews this lease token.';\n"
|
||||
" brokerButtons();\n"
|
||||
"});\n"
|
||||
"let firmwareWork = null, firmwareLocked = false;\n"
|
||||
"const firmwareUncertain = 'Update status unknown. The firmware may already be installed. Reconnect and check before retrying.';\n"
|
||||
"const firmwareWarning = 'Upload firmware and reboot? All connections will close. Saved settings are kept; unsaved changes will be lost.';\n"
|
||||
"function firmwareButtons() {\n"
|
||||
" element('firmware-file').disabled = element('firmware-upload').disabled = !!firmwareWork || firmwareLocked || !!lifecycleAbort || lifecyclePending;\n"
|
||||
"}\n"
|
||||
"function cancelFirmware() {\n"
|
||||
" const work = firmwareWork; firmwareWork = null; element('firmware-file').value = '';\n"
|
||||
" if (work) { firmwareLocked = true; work.controller.abort(); work.xhr?.abort(); element('firmware-detail').textContent = firmwareUncertain; }\n"
|
||||
" firmwareButtons();\n"
|
||||
"}\n"
|
||||
"async function uploadFirmware() {\n"
|
||||
" if (firmwareWork || firmwareLocked || lifecycleAbort || lifecyclePending || selected !== 'settings' || settingsDomain !== 'lifecycle' || accountRole !== 'admin' || !sessionVerified || !csrf || suspended || unloading || navigating || loggingOut) return;\n"
|
||||
" const file = element('firmware-file').files?.[0], detail = element('firmware-detail'), progress = element('firmware-progress');\n"
|
||||
" if (!file || !/\\.bin$/i.test(file.name) || !Number.isSafeInteger(file.size) || file.size < 1 || file.size > 4 * 1024 * 1024) { detail.textContent = 'Choose a nonempty .bin file up to 4 MiB.'; return; }\n"
|
||||
" if (!window.confirm(firmwareWarning)) return;\n"
|
||||
" const generation = workGeneration, work = {controller:new AbortController(), xhr:null}; firmwareWork = work; lifecycleButtons();\n"
|
||||
" const current = () => firmwareWork === work && live(generation) && sessionVerified && accountRole === 'admin' && !suspended && !loggingOut;\n"
|
||||
" const finish = (message, locked) => { if (!current()) return; firmwareWork = null; firmwareLocked = locked; detail.textContent = message; element('firmware-file').value = ''; lifecycleButtons(); };\n"
|
||||
" progress.value = 0; detail.textContent = 'Checking session…';\n"
|
||||
" try {\n"
|
||||
" if (!await loadSession(generation, work.controller.signal, false)) { finish('Session changed; no upload sent. Sign in again.', false); return; }\n"
|
||||
" if (!current()) return;\n"
|
||||
" const xhr = new XMLHttpRequest(); work.xhr = xhr;\n"
|
||||
" // XHR uses CORS mode and same-origin cookies. File supplies the known body length; never set Origin or Content-Length manually.\n"
|
||||
" xhr.open('POST', '/api/firmware'); xhr.timeout = 180000;\n"
|
||||
" xhr.setRequestHeader('Content-Type', 'application/octet-stream'); xhr.setRequestHeader('X-CSRF-Token', csrf);\n"
|
||||
" xhr.upload.onprogress = event => {\n"
|
||||
" if (!current()) return;\n"
|
||||
" if (event.lengthComputable && Number.isFinite(event.total) && event.total > 0 && Number.isFinite(event.loaded) && event.loaded >= 0) { progress.value = Math.min(100, Math.floor(event.loaded * 100 / event.total)); detail.textContent = progress.value === 100 ? 'Upload complete. Validating firmware…' : 'Uploading: ' + progress.value + '%'; }\n"
|
||||
" else detail.textContent = 'Uploading… Progress unavailable.';\n"
|
||||
" };\n"
|
||||
" xhr.onerror = xhr.ontimeout = xhr.onabort = () => finish(firmwareUncertain, true);\n"
|
||||
" xhr.onload = () => {\n"
|
||||
" if (!current()) return;\n"
|
||||
" if (xhr.status === 401) { cancelFirmware(); login(); return; }\n"
|
||||
" let value; try { if (xhr.responseText.length > 128) throw new Error(); value = JSON.parse(xhr.responseText); } catch (_) { finish(firmwareUncertain, true); return; }\n"
|
||||
" if (xhr.status === 200 && value && Object.keys(value).length === 2 && value.ok === true && value.rebooting === true) { progress.value = 100; finish('Firmware accepted; rebooting. Reconnect and sign in shortly.', true); return; }\n"
|
||||
" const errors = {invalid_request:'Invalid upload request.', invalid_firmware:'Invalid or incompatible application image.', firmware_incomplete:'Incomplete firmware image.', authentication_required:'Session ended.', origin:'Origin security check failed.', csrf:'Session security check failed.', admin_required:'Administrator access required.', firmware_timeout:'Device upload deadline exceeded.', firmware_too_large:'Image exceeds the device partition capacity.', firmware_content_type:'Raw binary content type required.', firmware_write_failed:'Firmware write failed.', firmware_commit_failed:'Firmware commit failed; inspect the device before any reset or retry.', unavailable:'Service unavailable.', busy:'Device is busy.', firmware_unavailable:'Firmware update partition unavailable.', firmware_resources:'Insufficient device resources.'};\n"
|
||||
" const code = value && Object.keys(value).length === 1 && value.error;\n"
|
||||
" if (xhr.status >= 400 && Object.hasOwn(errors, code)) { finish(errors[code], ['authentication_required','origin','csrf','admin_required','firmware_commit_failed'].includes(code)); }\n"
|
||||
" else finish(firmwareUncertain, true);\n"
|
||||
" };\n"
|
||||
" detail.textContent = 'Uploading… Keep power connected.'; xhr.send(file);\n"
|
||||
" } catch (_) { finish(work.xhr ? firmwareUncertain : 'Session check failed; no upload sent. Reconnect and try again.', !!work.xhr); }\n"
|
||||
"}\n"
|
||||
"element('firmware-upload').addEventListener('click', uploadFirmware);\n"
|
||||
"const lifecycleActions = ['stop','restart','reboot','rotate'];\n"
|
||||
"let lifecycleSnapshot = null, lifecycleAbort = null, lifecyclePending = false, lifecycleAwaitingAck = false, lifecycleId = 0, lifecycleAction = '';\n"
|
||||
"const lifecycleRecovery = 'Outcome may be unknown; no automatic retry. HTTPS stop: use UART0 or reachable admin SSH web start. HTTPS restart expires this login; reload and sign in again. Reboot interrupts every client, including USB; restore network after boot, reload/sign in and inspect before acting again. Rotation may have persisted a NEW identity even when stop/start fails; no rollback. The stored fingerprint may differ from a retained old server certificate. Inspect with trusted UART0 web certificate info, verify the new fingerprint before renewing trust, then reload and sign in freshly. SSH and native USB UART1 access are not stopped by rotation. A stuck ACK handoff requires canonical web stop then web start; this closes all web clients.';\n"
|
||||
"function lifecycleButtons() {\n"
|
||||
" const busy = !!lifecycleAbort;\n"
|
||||
" firmwareButtons();\n"
|
||||
" const busy = !!lifecycleAbort || !!firmwareWork || firmwareLocked;\n"
|
||||
" element('lifecycle-refresh').disabled = element('lifecycle-result').disabled = busy;\n"
|
||||
" for (const action of lifecycleActions) element('lifecycle-' + action).disabled = busy || lifecyclePending || !lifecycleSnapshot?.controllable || (action === 'rotate' && !lifecycleSnapshot?.rotatable);\n"
|
||||
"}\n"
|
||||
@@ -911,7 +960,7 @@ static const char s_app_js[] =
|
||||
" lifecycleButtons();\n"
|
||||
"}\n"
|
||||
"async function lifecycleRequest(action, snapshotRead = false) {\n"
|
||||
" if (settingsDomain !== 'lifecycle' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || lifecycleAbort || (action && lifecyclePending)) return;\n"
|
||||
" if (settingsDomain !== 'lifecycle' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || firmwareWork || firmwareLocked || lifecycleAbort || (action && lifecyclePending)) return;\n"
|
||||
" let body; const detail = element(snapshotRead ? 'lifecycle-detail' : 'lifecycle-operation-detail');\n"
|
||||
" if (action) {\n"
|
||||
" if (!lifecycleActions.includes(action) || !lifecycleSnapshot?.controllable || (action === 'rotate' && !lifecycleSnapshot?.rotatable)) return;\n"
|
||||
@@ -1639,6 +1688,7 @@ static const char s_app_js[] =
|
||||
"const requests = new Set();\n"
|
||||
"const live = (generation) => generation === workGeneration && !unloading && !navigating;\n"
|
||||
"const cancelWork = () => {\n"
|
||||
" cancelFirmware();\n"
|
||||
" clearSettings();\n"
|
||||
" ++workGeneration;\n"
|
||||
" ++connectionGeneration;\n"
|
||||
|
||||
Reference in New Issue
Block a user