/* SPDX-License-Identifier: GPL-3.0-only */ #include "web_lifecycle_settings.h" #include #include #include #include "admin_ssh_console.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "secure_random.h" #include "web_cookie_auth.h" #include "web_httpd_adapter.h" #include "web_server.h" #include "web_security.h" #if CONFIG_HTTPD_QUEUE_WORK_BLOCKING #error "Lifecycle ACK handoff requires nonblocking HTTPD work submission" #endif enum { IDLE, PENDING, EXECUTING, OK, FAILED, CANCELLED }; static const char *const s_states[] = {"idle", "pending", "pending", "ok", "failed", "cancelled"}; static const char *const s_actions[] = {"stop", "restart", "reboot", "rotate"}; typedef struct { uint32_t id, generation, identity_generation; web_session_id_t session; user_principal_t principal; int64_t ack_deadline, deadline; unsigned action, state; bool queued; } lifecycle_operation_t; static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; static lifecycle_operation_t s_operation; static uint32_t s_next_id, s_ack_id; /* Comparison only; never dereferenced outside the invoking HTTPD handler. */ static httpd_handle_t s_ack_server; static void cancel_locked(void) { s_operation.state = CANCELLED; secure_wipe(&s_operation.principal, sizeof(s_operation.principal)); } static void expire_locked(int64_t now) { if (s_operation.state == PENDING && now >= (s_operation.queued ? s_operation.deadline : s_operation.ack_deadline)) cancel_locked(); } /* Exactly action + service generation, plus identity generation only for rotate. * No escapes, duplicates, coercions or extra fields. */ static bool parse(const char *body, size_t length, lifecycle_operation_t *operation) { const char *keys[] = {"action", "generation", "identity_generation"}; unsigned seen = 0; size_t pos = 0; #define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos #define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0) TAKE('{'); for (unsigned field = 0; field < 3; ++field) { if (field) { TAKE(','); } TAKE('"'); size_t start = pos; while (pos < length && body[pos] != '"') ++pos; if (pos == length) return false; unsigned key = 0; for (; key < 3; ++key) if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break; if (key == 3 || (seen & (1U << key))) return false; ++pos; TAKE(':'); SPACE(); if (key == 0) { TAKE('"'); start = pos; while (pos < length && body[pos] != '"') ++pos; if (pos == length) return false; unsigned action = 0; for (; action < 4; ++action) if (strlen(s_actions[action]) == pos - start && !memcmp(body + start, s_actions[action], pos - start)) break; if (action == 4) return false; operation->action = action; ++pos; } else { uint32_t number = 0; start = pos; while (pos < length && body[pos] >= '0' && body[pos] <= '9') { unsigned digit = (unsigned)(body[pos++] - '0'); if (number > (UINT32_MAX - digit) / 10U) return false; number = number * 10U + digit; } if (pos == start || (pos - start > 1 && body[start] == '0')) return false; if (key == 1) operation->generation = number; else operation->identity_generation = number; } seen |= 1U << key; SPACE(); if (pos < length && body[pos] == '}') break; } TAKE('}'); SPACE(); #undef TAKE #undef SPACE return pos == length && seen == (operation->action == 3 ? 7U : 3U) && operation->generation && operation->generation != UINT32_MAX && (operation->action != 3 || (operation->identity_generation && operation->identity_generation != UINT32_MAX)); } /* Runs on HTTPD after its synchronous response handler returns. No socket IO, * wait, authorization or lifecycle call here. A duplicate/late ID is inert. */ static void ack_handoff(void *argument) { uint32_t id = (uint32_t)(uintptr_t)argument; int64_t now = esp_timer_get_time(); taskENTER_CRITICAL(&s_lock); bool submit = id && s_ack_id == id; if (submit) { s_ack_id = 0; s_ack_server = NULL; expire_locked(now); submit = s_operation.id == id && s_operation.state == PENDING; if (submit) s_operation.queued = true; } taskEXIT_CRITICAL(&s_lock); if (submit && admin_ssh_console_submit_lifecycle_settings(id) != ESP_OK) { taskENTER_CRITICAL(&s_lock); if (s_operation.id == id && s_operation.state == PENDING) cancel_locked(); taskEXIT_CRITICAL(&s_lock); } } void web_lifecycle_settings_stopped(httpd_handle_t server) { taskENTER_CRITICAL(&s_lock); if (server && s_ack_server == server) { s_ack_id = 0; s_ack_server = NULL; } /* Successful shutdown invalidates all old logins. Executing work owns its * slot until return, including its deliberately session-invalidating stop. */ if (s_operation.state == PENDING) cancel_locked(); taskEXIT_CRITICAL(&s_lock); } void web_lifecycle_settings_execute(uint32_t id) { lifecycle_operation_t operation = {0}; int64_t now = esp_timer_get_time(); taskENTER_CRITICAL(&s_lock); expire_locked(now); bool execute = id && s_operation.id == id && s_operation.state == PENDING && s_operation.queued; if (execute) { s_operation.state = EXECUTING; operation = s_operation; secure_wipe(&s_operation.principal, sizeof(s_operation.principal)); } taskEXIT_CRITICAL(&s_lock); if (!execute) return; bool current = false; esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, ¤t); unsigned state = CANCELLED; if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN && esp_timer_get_time() < operation.deadline) { bool committed = false; error = operation.action == 0 ? web_server_stop_current(operation.generation) : operation.action == 1 ? web_server_restart_current(operation.generation) : operation.action == 2 ? web_server_reboot_current(operation.generation) : web_server_replace_identity(operation.generation, operation.identity_generation, false, &committed); /* Even INVALID_STATE can be a detach failure after stop admission. */ state = error == ESP_OK ? OK : FAILED; } taskENTER_CRITICAL(&s_lock); if (s_operation.id == id && s_operation.state == EXECUTING) s_operation.state = state; taskEXIT_CRITICAL(&s_lock); secure_wipe(&operation, sizeof(operation)); } 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); return web_httpd_unread_body(request) ? ESP_FAIL : error; } esp_err_t web_lifecycle_operation_handler(httpd_req_t *request) { web_session_view_t view = {0}; lifecycle_operation_t operation = {0}; bool allowed = false, mutation = request->method == HTTP_POST; esp_err_t error = mutation ? web_cookie_auth_require_json(request, 256, &view, &allowed) : web_cookie_auth_require(request, false, false, &view, &allowed); if (error != ESP_OK || !allowed) goto done; if (view.principal.role != USER_ROLE_ADMIN) { error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); goto done; } if (mutation) { char type[40] = {0}, body[256]; size_t received = 0; bool valid = request->content_len && request->content_len <= sizeof(body) && httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK && (!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8")); for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) { int count = httpd_req_recv(request, body + received, request->content_len - received); if (count <= 0 || (size_t)count > request->content_len - received) valid = false; else received += (size_t)count; } valid = valid && received == request->content_len && parse(body, received, &operation); secure_wipe(body, sizeof(body)); if (!valid) { error = respond(request, "400 Bad Request", "{\"error\":\"invalid_lifecycle_request\"}"); goto done; } operation.session = view.id; operation.principal = view.principal; int64_t now = esp_timer_get_time(); operation.ack_deadline = now + 2000000LL; operation.deadline = now + 30000000LL; operation.state = PENDING; taskENTER_CRITICAL(&s_lock); expire_locked(now); bool busy = s_ack_id || s_operation.state == PENDING || s_operation.state == EXECUTING || s_next_id == UINT32_MAX; if (!busy) { operation.id = ++s_next_id; s_operation = operation; s_ack_id = operation.id; s_ack_server = request->handle; } taskEXIT_CRITICAL(&s_lock); if (busy) { error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}"); goto done; } } else { int64_t now = esp_timer_get_time(); taskENTER_CRITICAL(&s_lock); expire_locked(now); if (s_operation.session == view.id) { operation.id = s_operation.id; operation.state = s_operation.state; operation.action = s_operation.action; } taskEXIT_CRITICAL(&s_lock); } char response[96]; int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}", operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]); error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : respond(request, mutation ? "202 Accepted" : "200 OK", response); if (mutation) { /* No lifecycle can run before this send returns and HTTPD hands off. * Successful send is not peer receipt. Never retry queue submission. */ if (error != ESP_OK || httpd_queue_work(request->handle, ack_handoff, (void *)(uintptr_t)operation.id) != ESP_OK) { taskENTER_CRITICAL(&s_lock); if (s_ack_id == operation.id) { s_ack_id = 0; s_ack_server = NULL; } if (s_operation.id == operation.id && s_operation.state == PENDING) cancel_locked(); taskEXIT_CRITICAL(&s_lock); } } done: secure_wipe(&operation, sizeof(operation)); secure_wipe(&view, sizeof(view)); web_httpd_wipe_request(request, web_httpd_unread_body(request)); return error; } esp_err_t web_lifecycle_settings_handler(httpd_req_t *request) { web_session_view_t view = {0}; bool allowed = false; esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed); if (error != ESP_OK || !allowed) goto done; if (view.principal.role != USER_ROLE_ADMIN) { error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); goto done; } web_server_management_snapshot_t snapshot; if (web_server_get_management_snapshot(&snapshot) != ESP_OK) { error = respond(request, "503 Service Unavailable", "{\"error\":\"lifecycle_unavailable\"}"); goto done; } web_security_identity_snapshot_t identity = {0}; bool available = web_security_get_identity_snapshot(&identity) == ESP_OK; char fingerprint[65] = {0}; if (available) { for (size_t i = 0; i < sizeof(identity.fingerprint); ++i) snprintf(fingerprint + i * 2, 3, "%02x", identity.fingerprint[i]); } char response[320]; int written = snprintf(response, sizeof(response), "{\"generation\":%" PRIu32 ",\"running\":%s,\"transitioning\":%s,\"controllable\":%s," "\"identity_generation\":%" PRIu32 ",\"fingerprint\":\"%s\",\"rotatable\":%s}", snapshot.generation, snapshot.running ? "true" : "false", snapshot.transitioning ? "true" : "false", snapshot.controllable ? "true" : "false", available ? identity.generation : 0, fingerprint, available && snapshot.controllable && !identity.busy && identity.generation && identity.generation != UINT32_MAX ? "true" : "false"); error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : respond(request, "200 OK", response); done: secure_wipe(&view, sizeof(view)); web_httpd_wipe_request(request, web_httpd_unread_body(request)); return error; }