Add SSH host identity rotation controls
This commit is contained in:
+83
-45
@@ -48,6 +48,9 @@ static bool s_mutex_creating;
|
||||
static ssh_security_blob_t s_material;
|
||||
static bool s_material_ready;
|
||||
static ssh_security_load_result_t s_load_result;
|
||||
static uint32_t s_identity_token, s_next_identity_token;
|
||||
static TaskHandle_t s_identity_owner;
|
||||
static bool s_identity_used;
|
||||
|
||||
static bool bytes_are_zero(const uint8_t *data, size_t size)
|
||||
{
|
||||
@@ -338,6 +341,10 @@ esp_err_t ssh_security_init(ssh_security_load_result_t *load_result)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
if (s_identity_token) {
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
ssh_security_blob_t candidate;
|
||||
bool missing = false;
|
||||
error = load_blob(&candidate, &missing);
|
||||
@@ -411,60 +418,91 @@ esp_err_t ssh_security_get_metadata(ssh_security_metadata_t *metadata)
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t ssh_security_rotate(void)
|
||||
esp_err_t ssh_security_get_identity_snapshot(ssh_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->metadata.generation = s_material.generation;
|
||||
memcpy(snapshot->metadata.sha256_fingerprint, s_material.sha256_fingerprint,
|
||||
sizeof(snapshot->metadata.sha256_fingerprint));
|
||||
snapshot->busy = s_identity_token != 0 || s_next_identity_token == UINT32_MAX;
|
||||
}
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t ssh_security_reserve_identity(uint32_t generation, bool reset, uint32_t *token)
|
||||
{
|
||||
if (!token || (reset && generation)) return ESP_ERR_INVALID_ARG;
|
||||
*token = 0;
|
||||
if (reset) {
|
||||
esp_err_t error = secure_random_init();
|
||||
if (error == ESP_OK) error = ensure_mutex();
|
||||
if (error != ESP_OK) return error;
|
||||
}
|
||||
if (!s_security_mutex) return ESP_ERR_INVALID_STATE;
|
||||
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) ||
|
||||
(generation && generation != s_material.generation)) {
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
esp_err_t error = ESP_ERR_INVALID_STATE;
|
||||
ssh_security_blob_t candidate;
|
||||
memset(&candidate, 0, sizeof(candidate));
|
||||
if (s_material_ready && s_material.generation != UINT32_MAX) {
|
||||
error = generate_blob(&candidate, s_material.generation + 1U);
|
||||
if (error == ESP_OK) {
|
||||
error = save_blob(&candidate);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
install_blob(&candidate);
|
||||
}
|
||||
}
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
*token = s_identity_token = ++s_next_identity_token;
|
||||
s_identity_owner = xTaskGetCurrentTaskHandle();
|
||||
s_identity_used = false;
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t ssh_security_reset(void)
|
||||
esp_err_t ssh_security_replace_reserved(uint32_t token)
|
||||
{
|
||||
esp_err_t error = secure_random_init();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
error = ensure_mutex();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (!s_security_mutex || !token) return ESP_ERR_INVALID_STATE;
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
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 ||
|
||||
s_identity_owner != xTaskGetCurrentTaskHandle()) {
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
ssh_security_blob_t candidate;
|
||||
error = generate_blob(&candidate, generation);
|
||||
if (error == ESP_OK) {
|
||||
error = save_blob(&candidate);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
install_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);
|
||||
|
||||
/* Reservation excludes writers while crypto and flash run outside locks. */
|
||||
ssh_security_blob_t candidate = {0};
|
||||
esp_err_t error = generate_blob(&candidate, generation);
|
||||
if (error == ESP_OK) error = save_blob(&candidate);
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
if (error == ESP_OK) install_blob(&candidate);
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
return error;
|
||||
}
|
||||
|
||||
void ssh_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_owner == xTaskGetCurrentTaskHandle()) {
|
||||
s_identity_token = 0;
|
||||
s_identity_owner = NULL;
|
||||
}
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
}
|
||||
|
||||
static esp_err_t replace_identity(bool reset)
|
||||
{
|
||||
uint32_t token = 0;
|
||||
esp_err_t error = ssh_security_reserve_identity(0, reset, &token);
|
||||
if (error == ESP_OK) error = ssh_security_replace_reserved(token);
|
||||
ssh_security_release_identity(token);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t ssh_security_rotate(void) { return replace_identity(false); }
|
||||
esp_err_t ssh_security_reset(void) { return replace_identity(true); }
|
||||
|
||||
+17
-1
@@ -3,6 +3,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
@@ -38,7 +39,22 @@ esp_err_t ssh_security_copy_private_key(uint8_t *output, size_t capacity,
|
||||
size_t *output_length);
|
||||
esp_err_t ssh_security_get_metadata(ssh_security_metadata_t *metadata);
|
||||
|
||||
/* Caller must stop SSH first. Rotation requires valid live material; reset replaces any stored state. */
|
||||
typedef struct {
|
||||
ssh_security_metadata_t metadata;
|
||||
bool busy;
|
||||
} ssh_security_identity_snapshot_t;
|
||||
|
||||
/* Zero-wait atomic public projection; no private material. */
|
||||
esp_err_t ssh_security_get_identity_snapshot(ssh_security_identity_snapshot_t *snapshot);
|
||||
/* Owner transaction: nonreused token, reserve before side effects and retain through
|
||||
* restart. Only the reserving task may replace once and release. Zero generation
|
||||
* selects canonical semantics; reset additionally permits unavailable material. */
|
||||
esp_err_t ssh_security_reserve_identity(uint32_t generation, bool reset, uint32_t *token);
|
||||
esp_err_t ssh_security_replace_reserved(uint32_t token);
|
||||
void ssh_security_release_identity(uint32_t token);
|
||||
|
||||
/* Rotation requires valid live material; reset replaces any stored state.
|
||||
* Direct callers share the reservation but do not restart the transport. */
|
||||
esp_err_t ssh_security_rotate(void);
|
||||
esp_err_t ssh_security_reset(void);
|
||||
|
||||
|
||||
+54
-33
@@ -707,6 +707,11 @@ static esp_err_t create_listener(void)
|
||||
|
||||
static esp_err_t start_runtime(void)
|
||||
{
|
||||
/* Never overwrite an orphaned context/listener or sessions after failed stop. */
|
||||
if (s_context != NULL || s_listen_fd >= 0) return ESP_ERR_INVALID_STATE;
|
||||
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
|
||||
if (s_slots[index].state != SSH_TRANSPORT_SESSION_FREE) return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
esp_err_t error = create_context();
|
||||
if (error == ESP_OK) {
|
||||
error = create_listener();
|
||||
@@ -752,7 +757,7 @@ static esp_err_t stop_runtime(void)
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (s_context != NULL) {
|
||||
if (all_free && s_context != NULL) {
|
||||
wolfSSH_CTX_free(s_context);
|
||||
s_context = NULL;
|
||||
}
|
||||
@@ -1393,10 +1398,18 @@ static void process_slots(void)
|
||||
}
|
||||
if (all_free) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!s_running) {
|
||||
s_cleanup_pending = false;
|
||||
}
|
||||
bool stopped = !s_running;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
/* The owner alone retires the retained context, before reopening admission. */
|
||||
if (stopped) {
|
||||
if (s_context != NULL) {
|
||||
wolfSSH_CTX_free(s_context);
|
||||
s_context = NULL;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_cleanup_pending = false;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1563,46 +1576,54 @@ esp_err_t ssh_transport_stop(void)
|
||||
return request_running(false);
|
||||
}
|
||||
|
||||
esp_err_t ssh_transport_replace_host_key(bool reset)
|
||||
esp_err_t ssh_transport_replace_identity(uint32_t service_generation,
|
||||
uint32_t identity_generation,
|
||||
bool reset, bool *committed)
|
||||
{
|
||||
if (s_command_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
xSemaphoreTake(s_command_mutex, portMAX_DELAY);
|
||||
if (!committed || (!!service_generation != !!identity_generation) ||
|
||||
(reset && service_generation) || service_generation == UINT32_MAX ||
|
||||
identity_generation == UINT32_MAX) return ESP_ERR_INVALID_ARG;
|
||||
*committed = false;
|
||||
if (!s_command_mutex) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_command_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
|
||||
bool was_running;
|
||||
bool cleanup_pending;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!s_initialized || s_transitioning) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
xSemaphoreGive(s_command_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
was_running = s_running;
|
||||
cleanup_pending = s_cleanup_pending;
|
||||
bool valid = s_initialized && !s_transitioning &&
|
||||
(!service_generation || (!s_cleanup_pending && service_generation == s_management_generation));
|
||||
bool was_running = s_running, cleanup_pending = s_cleanup_pending;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
|
||||
esp_err_t error = ESP_OK;
|
||||
if (was_running || cleanup_pending) {
|
||||
error = request_running_locked(false);
|
||||
}
|
||||
uint32_t token = 0;
|
||||
esp_err_t error = valid ? ssh_security_reserve_identity(identity_generation, reset, &token)
|
||||
: ESP_ERR_INVALID_STATE;
|
||||
if (error == ESP_OK) {
|
||||
error = reset ? ssh_security_reset() : ssh_security_rotate();
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
if (was_running) {
|
||||
(void)request_running_locked(true);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_management_generation != UINT32_MAX) ++s_management_generation;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
/* Keep the service mutex and identity reservation through stop/replace/start.
|
||||
* Failed stop must never mutate identity or attempt another start. */
|
||||
if (was_running || cleanup_pending) error = request_running_locked(false);
|
||||
if (error == ESP_OK) {
|
||||
error = ssh_security_replace_reserved(token);
|
||||
*committed = error == ESP_OK;
|
||||
if (error != ESP_OK && was_running) {
|
||||
/* Stop succeeded: restore service using unchanged committed material. */
|
||||
(void)request_running_locked(true);
|
||||
} else if (error == ESP_OK && (was_running || reset)) {
|
||||
error = request_running_locked(true);
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(s_command_mutex);
|
||||
return error;
|
||||
}
|
||||
if (was_running || reset) {
|
||||
error = request_running_locked(true);
|
||||
}
|
||||
ssh_security_release_identity(token);
|
||||
xSemaphoreGive(s_command_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t ssh_transport_replace_host_key(bool reset)
|
||||
{
|
||||
bool committed;
|
||||
return ssh_transport_replace_identity(0, 0, reset, &committed);
|
||||
}
|
||||
|
||||
esp_err_t ssh_transport_get_snapshot(ssh_transport_snapshot_t *snapshot)
|
||||
{
|
||||
if (snapshot == NULL) {
|
||||
|
||||
@@ -124,6 +124,14 @@ esp_err_t ssh_transport_init(void);
|
||||
esp_err_t ssh_transport_start(void);
|
||||
esp_err_t ssh_transport_stop(void);
|
||||
|
||||
/* Conditional off-HTTPD rotation: both generations checked/reserved before stop.
|
||||
* Zero generations retain canonical rotate/reset semantics. A failed stop skips
|
||||
* mutation/start; persistence failure may already have disconnected all SSH.
|
||||
* committed reports irreversible publication even if restart subsequently fails. */
|
||||
esp_err_t ssh_transport_replace_identity(uint32_t service_generation,
|
||||
uint32_t identity_generation,
|
||||
bool reset, bool *committed);
|
||||
|
||||
/* Serialize stop, persistent host-key replacement, and conditional restart. */
|
||||
esp_err_t ssh_transport_replace_host_key(bool reset);
|
||||
|
||||
|
||||
+45
-16
@@ -9,54 +9,58 @@
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "secure_random.h"
|
||||
#include "ssh_transport.h"
|
||||
#include "ssh_security.h"
|
||||
#include "mbedtls/base64.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
|
||||
enum { IDLE, PENDING, OK, FAILED, CANCELLED, CONFLICT };
|
||||
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "conflict"};
|
||||
static const char *const s_actions[] = {"start", "stop", "disconnect"};
|
||||
enum { ROTATE = 3 };
|
||||
static const char *const s_actions[] = {"start", "stop", "disconnect", "rotate"};
|
||||
typedef struct {
|
||||
uint32_t id;
|
||||
web_session_id_t session;
|
||||
user_principal_t principal;
|
||||
int64_t deadline;
|
||||
uint32_t generation, target;
|
||||
ssh_transport_management_action_t action;
|
||||
uint32_t generation, target, identity_generation;
|
||||
unsigned action;
|
||||
unsigned state;
|
||||
} ssh_operation_t;
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static ssh_operation_t s_operation;
|
||||
static uint32_t s_next_id;
|
||||
|
||||
/* Exact three-field flat JSON; no escapes, duplicates, extra fields or coercion. */
|
||||
/* Three fields for service actions; rotation additionally requires identity_generation.
|
||||
* No escapes, duplicates, extra fields or coercion. */
|
||||
static bool parse(const char *body, size_t length, ssh_operation_t *operation)
|
||||
{
|
||||
const char *keys[] = {"action", "generation", "target"};
|
||||
const char *keys[] = {"action", "generation", "target", "identity_generation"};
|
||||
unsigned seen = 0;
|
||||
size_t pos = 0;
|
||||
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
|
||||
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
|
||||
TAKE('{');
|
||||
for (unsigned field = 0; field < 3; ++field) {
|
||||
for (unsigned field = 0; field < 4; ++field) {
|
||||
if (field) { TAKE(','); }
|
||||
TAKE('"');
|
||||
size_t start = pos;
|
||||
while (pos < length && body[pos] != '"') ++pos;
|
||||
if (pos == length) return false;
|
||||
unsigned key = 0;
|
||||
for (; key < 3; ++key)
|
||||
for (; key < 4; ++key)
|
||||
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
|
||||
if (key == 3 || (seen & (1U << key))) return false;
|
||||
if (key == 4 || (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;
|
||||
operation->action = (ssh_transport_management_action_t)action;
|
||||
if (action == 4) return false;
|
||||
operation->action = action;
|
||||
++pos;
|
||||
} else {
|
||||
uint32_t number = 0;
|
||||
@@ -68,14 +72,19 @@ static bool parse(const char *body, size_t length, ssh_operation_t *operation)
|
||||
}
|
||||
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
|
||||
if (key == 1) operation->generation = number;
|
||||
else operation->target = number;
|
||||
else if (key == 2) operation->target = 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 == 7 && operation->generation &&
|
||||
return pos == length &&
|
||||
(operation->action == ROTATE ? seen == 15 && operation->identity_generation &&
|
||||
operation->identity_generation != UINT32_MAX : seen == 7) && operation->generation &&
|
||||
operation->generation != UINT32_MAX &&
|
||||
((operation->action == SSH_TRANSPORT_MANAGE_DISCONNECT) == (operation->target != 0U));
|
||||
}
|
||||
@@ -95,8 +104,12 @@ void web_ssh_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) {
|
||||
error = ssh_transport_manage_current(operation.action, operation.target, operation.generation);
|
||||
bool committed = false;
|
||||
error = operation.action == ROTATE
|
||||
? ssh_transport_replace_identity(operation.generation, operation.identity_generation, false, &committed)
|
||||
: ssh_transport_manage_current(operation.action, operation.target, operation.generation);
|
||||
state = error == ESP_OK ? OK :
|
||||
(operation.action == ROTATE) ? FAILED :
|
||||
(error == ESP_ERR_INVALID_STATE || error == ESP_ERR_NOT_FOUND) ? CONFLICT : FAILED;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
@@ -207,10 +220,26 @@ esp_err_t web_ssh_settings_handler(httpd_req_t *request)
|
||||
error = respond(request, "503 Service Unavailable", "{\"error\":\"ssh_unavailable\"}");
|
||||
goto done;
|
||||
}
|
||||
ssh_security_identity_snapshot_t identity = {0};
|
||||
unsigned char fingerprint[48] = {0};
|
||||
size_t fingerprint_length = 0;
|
||||
bool have_identity = ssh_security_get_identity_snapshot(&identity) == ESP_OK;
|
||||
if (have_identity && mbedtls_base64_encode(fingerprint, sizeof(fingerprint), &fingerprint_length,
|
||||
identity.metadata.sha256_fingerprint, sizeof(identity.metadata.sha256_fingerprint)) != 0) {
|
||||
error = ESP_FAIL;
|
||||
goto done;
|
||||
}
|
||||
while (fingerprint_length && fingerprint[fingerprint_length - 1] == '=') --fingerprint_length;
|
||||
fingerprint[fingerprint_length] = 0;
|
||||
char response[768];
|
||||
int written = snprintf(response, sizeof(response),
|
||||
"{\"generation\":%" PRIu32 ",\"running\":%s,\"transitioning\":%s,\"sessions\":[",
|
||||
snapshot.generation, snapshot.running ? "true" : "false", snapshot.transitioning ? "true" : "false");
|
||||
"{\"generation\":%" PRIu32 ",\"running\":%s,\"transitioning\":%s,"
|
||||
"\"identity_generation\":%" PRIu32 ",\"algorithm\":\"%s\",\"fingerprint\":\"%s%s\",\"rotatable\":%s,\"sessions\":[",
|
||||
snapshot.generation, snapshot.running ? "true" : "false", snapshot.transitioning ? "true" : "false",
|
||||
have_identity ? identity.metadata.generation : 0, SSH_SECURITY_KEY_TYPE,
|
||||
have_identity ? "SHA256:" : "", fingerprint,
|
||||
have_identity && !identity.busy && identity.metadata.generation != UINT32_MAX &&
|
||||
!snapshot.transitioning && snapshot.generation != UINT32_MAX ? "true" : "false");
|
||||
if (written < 0 || (size_t)written >= sizeof(response)) { error = ESP_FAIL; goto done; }
|
||||
size_t used = (size_t)written;
|
||||
unsigned count = 0;
|
||||
|
||||
+9
-5
@@ -200,7 +200,7 @@ static const char s_index_html[] =
|
||||
"<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-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=\"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-rotate\" class=\"button\" type=\"button\">Rotate SSH host identity…</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"
|
||||
"<p class=\"connection-detail\">Working OLED inactivity settings, not saved NVS values. Zero disables a transition. Each timeout is 0–86400 seconds; when both are enabled, Off must be later than Dim.</p>\n"
|
||||
@@ -953,7 +953,7 @@ static const char s_app_js[] =
|
||||
"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"
|
||||
"const sshActions = ['start','stop','disconnect','rotate'];\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"
|
||||
"function sshButtons() {\n"
|
||||
@@ -963,6 +963,7 @@ static const char s_app_js[] =
|
||||
" element('ssh-start').disabled = busy || sshPending || !available || sshSnapshot.running;\n"
|
||||
" element('ssh-stop').disabled = busy || sshPending || !available || !sshSnapshot.running;\n"
|
||||
" element('ssh-disconnect').disabled = busy || sshPending || !available || !sshSelection || sshSelection.stale;\n"
|
||||
" element('ssh-rotate').disabled = busy || sshPending || !available || !sshSnapshot.rotatable;\n"
|
||||
"}\n"
|
||||
"function clearSsh() {\n"
|
||||
" if (sshAbort) sshAbort.abort(); sshAbort = null; sshSnapshot = null; sshSelection = null;\n"
|
||||
@@ -973,7 +974,7 @@ static const char s_app_js[] =
|
||||
" sshButtons();\n"
|
||||
"}\n"
|
||||
"function sshValid(v) {\n"
|
||||
" return v && Object.keys(v).length === 4 && brokerUint(v.generation) && v.generation > 0 && typeof v.running === 'boolean' && typeof v.transitioning === 'boolean' &&\n"
|
||||
" return v && Object.keys(v).length === 8 && brokerUint(v.identity_generation) && v.algorithm === 'ecdsa-sha2-nistp256' && typeof v.rotatable === 'boolean' && typeof v.fingerprint === 'string' && (v.identity_generation ? /^SHA256:[A-Za-z0-9+/]{43}$/.test(v.fingerprint) : v.fingerprint === '') && (!v.rotatable || (v.identity_generation > 0 && v.identity_generation < 4294967295 && !v.transitioning && v.generation < 4294967295)) && brokerUint(v.generation) && v.generation > 0 && typeof v.running === 'boolean' && typeof v.transitioning === 'boolean' &&\n"
|
||||
" Array.isArray(v.sessions) && v.sessions.length <= 2 && new Set(v.sessions.map(s => s.id)).size === v.sessions.length && v.sessions.every(s =>\n"
|
||||
" s && Object.keys(s).length === 5 && brokerUint(s.id) && s.id > 0 && Number.isInteger(s.state) && s.state >= 1 && s.state <= 3 &&\n"
|
||||
" Number.isInteger(s.route) && s.route >= 0 && s.route <= 2 && typeof s.closing === 'boolean' && typeof s.name_hex === 'string' && /^(?:[0-9a-f]{2}){0,16}$/.test(s.name_hex));\n"
|
||||
@@ -987,8 +988,10 @@ static const char s_app_js[] =
|
||||
" if (action === 'disconnect' && (!sshSelection || sshSelection.stale || !row || row.closing || row.state === 3 || sshSelection.target !== target || sshSelection.generation !== sshSnapshot.generation)) return;\n"
|
||||
" if ((action === 'start' && sshSnapshot.running) || (action === 'stop' && !sshSnapshot.running)) return;\n"
|
||||
" const value = {action, generation: action === 'disconnect' ? sshSelection.generation : sshSnapshot.generation, target: action === 'disconnect' ? target : 0};\n"
|
||||
" if (action === 'rotate') { if (!sshSnapshot.rotatable) return; value.identity_generation = sshSnapshot.identity_generation; }\n"
|
||||
" const scope = action === 'disconnect' ? 'Disconnect only SSH session ' + sshLabel(row) + '?' : action === 'stop' ? 'Stop SSH and close ALL SSH sessions, including sessions admitted before execution?' : 'Start the SSH listener on TCP port 22?';\n"
|
||||
" if (!window.confirm(scope + ' Settings and host identity are unchanged. HTTPS, Wi-Fi, USB and UART0 remain available. Already executing SSH commands may finish.')) return;\n"
|
||||
" const rotation = 'Rotate and persist SSH host identity ' + sshSnapshot.algorithm + ', ' + sshSnapshot.fingerprint + ', identity generation ' + sshSnapshot.identity_generation + ', service generation ' + sshSnapshot.generation + '? ALL SSH sessions close, including those admitted before execution. Stopped SSH remains stopped. known_hosts trust changes: verify the NEW fingerprint over trusted UART0 using ssh host-key info BEFORE accepting it. Do not blindly remove known_hosts warnings. Stop/persistence/restart can fail after partial effects: SSH may be disconnected even if persistence fails, or the new identity may be saved while restart fails. HTTPS stays accessible; accounts, Wi-Fi, USB UART1 and UART0 are unchanged. Already executing SSH commands may finish. No automatic retry.';\n"
|
||||
" if (!window.confirm(action === 'rotate' ? rotation : scope + ' Settings and host identity are unchanged. HTTPS, Wi-Fi, USB and UART0 remain available. Already executing SSH commands may finish.')) return;\n"
|
||||
" body = JSON.stringify(value);\n"
|
||||
" }\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; sshAbort = controller; sshButtons();\n"
|
||||
@@ -1005,6 +1008,7 @@ static const char s_app_js[] =
|
||||
" sshSnapshot = v;\n"
|
||||
" if (sshSelection && (sshSelection.generation !== v.generation || v.transitioning || !v.sessions.some(s => s.id === sshSelection.target && s.route === sshSelection.route && s.name_hex === sshSelection.name_hex && !s.closing && s.state !== 3))) sshSelection.stale = true;\n"
|
||||
" const list = element('ssh-values'); list.textContent = '';\n"
|
||||
" for (const [label, value] of [['Stored host algorithm',v.algorithm],['Stored fingerprint',v.fingerprint || 'Unavailable'],['Identity generation',v.identity_generation],['Service generation',v.generation]]) { const dt = document.createElement('dt'), dd = document.createElement('dd'); dt.textContent = label; dd.textContent = String(value); list.appendChild(dt); list.appendChild(dd); }\n"
|
||||
" for (let i = 0; i < 2; ++i) {\n"
|
||||
" const s = v.sessions[i], o = element('ssh-option-' + i); o.hidden = !s; o.disabled = !s || s.closing || s.state === 3; o.value = s ? String(s.id) : ''; o.textContent = s ? sshLabel(s) : '';\n"
|
||||
" if (s) { const dt = document.createElement('dt'), dd = document.createElement('dd'); dt.textContent = sshLabel(s); dd.textContent = s.closing ? 'Close requested' : ['Free','Handshake','Active','Closing'][s.state]; list.appendChild(dt); list.appendChild(dd); }\n"
|
||||
@@ -1016,7 +1020,7 @@ static const char s_app_js[] =
|
||||
" const warning = !action && sshAwaitingAck ? 'Acknowledgement was lost; latest result may belong to another tab or earlier operation. ' : !action && sshId && sshId !== v.id ? 'Previous result replaced/unavailable; previous outcome unknown. ' : '';\n"
|
||||
" sshId = v.id; sshAction = v.action; sshPending = v.state === 'pending'; sshAwaitingAck = false; sshSnapshot = null; if (sshSelection) sshSelection.stale = true;\n"
|
||||
" const messages = {idle:'No retained result; inspect SSH before retrying.',pending:'Queued or executing. Select Check Operation Result; do not resubmit.',ok:'Accepted/completed at execution time. Disconnect acknowledgement is an owner close request, not proof of peer closure. Refresh to inspect.',failed:'Failed or lifecycle timed out. Admitted work may still finish. Refresh and inspect before retrying.',conflict:'Service changed, unavailable, or target absent/closing. No action admitted; Refresh and reselect.',cancelled:'Rejected before execution: login/currentness or queue deadline expired.'};\n"
|
||||
" detail.textContent = warning + v.action + ': ' + messages[v.state];\n"
|
||||
" detail.textContent = warning + v.action + ': ' + messages[v.state] + (v.action === 'rotate' ? ' SSH may have disconnected even if persistence failed; a new key may be persisted even if restart failed. HTTPS stays accessible. Check stored identity and service state; verify the NEW fingerprint with trusted UART0 ssh host-key info before accepting changed known_hosts trust. No automatic retry.' : '');\n"
|
||||
" element('ssh-detail').textContent = 'Snapshot stale. Select Refresh to inspect current service state.';\n"
|
||||
" }\n"
|
||||
" } catch (error) {\n"
|
||||
|
||||
Reference in New Issue
Block a user