mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-18 21:00:22 -05:00
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>
344 lines
13 KiB
C
344 lines
13 KiB
C
/**
|
|
* @file ota_update.c
|
|
* @brief HTTP OTA firmware update for ESP32-S3 CSI Node.
|
|
*
|
|
* Uses ESP-IDF's native OTA API with rollback support.
|
|
* The HTTP server runs on port 8032 and accepts:
|
|
* POST /ota — firmware binary payload (application/octet-stream)
|
|
* GET /ota/status — current firmware version and partition info
|
|
*/
|
|
|
|
#include "ota_update.h"
|
|
|
|
#include <string.h>
|
|
#include "esp_log.h"
|
|
#include "esp_ota_ops.h"
|
|
#include "esp_http_server.h"
|
|
#include "esp_app_desc.h"
|
|
#include "nvs_flash.h"
|
|
#include "nvs.h"
|
|
#include "freertos/FreeRTOS.h"
|
|
#include "freertos/task.h"
|
|
|
|
static const char *TAG = "ota_update";
|
|
|
|
/** OTA HTTP server port. */
|
|
#define OTA_PORT 8032
|
|
|
|
/** NVS namespace and key for the OTA pre-shared key. */
|
|
#define OTA_NVS_NAMESPACE "security"
|
|
#define OTA_NVS_KEY "ota_psk"
|
|
|
|
/** Maximum PSK length (hex-encoded SHA-256). */
|
|
#define OTA_PSK_MAX_LEN 65
|
|
|
|
/** Cached PSK loaded from NVS at init time. Empty = auth disabled. */
|
|
static char s_ota_psk[OTA_PSK_MAX_LEN] = {0};
|
|
|
|
/**
|
|
* ADR-050: Verify the Authorization header contains the correct PSK.
|
|
* Returns true only when a PSK is provisioned AND the Bearer token
|
|
* matches it. An unprovisioned node refuses all OTA requests
|
|
* (fail-closed, see RuView#596 audit). The OTA server still starts so
|
|
* the operator can `provision.py --ota-psk <hex>` over USB-CDC without
|
|
* a reflash, but the upload endpoint will reject every request until
|
|
* the PSK is set.
|
|
*/
|
|
static bool ota_check_auth(httpd_req_t *req)
|
|
{
|
|
if (s_ota_psk[0] == '\0') {
|
|
/* No PSK provisioned — fail closed. Previously this returned
|
|
* true ("permissive for dev"), which let any host on the WiFi
|
|
* push attacker-controlled firmware to a freshly-flashed node.
|
|
* Plain HTTP transport + no Secure Boot V2 + no signed-image
|
|
* verification meant a single LAN call could brick or back-
|
|
* door a node. Reject until provisioned. */
|
|
ESP_LOGW(TAG, "OTA rejected: no PSK in NVS (run provision.py --ota-psk <hex>)");
|
|
return false;
|
|
}
|
|
|
|
char auth_header[128] = {0};
|
|
if (httpd_req_get_hdr_value_str(req, "Authorization", auth_header,
|
|
sizeof(auth_header)) != ESP_OK) {
|
|
return false;
|
|
}
|
|
|
|
/* Expect "Bearer <psk>" */
|
|
const char *prefix = "Bearer ";
|
|
if (strncmp(auth_header, prefix, strlen(prefix)) != 0) {
|
|
return false;
|
|
}
|
|
|
|
const char *token = auth_header + strlen(prefix);
|
|
/* Constant-time comparison to prevent timing attacks. */
|
|
size_t psk_len = strlen(s_ota_psk);
|
|
size_t tok_len = strlen(token);
|
|
if (psk_len != tok_len) return false;
|
|
volatile uint8_t result = 0;
|
|
for (size_t i = 0; i < psk_len; i++) {
|
|
result |= (uint8_t)(s_ota_psk[i] ^ token[i]);
|
|
}
|
|
return result == 0;
|
|
}
|
|
|
|
/**
|
|
* GET /ota/status — return firmware version and partition info.
|
|
*/
|
|
static esp_err_t ota_status_handler(httpd_req_t *req)
|
|
{
|
|
const esp_app_desc_t *app = esp_app_get_description();
|
|
const esp_partition_t *running = esp_ota_get_running_partition();
|
|
const esp_partition_t *update = esp_ota_get_next_update_partition(NULL);
|
|
|
|
/* ADR-379: lets an operator confirm remotely that an OTA'd image left
|
|
* pending_verify (and so survives a power cycle) without a serial log. */
|
|
esp_ota_img_states_t st;
|
|
const char *ota_state = "none";
|
|
if (running && esp_ota_get_state_partition(running, &st) == ESP_OK) {
|
|
ota_state = st == ESP_OTA_IMG_VALID ? "valid" :
|
|
st == ESP_OTA_IMG_PENDING_VERIFY ? "pending_verify" :
|
|
st == ESP_OTA_IMG_NEW ? "new" :
|
|
st == ESP_OTA_IMG_UNDEFINED ? "undefined" : "other";
|
|
}
|
|
|
|
char response[512];
|
|
int len = snprintf(response, sizeof(response),
|
|
"{\"version\":\"%s\",\"date\":\"%s\",\"time\":\"%s\","
|
|
"\"running_partition\":\"%s\",\"next_partition\":\"%s\","
|
|
"\"ota_state\":\"%s\",\"max_size\":%lu}",
|
|
app->version, app->date, app->time,
|
|
running ? running->label : "unknown",
|
|
update ? update->label : "none",
|
|
ota_state,
|
|
(unsigned long)(update ? update->size : 0));
|
|
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_send(req, response, len);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/* Receive chunk for POST /ota. Static, not on the httpd task stack: handlers
|
|
* run one at a time on the single httpd task, so one buffer is enough, and
|
|
* 1 KB was a quarter of the default 4 KB stack (ADR-379). */
|
|
static char s_ota_rx_buf[1024];
|
|
|
|
void ota_update_log_httpd_stack(const char *what)
|
|
{
|
|
/* ESP-IDF FreeRTOS counts stack in bytes. */
|
|
ESP_LOGI(TAG, "httpd stack after %s: %u of %d bytes never used",
|
|
what, (unsigned)uxTaskGetStackHighWaterMark(NULL),
|
|
CONFIG_OTA_HTTPD_STACK_SIZE);
|
|
}
|
|
|
|
/**
|
|
* POST /ota — receive and flash firmware binary. Returns ESP_OK only after the
|
|
* new slot is set as boot partition and the response is sent; the caller
|
|
* reboots.
|
|
*/
|
|
static esp_err_t ota_upload_receive(httpd_req_t *req)
|
|
{
|
|
/* ADR-050: Authenticate before accepting firmware upload. */
|
|
if (!ota_check_auth(req)) {
|
|
ESP_LOGW(TAG, "OTA upload rejected: authentication failed");
|
|
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN,
|
|
"Authentication required. Use: Authorization: Bearer <psk>");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
ESP_LOGI(TAG, "OTA update started, content_length=%d", req->content_len);
|
|
|
|
const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL);
|
|
if (update_partition == NULL) {
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
|
"No OTA partition available");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
if (req->content_len <= 0 || (size_t)req->content_len > update_partition->size) {
|
|
ESP_LOGW(TAG, "OTA rejected: content_length=%d exceeds partition '%s' size=%lu",
|
|
req->content_len, update_partition->label,
|
|
(unsigned long)update_partition->size);
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
|
|
"Invalid firmware size for OTA partition");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
esp_ota_handle_t ota_handle;
|
|
esp_err_t err = esp_ota_begin(update_partition, OTA_WITH_SEQUENTIAL_WRITES, &ota_handle);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err));
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
|
"OTA begin failed");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
/* Read firmware in chunks. */
|
|
char *buf = s_ota_rx_buf;
|
|
int received = 0;
|
|
int total = 0;
|
|
|
|
while (total < req->content_len) {
|
|
received = httpd_req_recv(req, buf, sizeof(s_ota_rx_buf));
|
|
if (received <= 0) {
|
|
if (received == HTTPD_SOCK_ERR_TIMEOUT) {
|
|
continue; /* Retry on timeout. */
|
|
}
|
|
ESP_LOGE(TAG, "OTA receive error at byte %d", total);
|
|
esp_ota_abort(ota_handle);
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
|
"Receive error");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
err = esp_ota_write(ota_handle, buf, received);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGE(TAG, "esp_ota_write failed at byte %d: %s",
|
|
total, esp_err_to_name(err));
|
|
esp_ota_abort(ota_handle);
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
|
"OTA write failed");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
total += received;
|
|
if ((total % (64 * 1024)) == 0) {
|
|
/* Integer percent: %f pulls newlib's float formatter onto this
|
|
* task's stack for no benefit. */
|
|
ESP_LOGI(TAG, "OTA progress: %d / %d bytes (%d%%)",
|
|
total, req->content_len,
|
|
(int)((int64_t)total * 100 / req->content_len));
|
|
}
|
|
}
|
|
|
|
err = esp_ota_end(ota_handle);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGE(TAG, "esp_ota_end failed: %s", esp_err_to_name(err));
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
|
"OTA validation failed");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
err = esp_ota_set_boot_partition(update_partition);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGE(TAG, "esp_ota_set_boot_partition failed: %s", esp_err_to_name(err));
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
|
"Set boot partition failed");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
ESP_LOGI(TAG, "OTA update successful! Rebooting to partition '%s'...",
|
|
update_partition->label);
|
|
|
|
const char *resp = "{\"status\":\"ok\",\"message\":\"OTA update successful. Rebooting...\"}";
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_send(req, resp, strlen(resp));
|
|
return ESP_OK;
|
|
}
|
|
|
|
static esp_err_t ota_upload_handler(httpd_req_t *req)
|
|
{
|
|
esp_err_t ret = ota_upload_receive(req);
|
|
|
|
/* The deepest path this server runs is esp_ota_end() -> esp_image_verify().
|
|
* Log the headroom it left so the stack size is set from evidence, not
|
|
* guessed a second time. */
|
|
ota_update_log_httpd_stack("POST /ota");
|
|
|
|
if (ret == ESP_OK) {
|
|
/* Delay briefly to let the response flush, then reboot. */
|
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
|
esp_restart();
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
/** Internal: start the HTTP server and register OTA endpoints. */
|
|
static esp_err_t ota_start_server(httpd_handle_t *out_handle)
|
|
{
|
|
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
|
config.server_port = OTA_PORT;
|
|
/* HTTPD_DEFAULT_CONFIG gives 4096 B. MEASURED 2026-09-29 on an S3 (node 4,
|
|
* v0.8.12): POST /ota overflowed it every time ("stack overflow in task
|
|
* httpd", RTC_SW_CPU_RST), so no OTA had ever completed on this firmware.
|
|
* See CONFIG_OTA_HTTPD_STACK_SIZE for the sizing. */
|
|
config.stack_size = CONFIG_OTA_HTTPD_STACK_SIZE;
|
|
config.max_uri_handlers = 12; /* Extra slots for WASM endpoints (ADR-040). */
|
|
/* Increase receive timeout for large uploads. */
|
|
config.recv_wait_timeout = 30;
|
|
|
|
httpd_handle_t server = NULL;
|
|
esp_err_t err = httpd_start(&server, &config);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGE(TAG, "Failed to start OTA HTTP server on port %d: %s",
|
|
OTA_PORT, esp_err_to_name(err));
|
|
if (out_handle) *out_handle = NULL;
|
|
return err;
|
|
}
|
|
|
|
httpd_uri_t status_uri = {
|
|
.uri = "/ota/status",
|
|
.method = HTTP_GET,
|
|
.handler = ota_status_handler,
|
|
.user_ctx = NULL,
|
|
};
|
|
httpd_register_uri_handler(server, &status_uri);
|
|
|
|
httpd_uri_t upload_uri = {
|
|
.uri = "/ota",
|
|
.method = HTTP_POST,
|
|
.handler = ota_upload_handler,
|
|
.user_ctx = NULL,
|
|
};
|
|
httpd_register_uri_handler(server, &upload_uri);
|
|
|
|
ESP_LOGI(TAG, "OTA HTTP server started on port %d", OTA_PORT);
|
|
ESP_LOGI(TAG, " GET /ota/status — firmware version info");
|
|
ESP_LOGI(TAG, " POST /ota — upload new firmware binary");
|
|
|
|
if (out_handle) *out_handle = server;
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* Load the OTA PSK from NVS into the module-local s_ota_psk cache and log
|
|
* the resulting posture. Called by both ota_update_init() and
|
|
* ota_update_init_ex() so the per-boot diagnostic prints no matter which
|
|
* entry point main.c uses — historically only ota_update_init() loaded the
|
|
* PSK, which left ota_update_init_ex() with an empty s_ota_psk and an
|
|
* invisible fail-closed posture (RuView#596 follow-up).
|
|
*/
|
|
static void ota_load_psk_from_nvs(void)
|
|
{
|
|
nvs_handle_t nvs;
|
|
if (nvs_open(OTA_NVS_NAMESPACE, NVS_READONLY, &nvs) == ESP_OK) {
|
|
size_t len = sizeof(s_ota_psk);
|
|
if (nvs_get_str(nvs, OTA_NVS_KEY, s_ota_psk, &len) == ESP_OK) {
|
|
ESP_LOGI(TAG, "OTA PSK loaded from NVS (%d chars) — authentication enabled", (int)len - 1);
|
|
} else {
|
|
ESP_LOGW(TAG, "No OTA PSK in NVS — OTA upload endpoint will REJECT all requests until "
|
|
"provisioned (provision.py --ota-psk <hex>). Fail-closed per RuView#596.");
|
|
}
|
|
nvs_close(nvs);
|
|
} else {
|
|
ESP_LOGW(TAG, "NVS namespace '%s' not found — OTA upload endpoint will REJECT all "
|
|
"requests until provisioned. Fail-closed per RuView#596.", OTA_NVS_NAMESPACE);
|
|
}
|
|
}
|
|
|
|
esp_err_t ota_update_init(void)
|
|
{
|
|
/* ADR-050: Load OTA PSK from NVS if provisioned. */
|
|
ota_load_psk_from_nvs();
|
|
return ota_start_server(NULL);
|
|
}
|
|
|
|
esp_err_t ota_update_init_ex(void **out_server)
|
|
{
|
|
/* ADR-050: Load OTA PSK from NVS if provisioned. main.c uses this
|
|
* variant (not ota_update_init), so without this call s_ota_psk
|
|
* stayed empty forever and the fail-closed posture was invisible
|
|
* in serial logs. */
|
|
ota_load_psk_from_nvs();
|
|
return ota_start_server((httpd_handle_t *)out_server);
|
|
}
|