Add HTTPS identity rotation support

This commit is contained in:
2026-09-13 18:21:37 +02:00
parent 36e80811e8
commit aa4bbc2c8c
24 changed files with 826 additions and 170 deletions
+2 -5
View File
@@ -109,11 +109,8 @@ static esp_err_t owner_perform(const admin_ssh_console_token_t *token,
/* The owner mask routes this crypto/NVS path to the 12KiB dispatcher.
* Commit before restart; a failed stop retains HTTPD ownership and must
* not be followed by start. No socket IO or console handler calls here. */
esp_err_t error = web_security_rotate_certificate();
if (error != ESP_OK) return error;
error = web_server_stop();
if (error != ESP_OK) return error;
return web_server_start();
bool committed = false;
return web_server_replace_identity(0, 0, false, &committed);
}
if (action == ADMIN_CONSOLE_DEFER_WEB_STOP) return web_server_stop();
if (action == ADMIN_SSH_DEFER_REBOOT) {
+12 -55
View File
@@ -234,68 +234,25 @@ static bool force_is_present(int argc, char **argv, int expected_argc)
return argc == expected_argc && strcmp(argv[expected_argc - 1], "--force") == 0;
}
static int restart_if_running(bool was_running)
static int replace_material(bool reset)
{
if (!was_running) {
return 0;
}
esp_err_t error = web_server_stop();
bool committed = false;
esp_err_t error = web_server_replace_identity(0, 0, reset, &committed);
if (error != ESP_OK) {
printf("Material changed, but the old TLS server could not stop: %s\n",
esp_err_to_name(error));
printf("%s: %s\n", committed
? "New HTTPS identity persisted, but stop/start failed; no rollback. Inspect via UART0 before retrying"
: "HTTPS identity replacement rejected or failed before publication",
esp_err_to_name(error));
return 1;
}
error = web_server_start();
if (error != ESP_OK) {
printf("Material changed, but HTTPS could not restart: %s\n",
esp_err_to_name(error));
return 1;
}
return 0;
}
static int rotate_certificate(void)
{
web_server_snapshot_t snapshot;
esp_err_t error = web_server_get_snapshot(&snapshot);
if (error != ESP_OK) {
printf("Could not inspect HTTPS runtime: %s\n", esp_err_to_name(error));
return 1;
}
error = web_security_rotate_certificate();
if (error != ESP_OK) {
printf("Could not rotate web certificate: %s\n", esp_err_to_name(error));
return 1;
}
printf("Web certificate and private key rotated and persisted.\n");
return restart_if_running(snapshot.running);
}
static int reset_material(void)
{
web_server_snapshot_t snapshot;
bool was_running = web_server_get_snapshot(&snapshot) == ESP_OK && snapshot.running;
esp_err_t error = web_security_reset_all();
if (error != ESP_OK) {
printf("Could not reset web security material: %s\n", esp_err_to_name(error));
return 1;
}
printf("HTTPS certificate and private key replaced and persisted; user accounts unchanged.\n");
if (was_running) {
return restart_if_running(true);
}
error = web_server_start();
if (error != ESP_OK) {
printf("Security material recovered, but HTTPS could not start: %s\n",
esp_err_to_name(error));
return 1;
}
printf("HTTPS started with the recovered security material.\n");
printf("Verify the new fingerprint via trusted UART0, renew browser trust, and sign in again.\n");
return 0;
}
static int rotate_certificate(void) { return replace_material(false); }
static int reset_material(void) { return replace_material(true); }
static void print_performance_time(const char *name, const web_serial_performance_timing_t *t)
{
printf(" %s: count=%" PRIu64 " sum_us=%" PRIu64 " avg_us_est=%" PRIu64 " max_us=%" PRIu64 "\n",
@@ -417,7 +374,7 @@ static int command_web(int argc, char **argv)
printf("Could not schedule HTTPS certificate rotation: %s\n", esp_err_to_name(error));
return 1;
}
printf("HTTPS certificate rotation scheduled after console output drains; both browser connections will close. Reconnect and verify the new certificate. If restart fails, use UART0 or SSH recovery.\n");
printf("HTTPS identity rotation scheduled after console output drains; all web logins and browser terminals will close. A new identity may persist even if stop/start fails; no rollback. Verify the new fingerprint via trusted UART0 web certificate info before renewing browser trust, then reload and sign in. SSH and USB UART1 access remain independent.\n");
return 0;
}
return rotate_certificate();
+35 -15
View File
@@ -11,6 +11,7 @@
#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"
@@ -18,9 +19,9 @@
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"};
static const char *const s_actions[] = {"stop", "restart", "reboot", "rotate"};
typedef struct {
uint32_t id, generation;
uint32_t id, generation, identity_generation;
web_session_id_t session;
user_principal_t principal;
int64_t ack_deadline, deadline;
@@ -46,33 +47,34 @@ static void expire_locked(int64_t now)
cancel_locked();
}
/* Exactly action + generation; no escapes, duplicates, coercions or extra fields. */
/* 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"};
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 < 2; ++field) {
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 < 2; ++key)
for (; key < 3; ++key)
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
if (key == 2 || (seen & (1U << key))) return false;
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 < 3; ++action)
for (; action < 4; ++action)
if (strlen(s_actions[action]) == pos - start && !memcmp(body + start, s_actions[action], pos - start)) break;
if (action == 3) return false;
if (action == 4) return false;
operation->action = action; ++pos;
} else {
uint32_t number = 0; start = pos;
@@ -82,14 +84,19 @@ static bool parse(const char *body, size_t length, lifecycle_operation_t *operat
number = number * 10U + digit;
}
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
operation->generation = number;
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 == 3 && operation->generation && operation->generation != UINT32_MAX;
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,
@@ -143,9 +150,11 @@ void web_lifecycle_settings_execute(uint32_t id)
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) :
web_server_reboot_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;
}
@@ -250,11 +259,22 @@ esp_err_t web_lifecycle_settings_handler(httpd_req_t *request)
if (web_server_get_management_snapshot(&snapshot) != ESP_OK) {
error = respond(request, "503 Service Unavailable", "{\"error\":\"lifecycle_unavailable\"}"); goto done;
}
char response[128];
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}",
"{\"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");
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));
+80 -56
View File
@@ -51,6 +51,8 @@ _Static_assert(sizeof(web_security_blob_t) == WEB_SECURITY_BLOB_SIZE,
static SemaphoreHandle_t s_security_mutex;
static web_security_blob_t s_material;
static bool s_material_ready;
static uint32_t s_identity_token, s_next_identity_token;
static bool s_identity_used;
static web_security_load_result_t s_load_result;
static bool bytes_are_zero(const uint8_t *data, size_t size)
@@ -649,6 +651,10 @@ esp_err_t web_security_init(web_security_load_result_t *load_result)
}
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
if (s_identity_token) {
xSemaphoreGive(s_security_mutex);
return ESP_ERR_INVALID_STATE;
}
if (s_material_ready) {
if (load_result != NULL) {
*load_result = s_load_result;
@@ -763,14 +769,6 @@ esp_err_t web_security_get_certificate_metadata(
return error;
}
static esp_err_t increment_generation(web_security_blob_t *blob)
{
if (blob->generation == UINT32_MAX) {
return ESP_ERR_INVALID_STATE;
}
++blob->generation;
return ESP_OK;
}
static void install_committed_blob(const web_security_blob_t *candidate)
{
@@ -781,64 +779,90 @@ static void install_committed_blob(const web_security_blob_t *candidate)
s_load_result = WEB_SECURITY_LOAD_STORED;
}
esp_err_t web_security_rotate_certificate(void)
esp_err_t web_security_get_identity_snapshot(web_security_identity_snapshot_t *snapshot)
{
if (s_security_mutex == NULL) {
if (!snapshot) return ESP_ERR_INVALID_ARG;
memset(snapshot, 0, sizeof(*snapshot));
if (!s_security_mutex) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
esp_err_t error = s_material_ready ? ESP_OK : ESP_ERR_INVALID_STATE;
if (error == ESP_OK) {
snapshot->generation = s_material.generation;
memcpy(snapshot->fingerprint, s_material.certificate_fingerprint, sizeof(snapshot->fingerprint));
snapshot->busy = s_identity_token != 0 || s_next_identity_token == UINT32_MAX;
}
xSemaphoreGive(s_security_mutex);
return error;
}
esp_err_t web_security_reserve_identity(uint32_t expected_generation, bool reset, uint32_t *token)
{
if (!token || (reset && expected_generation)) return ESP_ERR_INVALID_ARG;
*token = 0;
if (!reset) {
if (!s_security_mutex) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
bool ready = s_material_ready;
xSemaphoreGive(s_security_mutex);
if (!ready) return ESP_ERR_INVALID_STATE;
}
esp_err_t error = secure_random_init();
if (error == ESP_OK) error = ensure_security_mutex();
if (error != ESP_OK) return error;
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
if (s_identity_token || s_next_identity_token == UINT32_MAX ||
(!s_material_ready && !reset) ||
(s_material_ready && s_material.generation == UINT32_MAX) ||
(expected_generation && expected_generation != s_material.generation)) {
xSemaphoreGive(s_security_mutex);
return ESP_ERR_INVALID_STATE;
}
*token = s_identity_token = ++s_next_identity_token;
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
esp_err_t error = ESP_ERR_INVALID_STATE;
web_security_blob_t candidate;
memset(&candidate, 0, sizeof(candidate));
if (s_material_ready) {
candidate = s_material;
error = increment_generation(&candidate);
if (error == ESP_OK) {
error = generate_certificate(&candidate);
}
if (error == ESP_OK) {
error = save_blob(&candidate);
}
if (error == ESP_OK) {
install_committed_blob(&candidate);
}
}
secure_wipe(&candidate, sizeof(candidate));
s_identity_used = false;
xSemaphoreGive(s_security_mutex);
return error;
return ESP_OK;
}
esp_err_t web_security_reset_all(void)
esp_err_t web_security_replace_reserved(uint32_t token)
{
esp_err_t error = secure_random_init();
if (error != ESP_OK) {
return error;
}
error = ensure_security_mutex();
if (error != ESP_OK) {
return error;
}
if (!s_security_mutex || !token) return ESP_ERR_INVALID_STATE;
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
web_security_blob_t candidate;
uint32_t generation = 1U;
if (s_material_ready) {
if (s_material.generation == UINT32_MAX) {
xSemaphoreGive(s_security_mutex);
return ESP_ERR_INVALID_STATE;
}
generation = s_material.generation + 1U;
if (s_identity_token != token || s_identity_used) {
xSemaphoreGive(s_security_mutex);
return ESP_ERR_INVALID_STATE;
}
error = generate_all(&candidate, generation);
if (error == ESP_OK) {
error = save_blob(&candidate);
}
if (error == ESP_OK) {
install_committed_blob(&candidate);
}
secure_wipe(&candidate, sizeof(candidate));
s_identity_used = true;
uint32_t generation = s_material_ready ? s_material.generation + 1U : 1U;
xSemaphoreGive(s_security_mutex);
/* The reservation, not a held mutex/spinlock, excludes all identity writers. */
web_security_blob_t candidate = {0};
esp_err_t error = generate_all(&candidate, generation);
if (error == ESP_OK) error = save_blob(&candidate);
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
if (error == ESP_OK) install_committed_blob(&candidate);
xSemaphoreGive(s_security_mutex);
secure_wipe(&candidate, sizeof(candidate));
return error;
}
void web_security_release_identity(uint32_t token)
{
if (!s_security_mutex || !token) return;
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
if (s_identity_token == token) s_identity_token = 0;
xSemaphoreGive(s_security_mutex);
}
static esp_err_t replace_identity(bool reset)
{
uint32_t token = 0;
esp_err_t error = web_security_reserve_identity(0, reset, &token);
if (error == ESP_OK) error = web_security_replace_reserved(token);
web_security_release_identity(token);
return error;
}
esp_err_t web_security_rotate_certificate(void) { return replace_identity(false); }
esp_err_t web_security_reset_all(void) { return replace_identity(true); }
+17
View File
@@ -70,6 +70,23 @@ esp_err_t web_security_copy_tls_material(
esp_err_t web_security_get_certificate_metadata(
web_security_certificate_metadata_t *metadata);
typedef struct {
uint32_t generation;
uint8_t fingerprint[WEB_SECURITY_SHA256_LENGTH];
bool busy;
} web_security_identity_snapshot_t;
/* Zero-wait public metadata only; never returns DER or private material. */
esp_err_t web_security_get_identity_snapshot(web_security_identity_snapshot_t *snapshot);
/* Internal owner transaction shared with canonical mutations. Tokens never reuse.
* Reserve before crypto; retain through service restart. No lock stays held.
* Zero expected_generation selects canonical CLI semantics; reset permits recovery.
* Only the reserving owner may replace once and release its token. */
esp_err_t web_security_reserve_identity(uint32_t expected_generation, bool reset, uint32_t *token);
esp_err_t web_security_replace_reserved(uint32_t token);
void web_security_release_identity(uint32_t token);
/* Mutations become visible only after a complete blob has committed to NVS. */
esp_err_t web_security_rotate_certificate(void);
+53 -5
View File
@@ -796,7 +796,7 @@ esp_err_t web_server_start(void)
return error == ESP_OK ? start_server(false) : error;
}
static esp_err_t stop_server(uint32_t expected_generation, bool restart)
static esp_err_t stop_server(uint32_t expected_generation, bool restart, bool reserved)
{
if (s_server_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
@@ -804,7 +804,7 @@ static esp_err_t stop_server(uint32_t expected_generation, bool restart)
if (xSemaphoreTake(s_server_mutex, expected_generation ? 0U : portMAX_DELAY) != pdTRUE)
return ESP_ERR_TIMEOUT;
if (s_server == NULL || s_transitioning ||
if (s_server == NULL || s_transitioning != reserved ||
(expected_generation && (expected_generation != s_generation ||
s_generation == UINT32_MAX || s_last_error != ESP_OK))) {
xSemaphoreGive(s_server_mutex);
@@ -879,21 +879,69 @@ static esp_err_t stop_server(uint32_t expected_generation, bool restart)
return error == ESP_OK && restart ? start_server(true) : error;
}
esp_err_t web_server_replace_identity(uint32_t expected_service_generation,
uint32_t expected_identity_generation, bool reset, bool *committed)
{
if (!committed || (!!expected_service_generation != !!expected_identity_generation) ||
(reset && expected_service_generation)) return ESP_ERR_INVALID_ARG;
*committed = false;
/* Conditional dispatcher admission must not wait in the legacy initializer. */
esp_err_t error = expected_service_generation
? (s_server_mutex ? ESP_OK : ESP_ERR_INVALID_STATE) : web_server_init();
if (error != ESP_OK) return error;
if (xSemaphoreTake(s_server_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
if (s_transitioning || (expected_service_generation &&
(!s_server || s_last_error != ESP_OK || s_generation == UINT32_MAX ||
expected_service_generation != s_generation))) {
xSemaphoreGive(s_server_mutex);
return ESP_ERR_INVALID_STATE;
}
bool running = s_server != NULL;
s_transitioning = true;
xSemaphoreGive(s_server_mutex);
uint32_t token = 0;
error = web_security_reserve_identity(expected_identity_generation, reset, &token);
if (error == ESP_OK) {
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (s_generation != UINT32_MAX) ++s_generation;
xSemaphoreGive(s_server_mutex);
error = web_security_replace_reserved(token);
}
if (error == ESP_OK) {
*committed = true;
if (running) error = stop_server(0, true, true);
else if (reset) error = start_server(true);
else {
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
xSemaphoreGive(s_server_mutex);
}
} else {
/* No identity publication: leave HTTPD and its logins untouched. */
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
xSemaphoreGive(s_server_mutex);
}
web_security_release_identity(token);
return error;
}
esp_err_t web_server_stop(void)
{
return stop_server(0U, false);
return stop_server(0U, false, 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);
return stop_server(expected_generation, false, 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);
return stop_server(expected_generation, true, false);
}
esp_err_t web_server_reboot_current(uint32_t expected_generation)
+8
View File
@@ -62,6 +62,14 @@ esp_err_t web_server_restart_current(uint32_t expected_generation);
* Admission cannot be cancelled; normally does not return. Same caller rules. */
esp_err_t web_server_reboot_current(uint32_t expected_generation);
/* Combined identity/service owner operation, off HTTPD only. Nonzero expected
* generations select healthy running conditional rotation; both zero select CLI.
* reset is CLI-only and starts a stopped service; ordinary rotation leaves it stopped.
* committed reports irreversible NVS publication even when stop/start later fails.
* Reservation covers generation checks, crypto/commit and canonical stop/start. */
esp_err_t web_server_replace_identity(uint32_t expected_service_generation,
uint32_t expected_identity_generation, bool reset, bool *committed);
/* 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);
+15 -10
View File
@@ -191,12 +191,14 @@ static const char s_index_html[] =
"<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><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>"
"<div id=\"lifecycle-settings\" hidden><h2>HTTPS identity, 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>"
"<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>"
"<p id=\"lifecycle-identity\" class=\"connection-detail\"></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>"
"<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-rotate\" class=\"button\" type=\"button\">Rotate HTTPS identity…</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"
@@ -893,17 +895,18 @@ 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"
"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. A stuck ACK handoff requires canonical web stop then web start; this closes all web clients.';\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"
" element('lifecycle-refresh').disabled = element('lifecycle-result').disabled = busy;\n"
" for (const action of lifecycleActions) element('lifecycle-' + action).disabled = busy || lifecyclePending || !lifecycleSnapshot?.controllable;\n"
" for (const action of lifecycleActions) element('lifecycle-' + action).disabled = busy || lifecyclePending || !lifecycleSnapshot?.controllable || (action === 'rotate' && !lifecycleSnapshot?.rotatable);\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"
" element('lifecycle-identity').textContent = '';\n"
" if (lifecyclePending) element('lifecycle-operation-detail').textContent = lifecycleRecovery + ' Navigation does not cancel admitted work.';\n"
" lifecycleButtons();\n"
"}\n"
@@ -911,9 +914,10 @@ static const char s_app_js[] =
" 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"
" if (!lifecycleActions.includes(action) || !lifecycleSnapshot?.controllable || (action === 'rotate' && !lifecycleSnapshot?.rotatable)) 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 (action === 'rotate') value.identity_generation = lifecycleSnapshot.identity_generation;\n"
" const scope = action === 'rotate' ? 'Rotate and persist the HTTPS certificate and private key? CURRENT stored SHA-256 fingerprint: ' + lifecycleSnapshot.fingerprint + '. Identity generation ' + value.identity_generation + ', service generation ' + value.generation + '. ALL web logins and BOTH browser terminals disconnect. Save browser drafts first. Browser trust changes; SSH identity and device settings remain unchanged.' : 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"
@@ -925,17 +929,18 @@ static const char s_app_js[] =
" 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"
" 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 ? 320 : 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"
" if (status !== 200 || !v || Object.keys(v).length !== 7 || !brokerUint(v.identity_generation) || typeof v.fingerprint !== 'string' || !(v.identity_generation ? /^[0-9a-f]{64}$/.test(v.fingerprint) : v.fingerprint === '') || typeof v.rotatable !== 'boolean' || (v.rotatable && (!v.controllable || !v.identity_generation || v.identity_generation === 4294967295)) || !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"
" element('lifecycle-identity').textContent = v.identity_generation ? 'Stored HTTPS identity generation ' + v.identity_generation + '; service generation ' + v.generation + '; SHA-256 certificate fingerprint: ' + v.fingerprint + '. Public metadata, not proof of the currently served certificate or trusted verification.' : 'HTTPS identity metadata unavailable; rotation disabled. Use UART0 recovery.';\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"
" 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:'Operation failed or admission rejected; identity may already be persisted even if stop/start failed. No rollback. Inspect via UART0 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"