Implement HTTPS lifecycle and reboot controls
This commit is contained in:
@@ -37,6 +37,7 @@ idf_component_register(
|
||||
"web_display_settings.c"
|
||||
"web_broker_settings.c"
|
||||
"web_ssh_settings.c"
|
||||
"web_lifecycle_settings.c"
|
||||
"web_admin_tickets.c"
|
||||
"web_admin_transport.c"
|
||||
"web_assets_data.c"
|
||||
|
||||
+17
-2
@@ -22,6 +22,7 @@
|
||||
#include "web_display_settings.h"
|
||||
#include "web_broker_settings.h"
|
||||
#include "web_ssh_settings.h"
|
||||
#include "web_lifecycle_settings.h"
|
||||
|
||||
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
|
||||
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
|
||||
@@ -93,6 +94,7 @@ typedef enum {
|
||||
ADMIN_REQUEST_DISPLAY_SETTINGS,
|
||||
ADMIN_REQUEST_BROKER_SETTINGS,
|
||||
ADMIN_REQUEST_SSH_SETTINGS,
|
||||
ADMIN_REQUEST_LIFECYCLE_SETTINGS,
|
||||
} admin_request_origin_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -109,6 +111,7 @@ typedef struct {
|
||||
uint32_t display_settings_id;
|
||||
uint32_t broker_settings_id;
|
||||
uint32_t ssh_settings_id;
|
||||
uint32_t lifecycle_settings_id;
|
||||
};
|
||||
} admin_request_t;
|
||||
|
||||
@@ -724,6 +727,16 @@ esp_err_t admin_ssh_console_submit_ssh_settings(uint32_t id)
|
||||
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
esp_err_t admin_ssh_console_submit_lifecycle_settings(uint32_t id)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_dispatch_ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready || !id) return ESP_ERR_INVALID_STATE;
|
||||
admin_request_t request = {.origin = ADMIN_REQUEST_LIFECYCLE_SETTINGS, .lifecycle_settings_id = id};
|
||||
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
static void worker_task(void *context)
|
||||
{
|
||||
(void)context;
|
||||
@@ -734,13 +747,15 @@ static void worker_task(void *context)
|
||||
}
|
||||
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS ||
|
||||
request.origin == ADMIN_REQUEST_NETWORK_SETTINGS || request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS ||
|
||||
request.origin == ADMIN_REQUEST_BROKER_SETTINGS || request.origin == ADMIN_REQUEST_SSH_SETTINGS) {
|
||||
request.origin == ADMIN_REQUEST_BROKER_SETTINGS || request.origin == ADMIN_REQUEST_SSH_SETTINGS ||
|
||||
request.origin == ADMIN_REQUEST_LIFECYCLE_SETTINGS) {
|
||||
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) web_serial_settings_execute(request.serial_settings_id);
|
||||
else if (request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) web_account_settings_execute(request.account_settings_id);
|
||||
else if (request.origin == ADMIN_REQUEST_NETWORK_SETTINGS) web_network_settings_execute(request.network_settings_id);
|
||||
else if (request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS) web_display_settings_execute(request.display_settings_id);
|
||||
else if (request.origin == ADMIN_REQUEST_BROKER_SETTINGS) web_broker_settings_execute(request.broker_settings_id);
|
||||
else web_ssh_settings_execute(request.ssh_settings_id);
|
||||
else if (request.origin == ADMIN_REQUEST_SSH_SETTINGS) web_ssh_settings_execute(request.ssh_settings_id);
|
||||
else web_lifecycle_settings_execute(request.lifecycle_settings_id);
|
||||
secure_wipe(&request, sizeof(request));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ esp_err_t admin_ssh_console_submit_network_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_display_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_broker_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_ssh_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_lifecycle_settings(uint32_t id);
|
||||
|
||||
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
|
||||
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include "esp_http_server.h"
|
||||
|
||||
esp_err_t web_lifecycle_settings_handler(httpd_req_t *request);
|
||||
esp_err_t web_lifecycle_operation_handler(httpd_req_t *request);
|
||||
/* Existing dispatcher only; callbacks submit IDs, never execute lifecycle work. */
|
||||
void web_lifecycle_settings_execute(uint32_t id);
|
||||
/* Only after successful HTTPD destruction, before another server can start. */
|
||||
void web_lifecycle_settings_stopped(httpd_handle_t server);
|
||||
+94
-16
@@ -13,6 +13,7 @@
|
||||
#include "esp_log.h"
|
||||
#include "esp_netif_ip_addr.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_system.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "secure_random.h"
|
||||
@@ -29,6 +30,7 @@
|
||||
#include "web_display_settings.h"
|
||||
#include "web_broker_settings.h"
|
||||
#include "web_ssh_settings.h"
|
||||
#include "web_lifecycle_settings.h"
|
||||
#include "web_admin_transport.h"
|
||||
#include "web_session_store.h"
|
||||
#include "web_cookie_auth.h"
|
||||
@@ -45,6 +47,8 @@ static SemaphoreHandle_t s_server_mutex;
|
||||
static httpd_handle_t s_server;
|
||||
static bool s_initialized;
|
||||
static bool s_transitioning;
|
||||
/* Firmware-lifetime lifecycle fence, independent of counters and handle reuse. */
|
||||
static uint32_t s_generation = 1U;
|
||||
static bool s_serial_transport_init_attempted;
|
||||
static bool s_serial_transport_initialized;
|
||||
static bool s_serial_transport_attached;
|
||||
@@ -416,6 +420,15 @@ static const httpd_uri_t s_account_generate_password_uri = {
|
||||
static const httpd_uri_t s_network_uri = {
|
||||
.uri = "/api/settings/network", .method = HTTP_GET, .handler = web_network_snapshot_handler,
|
||||
};
|
||||
static const httpd_uri_t s_lifecycle_settings_uri = {
|
||||
.uri = "/api/settings/lifecycle", .method = HTTP_GET, .handler = web_lifecycle_settings_handler,
|
||||
};
|
||||
static const httpd_uri_t s_lifecycle_operation_get_uri = {
|
||||
.uri = "/api/settings/lifecycle-operation", .method = HTTP_GET, .handler = web_lifecycle_operation_handler,
|
||||
};
|
||||
static const httpd_uri_t s_lifecycle_operation_post_uri = {
|
||||
.uri = "/api/settings/lifecycle-operation", .method = HTTP_POST, .handler = web_lifecycle_operation_handler,
|
||||
};
|
||||
static const httpd_uri_t s_ssh_settings_uri = {
|
||||
.uri = "/api/settings/ssh", .method = HTTP_GET, .handler = web_ssh_settings_handler,
|
||||
};
|
||||
@@ -619,30 +632,27 @@ esp_err_t web_server_init(void)
|
||||
s_serial_transport_error = serial_transport_error;
|
||||
s_serial_transport_initialized = serial_transport_error == ESP_OK;
|
||||
}
|
||||
s_initialized = true;
|
||||
if (s_last_error == ESP_ERR_INVALID_STATE) {
|
||||
if (!s_initialized && s_last_error == ESP_ERR_INVALID_STATE) {
|
||||
s_last_error = ESP_OK;
|
||||
}
|
||||
s_initialized = true;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
|
||||
/* The Phase 5A HTTPS recovery surface remains available if WebSocket setup fails. */
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t web_server_start(void)
|
||||
static esp_err_t start_server(bool reserved)
|
||||
{
|
||||
esp_err_t error = web_server_init();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t error;
|
||||
bool serial_transport_ready;
|
||||
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||
if (s_server != NULL || s_transitioning) {
|
||||
if (s_server != NULL || s_transitioning != reserved) {
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
s_transitioning = true;
|
||||
if (s_generation != UINT32_MAX) ++s_generation;
|
||||
serial_transport_ready = s_serial_transport_initialized;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
|
||||
@@ -665,7 +675,7 @@ esp_err_t web_server_start(void)
|
||||
config.httpd.max_open_sockets = 6;
|
||||
config.httpd.max_uri_handlers =
|
||||
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
|
||||
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 22U;
|
||||
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 25U;
|
||||
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
|
||||
config.httpd.lru_purge_enable = false;
|
||||
config.httpd.recv_wait_timeout = 1;
|
||||
@@ -741,6 +751,10 @@ esp_err_t web_server_start(void)
|
||||
web_httpd_register_optional_get(server, &s_ssh_operation_get_uri) == ESP_OK &&
|
||||
web_httpd_register_optional(server, &s_ssh_operation_post_uri) != ESP_OK)
|
||||
(void)httpd_unregister_uri_handler(server, s_ssh_operation_get_uri.uri, HTTP_GET);
|
||||
if (web_httpd_register_optional_get(server, &s_lifecycle_settings_uri) == ESP_OK &&
|
||||
web_httpd_register_optional_get(server, &s_lifecycle_operation_get_uri) == ESP_OK &&
|
||||
web_httpd_register_optional(server, &s_lifecycle_operation_post_uri) != ESP_OK)
|
||||
(void)httpd_unregister_uri_handler(server, s_lifecycle_operation_get_uri.uri, HTTP_GET);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
web_cookie_auth_stop();
|
||||
@@ -750,6 +764,7 @@ esp_err_t web_server_start(void)
|
||||
if (cleanup_error == ESP_OK) cleanup_error = httpd_ssl_stop(server);
|
||||
if (cleanup_error == ESP_OK) {
|
||||
web_httpd_idle_stopped(server);
|
||||
web_lifecycle_settings_stopped(server);
|
||||
server = NULL;
|
||||
} else {
|
||||
/* Retain ownership so stop can retry and start cannot allocate a second server. */
|
||||
@@ -775,14 +790,23 @@ esp_err_t web_server_start(void)
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_server_stop(void)
|
||||
esp_err_t web_server_start(void)
|
||||
{
|
||||
esp_err_t error = web_server_init();
|
||||
return error == ESP_OK ? start_server(false) : error;
|
||||
}
|
||||
|
||||
static esp_err_t stop_server(uint32_t expected_generation, bool restart)
|
||||
{
|
||||
if (s_server_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||
if (s_server == NULL || s_transitioning) {
|
||||
if (xSemaphoreTake(s_server_mutex, expected_generation ? 0U : portMAX_DELAY) != pdTRUE)
|
||||
return ESP_ERR_TIMEOUT;
|
||||
if (s_server == NULL || s_transitioning ||
|
||||
(expected_generation && (expected_generation != s_generation ||
|
||||
s_generation == UINT32_MAX || s_last_error != ESP_OK))) {
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
@@ -791,6 +815,7 @@ esp_err_t web_server_stop(void)
|
||||
bool admin_transport_owned = s_admin_transport_owned;
|
||||
esp_err_t serial_transport_error = s_serial_transport_error;
|
||||
s_transitioning = true;
|
||||
if (s_generation != UINT32_MAX) ++s_generation;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
|
||||
web_cookie_auth_stop();
|
||||
@@ -829,7 +854,10 @@ esp_err_t web_server_stop(void)
|
||||
}
|
||||
|
||||
esp_err_t error = httpd_ssl_stop(server);
|
||||
if (error == ESP_OK) web_httpd_idle_stopped(server);
|
||||
if (error == ESP_OK) {
|
||||
web_httpd_idle_stopped(server);
|
||||
web_lifecycle_settings_stopped(server);
|
||||
}
|
||||
if (error == ESP_OK && admin_transport_owned) web_admin_transport_stopped(server);
|
||||
if (error != ESP_OK && serial_transport_attached) {
|
||||
/* Stay detached: old HTTPD work may still be reading static TX storage. */
|
||||
@@ -837,7 +865,8 @@ esp_err_t web_server_stop(void)
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||
s_transitioning = false;
|
||||
/* Do not expose a stopped/unreserved gap to another lifecycle caller. */
|
||||
s_transitioning = error == ESP_OK && restart;
|
||||
s_last_error = error;
|
||||
s_serial_transport_error = serial_transport_error;
|
||||
s_serial_transport_attached = false;
|
||||
@@ -847,7 +876,56 @@ esp_err_t web_server_stop(void)
|
||||
++s_counters.stops;
|
||||
}
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
return error;
|
||||
return error == ESP_OK && restart ? start_server(true) : error;
|
||||
}
|
||||
|
||||
esp_err_t web_server_stop(void)
|
||||
{
|
||||
return stop_server(0U, false);
|
||||
}
|
||||
|
||||
esp_err_t web_server_stop_current(uint32_t expected_generation)
|
||||
{
|
||||
if (!expected_generation) return ESP_ERR_INVALID_ARG;
|
||||
return stop_server(expected_generation, false);
|
||||
}
|
||||
|
||||
esp_err_t web_server_restart_current(uint32_t expected_generation)
|
||||
{
|
||||
if (!expected_generation) return ESP_ERR_INVALID_ARG;
|
||||
return stop_server(expected_generation, true);
|
||||
}
|
||||
|
||||
esp_err_t web_server_reboot_current(uint32_t expected_generation)
|
||||
{
|
||||
if (!expected_generation) return ESP_ERR_INVALID_ARG;
|
||||
if (s_server_mutex == NULL) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_server_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
if (s_server == NULL || s_transitioning || s_last_error != ESP_OK ||
|
||||
s_generation == UINT32_MAX || expected_generation != s_generation) {
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
s_transitioning = true;
|
||||
++s_generation;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
esp_restart();
|
||||
return ESP_FAIL; /* Defensive only: reset normally never returns. */
|
||||
}
|
||||
|
||||
esp_err_t web_server_get_management_snapshot(web_server_management_snapshot_t *snapshot)
|
||||
{
|
||||
if (snapshot == NULL) return ESP_ERR_INVALID_ARG;
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
if (s_server_mutex == NULL) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_server_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
snapshot->generation = s_generation;
|
||||
snapshot->running = s_server != NULL;
|
||||
snapshot->transitioning = s_transitioning;
|
||||
snapshot->controllable = s_initialized && s_server != NULL && !s_transitioning &&
|
||||
s_last_error == ESP_OK && s_generation != UINT32_MAX;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t web_server_get_snapshot(web_server_snapshot_t *snapshot)
|
||||
|
||||
+25
-1
@@ -39,7 +39,31 @@ typedef struct {
|
||||
/* Initialize runtime state without requiring valid certificate material. */
|
||||
esp_err_t web_server_init(void);
|
||||
|
||||
/* Start one TLS-only server on all active network interfaces. */
|
||||
typedef struct {
|
||||
uint32_t generation;
|
||||
bool running;
|
||||
bool transitioning;
|
||||
bool controllable;
|
||||
} web_server_management_snapshot_t;
|
||||
|
||||
/* Zero-wait, secret-free projection; controllable excludes failed cleanup and
|
||||
* exhausted generations. No HTTPD work or owner wait is performed. */
|
||||
esp_err_t web_server_get_management_snapshot(web_server_management_snapshot_t *snapshot);
|
||||
|
||||
/* Conditional lifecycle admission under the canonical server mutex. Call only
|
||||
* off HTTPD, after caller-owned authorization and bounded ACK handoff. These
|
||||
* APIs do not authenticate, acknowledge, cancel on revocation, or bound HTTPD
|
||||
* shutdown time. Restart reserves the lifecycle through stop and start; a failed
|
||||
* stop never starts another server. Stale/exhausted/unclean state rejects without
|
||||
* side effects. Canonical stop/start below remain the recovery path. */
|
||||
esp_err_t web_server_stop_current(uint32_t expected_generation);
|
||||
esp_err_t web_server_restart_current(uint32_t expected_generation);
|
||||
/* Reserves this HTTPS generation before canonical whole-device esp_restart().
|
||||
* Admission cannot be cancelled; normally does not return. Same caller rules. */
|
||||
esp_err_t web_server_reboot_current(uint32_t expected_generation);
|
||||
|
||||
/* Start one TLS-only server on all active network interfaces.
|
||||
* Start/stop may wait for HTTPD; never call from its task or queued callbacks. */
|
||||
esp_err_t web_server_start(void);
|
||||
esp_err_t web_server_stop(void);
|
||||
|
||||
|
||||
+68
-4
@@ -190,7 +190,14 @@ static const char s_index_html[] =
|
||||
"<div id=\"settings-navigation\" class=\"serial-actions\"><button id=\"settings-serial\" class=\"button\" type=\"button\" aria-pressed=\"true\">Serial settings</button>"
|
||||
"<button id=\"settings-accounts\" class=\"button\" type=\"button\" aria-pressed=\"false\">Accounts</button>"
|
||||
"<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></div>"
|
||||
"<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 service and device reboot</h2>"
|
||||
"<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>"
|
||||
"<button id=\"lifecycle-network\" class=\"button\" type=\"button\">Open existing Network / Wi-Fi controls</button>"
|
||||
"<button id=\"lifecycle-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"lifecycle-detail\" class=\"connection-detail\" role=\"status\"></p>"
|
||||
"<div class=\"serial-actions\"><button id=\"lifecycle-stop\" class=\"button\" type=\"button\">Stop HTTPS…</button><button id=\"lifecycle-restart\" class=\"button\" type=\"button\">Restart HTTPS…</button><button id=\"lifecycle-reboot\" class=\"button\" type=\"button\">Reboot device…</button><button id=\"lifecycle-result\" class=\"button\" type=\"button\">Check Operation Result</button></div>"
|
||||
"<p id=\"lifecycle-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. Acknowledgement is not peer receipt or completion. Connection loss, expiry, revocation or timeout does not prove cancellation after admission. No automatic mutation retry or restore. Check Result, inspect state, then act explicitly.</p><a href=\"/\">Reload / sign in after recovery</a></div>\n"
|
||||
"<div id=\"ssh-settings\" hidden><h2>SSH service and sessions</h2><p class=\"connection-detail\">SSH only, TCP port 22. Start/Stop do not change saved settings or host identity. Stop closes all SSH sessions, including any admitted before execution; an SSH administrator's already executing command may finish. HTTPS login, browser terminals, Wi-Fi, USB and UART0 are not stopped. Targeted disconnect affects only the selected SSH connection, not all logins for its account. Viewing or selecting never changes services or writer ownership.</p><button id=\"ssh-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"ssh-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"ssh-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Disconnect SSH session<select id=\"ssh-target\"><option value=\"\">Select a session</option><option id=\"ssh-option-0\" hidden disabled></option><option id=\"ssh-option-1\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"ssh-start\" class=\"button\" type=\"button\">Start SSH…</button><button id=\"ssh-stop\" class=\"button\" type=\"button\">Stop SSH…</button><button id=\"ssh-disconnect\" class=\"button\" type=\"button\">Disconnect selected…</button><button id=\"ssh-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"ssh-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. After submission use Check Operation Result, then Refresh. Navigation or timeout does not cancel admitted work. No automatic mutation retry.</p></div>\n"
|
||||
"<div id=\"broker-settings\" hidden><h2>Broker clients and writer</h2><p class=\"connection-detail\">One writer, multiple isolated observers. Viewing, refreshing and selecting do not change the lease or either terminal. Assignment revokes the previous writer, without recalling bytes already accepted by UART. Any intervening lease transition rejects stale confirmation, even release and reacquire by the same writer.</p><p class=\"connection-detail\">Pending and high-water are bounded output bytes; dropped counts cover this connection or the last shell counter clear. No UART data is consumed. Refresh retains explicit selection without renewing its lease token. Stale selections require choosing the blank option then the target again. No persistence or disconnect controls.</p><button id=\"broker-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"broker-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"broker-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Assign writer to<select id=\"broker-target\"><option value=\"\">Select a connected client</option><option id=\"broker-option-0\" hidden disabled></option><option id=\"broker-option-1\" hidden disabled></option><option id=\"broker-option-2\" hidden disabled></option><option id=\"broker-option-3\" hidden disabled></option><option id=\"broker-option-4\" hidden disabled></option><option id=\"broker-option-5\" hidden disabled></option><option id=\"broker-option-6\" hidden disabled></option><option id=\"broker-option-7\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"broker-assign\" class=\"button\" type=\"button\">Assign writer…</button><button id=\"broker-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"broker-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. Navigation or timeout does not cancel admitted work. Check Result after uncertainty; no automatic mutation retry.</p></div>\n"
|
||||
"<div id=\"display-settings\" hidden><h2>Display</h2>\n"
|
||||
@@ -214,7 +221,7 @@ static const char s_index_html[] =
|
||||
"Start/Stop also change RAM enabled-at-boot; Save persists that policy. Reconnect/Next do nothing while stopped. "
|
||||
"Next selects the next enabled profile in priority order, wrapping.</p>"
|
||||
"<p class=\"connection-detail\">Network changes may disconnect HTTPS, SSH and both browser terminals before acknowledgement. Accepted is NOT connected. "
|
||||
"Recover through STA/AP, UART0 or network-independent native USB serial. Navigation itself preserves terminals and writer lease.</p>"
|
||||
"Restore STA/AP reachability or use UART0 administration; SSH recovery requires a reachable running SSH service. Native USB preserves network-independent UART1 serial access, not Wi-Fi administration. Navigation itself preserves terminals and writer lease.</p>"
|
||||
"<button id=\"network-refresh\" class=\"button\" type=\"button\">Refresh</button>"
|
||||
"<p id=\"network-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"network-summary\" class=\"settings-values\"></dl>"
|
||||
"<div id=\"network-edit\" hidden><h3>Wi-Fi working configuration</h3><div class=\"settings-edit\">"
|
||||
@@ -454,7 +461,7 @@ static const char s_app_js[] =
|
||||
"window.addEventListener('keydown', event => { if (quick && event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeQuick(true); } });\n"
|
||||
"function clearSettings() {\n"
|
||||
" resetQuick();\n"
|
||||
" clearAccounts(); clearNetwork(); clearDisplay(); clearBroker(); clearSsh();\n"
|
||||
" clearAccounts(); clearNetwork(); clearDisplay(); clearBroker(); clearSsh(); clearLifecycle();\n"
|
||||
" if (!serialAuto && serialOperationPending) element('serial-operation-detail').textContent = serialOutcomeWarning + 'Operation outcome pending or unknown. Select Check Result on return; navigation does not cancel backend work.';\n"
|
||||
" stopSerialAuto(true);\n"
|
||||
" if (settingsAbort) settingsAbort.abort();\n"
|
||||
@@ -471,6 +478,7 @@ static const char s_app_js[] =
|
||||
" if (settingsDomain === 'display') return refreshDisplay();\n"
|
||||
" if (settingsDomain === 'broker') return refreshBroker();\n"
|
||||
" if (settingsDomain === 'ssh') return refreshSsh();\n"
|
||||
" if (settingsDomain === 'lifecycle') return lifecycleRequest(null, true);\n"
|
||||
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || serialAuto) return;\n"
|
||||
" settingsHost.hidden = false;\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n"
|
||||
@@ -885,6 +893,61 @@ 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"
|
||||
"const lifecycleActions = ['stop','restart','reboot'];\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. A stuck ACK handoff requires canonical web stop then web start; this closes all web clients.';\n"
|
||||
"function lifecycleButtons() {\n"
|
||||
" const busy = !!lifecycleAbort;\n"
|
||||
" element('lifecycle-refresh').disabled = element('lifecycle-result').disabled = busy;\n"
|
||||
" for (const action of lifecycleActions) element('lifecycle-' + action).disabled = busy || lifecyclePending || !lifecycleSnapshot?.controllable;\n"
|
||||
"}\n"
|
||||
"function clearLifecycle() {\n"
|
||||
" if (lifecycleAbort) lifecycleAbort.abort(); lifecycleAbort = null; lifecycleSnapshot = null;\n"
|
||||
" element('lifecycle-detail').textContent = 'Select Refresh to inspect HTTPS state.';\n"
|
||||
" if (lifecyclePending) element('lifecycle-operation-detail').textContent = lifecycleRecovery + ' Navigation does not cancel admitted work.';\n"
|
||||
" 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"
|
||||
" let body; const detail = element(snapshotRead ? 'lifecycle-detail' : 'lifecycle-operation-detail');\n"
|
||||
" if (action) {\n"
|
||||
" if (!lifecycleActions.includes(action) || !lifecycleSnapshot?.controllable) return;\n"
|
||||
" const value = {action, generation:lifecycleSnapshot.generation};\n"
|
||||
" const scope = action === 'reboot' ? 'Reboot the ENTIRE device? ALL clients disconnect; SSH, USB and UART operation are interrupted during restart. Unsaved RAM-only working configuration and browser drafts may be lost.' : (action === 'stop' ? 'Stop HTTPS?' : 'Restart HTTPS?') + ' ALL web logins and BOTH browser terminal routes disconnect, including clients admitted before execution. Device working configuration and identity are unchanged; save unsaved browser drafts first. SSH, USB and UART0 are not stopped.';\n"
|
||||
" if (!window.confirm(scope + ' ' + lifecycleRecovery)) return;\n"
|
||||
" body = JSON.stringify(value);\n"
|
||||
" }\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; lifecycleAbort = controller; lifecycleButtons();\n"
|
||||
" const current = () => lifecycleAbort === controller && settingsDomain === 'lifecycle' && selected === 'settings';\n"
|
||||
" const deadline = window.setTimeout(() => { if (!current()) return; controller.abort(); lifecycleAbort = null; lifecycleSnapshot = null; detail.textContent = 'Request timed out. ' + lifecycleRecovery; lifecycleButtons(); }, 15000);\n"
|
||||
" controller.signal.addEventListener('abort', () => window.clearTimeout(deadline), {once:true});\n"
|
||||
" detail.textContent = snapshotRead ? 'Reading HTTPS; previous snapshot is stale.' : 'Checking/submitting once. ' + lifecycleRecovery;\n"
|
||||
" try {\n"
|
||||
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
|
||||
" if (action) { lifecyclePending = true; lifecycleAwaitingAck = true; lifecycleId = 0; lifecycleAction = action; lifecycleSnapshot = null; lifecycleButtons(); }\n"
|
||||
" const {status, payload:v} = await api(snapshotRead ? '/api/settings/lifecycle' : '/api/settings/lifecycle-operation', generation, {method:action ? 'POST' : 'GET', body, signal:controller.signal, limit:snapshotRead ? 128 : 96, current});\n"
|
||||
" if (snapshotRead) {\n"
|
||||
" if (status !== 200 || !v || Object.keys(v).length !== 4 || !brokerUint(v.generation) || !v.generation || typeof v.running !== 'boolean' || typeof v.transitioning !== 'boolean' || typeof v.controllable !== 'boolean' || (v.controllable && (!v.running || v.transitioning || v.generation === 4294967295))) throw new Error('Invalid lifecycle snapshot');\n"
|
||||
" lifecycleSnapshot = v;\n"
|
||||
" detail.textContent = (v.running ? 'HTTPS running. ' : 'HTTPS stopped. ') + (v.controllable ? 'Explicit confirmation required.' : 'Transition, failed cleanup or exhausted generation: use UART0/admin SSH recovery.');\n"
|
||||
" } else {\n"
|
||||
" if (status !== (action ? 202 : 200) || !v || Object.keys(v).length !== 3 || !brokerUint(v.id) || !['none',...lifecycleActions].includes(v.action) || !['idle','pending','ok','failed','cancelled'].includes(v.state) || ((v.id === 0) !== (v.state === 'idle')) || ((v.id === 0) !== (v.action === 'none')) || (action && (!v.id || v.action !== action || v.state !== 'pending'))) throw new Error('Invalid lifecycle result');\n"
|
||||
" const matched = !!action || (!lifecycleAwaitingAck && lifecycleId === v.id && lifecycleAction === v.action);\n"
|
||||
" if (!matched && lifecyclePending) { detail.textContent = 'Result cannot be matched to this submission (lost ACK or replaced result). ' + lifecycleRecovery; return; }\n"
|
||||
" lifecycleId = v.id; lifecycleAction = v.action; lifecyclePending = v.state === 'pending'; lifecycleAwaitingAck = false; lifecycleSnapshot = null;\n"
|
||||
" const messages = {idle:'No retained result. Inspect before any new action.',pending:'ACK handoff, queued or executing; do not resubmit. Check Result explicitly.',ok:'Completed at execution time; not proof of peer receipt.',failed:'Lifecycle failed or admission rejected; changes may already have occurred. Inspect before retrying.',cancelled:'Not executed: ACK/queue handoff, deadline or original login/currentness rejected before lifecycle admission.'};\n"
|
||||
" detail.textContent = v.action + ': ' + messages[v.state] + ' ' + lifecycleRecovery;\n"
|
||||
" element('lifecycle-detail').textContent = 'Snapshot stale. Refresh to inspect state; this never repeats a mutation.';\n"
|
||||
" }\n"
|
||||
" } catch (error) {\n"
|
||||
" if (live(generation) && current()) { lifecycleSnapshot = null; detail.textContent = (error.status ? error.message + ' ' : '') + lifecycleRecovery; }\n"
|
||||
" } finally { window.clearTimeout(deadline); if (current()) { lifecycleAbort = null; lifecycleButtons(); } }\n"
|
||||
"}\n"
|
||||
"element('settings-lifecycle').addEventListener('click', () => selectSettingsDomain('lifecycle'));\n"
|
||||
"element('lifecycle-network').addEventListener('click', () => selectSettingsDomain('network'));\n"
|
||||
"element('lifecycle-refresh').addEventListener('click', () => lifecycleRequest(null, true));\n"
|
||||
"element('lifecycle-result').addEventListener('click', () => lifecycleRequest(null));\n"
|
||||
"for (const action of lifecycleActions) element('lifecycle-' + action).addEventListener('click', () => lifecycleRequest(action));\n"
|
||||
"const sshActions = ['start','stop','disconnect'];\n"
|
||||
"let sshSnapshot = null, sshSelection = null, sshAbort = null, sshPending = false, sshAwaitingAck = false, sshId = 0, sshAction = '';\n"
|
||||
"const sshLabel = s => String(s.id) + ' / ' + ['Handshake','Serial','Admin console'][s.route] + ' / ' + (brokerName(s) || 'not authenticated');\n"
|
||||
@@ -1044,6 +1107,7 @@ static const char s_app_js[] =
|
||||
" element('serial-settings-content').hidden = domain !== 'serial'; element('account-settings').hidden = domain !== 'accounts'; element('network-settings').hidden = domain !== 'network';\n"
|
||||
" element('display-settings').hidden = domain !== 'display'; element('settings-display').setAttribute('aria-pressed', String(domain === 'display'));\n"
|
||||
" element('ssh-settings').hidden = domain !== 'ssh'; element('settings-ssh').setAttribute('aria-pressed', String(domain === 'ssh'));\n"
|
||||
" element('lifecycle-settings').hidden = domain !== 'lifecycle'; element('settings-lifecycle').setAttribute('aria-pressed', String(domain === 'lifecycle'));\n"
|
||||
" element('broker-settings').hidden = domain !== 'broker'; element('settings-broker').setAttribute('aria-pressed', String(domain === 'broker'));\n"
|
||||
" element('settings-network').setAttribute('aria-pressed', String(domain === 'network'));\n"
|
||||
" element('settings-serial').setAttribute('aria-pressed', String(domain === 'serial')); element('settings-accounts').setAttribute('aria-pressed', String(domain === 'accounts'));\n"
|
||||
@@ -1347,7 +1411,7 @@ static const char s_app_js[] =
|
||||
" if (!networkActions.includes(action)) throw new Error('Invalid action.');\n"
|
||||
" request = networkRequest(action); body = networkWire(request);\n"
|
||||
" const disruptive = ['start','stop','reconnect','next-profile','wifi-load'].includes(action) || action === 'wifi-patch' && Object.keys(request).some(k => !['action','generation','enabled_at_boot'].includes(k)) || action === 'profile-patch' && (networkTarget().enabled || net('enabled').checked);\n"
|
||||
" if ((disruptive || ['mdns-load','mdns-defaults'].includes(action)) && !window.confirm(action + ': ' + (disruptive ? 'May disconnect HTTPS/SSH and BOTH browser terminals before acknowledgement. Accepted is NOT online. Recover through STA/AP, UART0 or native USB. ' : 'Replace working mDNS with loaded/default settings and request reannouncement. ') + 'RAM changes require explicit Save. Continue?')) { body = undefined; return; }\n"
|
||||
" if ((disruptive || ['mdns-load','mdns-defaults'].includes(action)) && !window.confirm(action + ': ' + (disruptive ? 'May disconnect HTTPS/SSH and BOTH browser terminals before acknowledgement. Accepted is NOT online. Restore STA/AP reachability or use UART0 administration; SSH may also be unreachable. Native USB remains UART1 serial, not network administration. Unsaved browser drafts may be lost. ' : 'Replace working mDNS with loaded/default settings and request reannouncement. ') + 'RAM changes require explicit Save. Continue?')) { body = undefined; return; }\n"
|
||||
" } catch (error) { body = undefined; detail.textContent = 'Not submitted. ' + error.message; return; }\n"
|
||||
" finally { clearNetworkSecret(); if (request) request.password = ''; request = null; }\n"
|
||||
" } else clearNetworkSecret();\n"
|
||||
|
||||
Reference in New Issue
Block a user