Implement HTTPS lifecycle and reboot controls
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_lifecycle_settings.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#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"
|
||||
|
||||
#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"};
|
||||
typedef struct {
|
||||
uint32_t id, 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 + generation; 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"};
|
||||
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 < 2; ++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 < 2; ++key)
|
||||
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
|
||||
if (key == 2 || (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 < 3; ++action)
|
||||
if (strlen(s_actions[action]) == pos - start && !memcmp(body + start, s_actions[action], pos - start)) break;
|
||||
if (action == 3) 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;
|
||||
operation->generation = number;
|
||||
}
|
||||
seen |= 1U << key;
|
||||
}
|
||||
TAKE('}'); SPACE();
|
||||
#undef TAKE
|
||||
#undef SPACE
|
||||
return pos == length && seen == 3 && operation->generation && operation->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) {
|
||||
error = operation.action == 0 ? web_server_stop_current(operation.generation) :
|
||||
operation.action == 1 ? web_server_restart_current(operation.generation) :
|
||||
web_server_reboot_current(operation.generation);
|
||||
/* 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;
|
||||
}
|
||||
char response[128];
|
||||
int written = snprintf(response, sizeof(response),
|
||||
"{\"generation\":%" PRIu32 ",\"running\":%s,\"transitioning\":%s,\"controllable\":%s}",
|
||||
snapshot.generation, snapshot.running ? "true" : "false",
|
||||
snapshot.transitioning ? "true" : "false", snapshot.controllable ? "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;
|
||||
}
|
||||
Reference in New Issue
Block a user