Add Typed SSH Service Controls

Provide admin-only SSH status plus generation-safe start, stop, and
single-session disconnect operations through the bounded dispatcher.
Include
Settings UI coverage, lifecycle safeguards, and host-side regression
tests.
This commit is contained in:
2026-09-13 16:07:04 +02:00
parent 7ccc8799e9
commit 737bd29f9e
25 changed files with 1049 additions and 47 deletions
+1
View File
@@ -36,6 +36,7 @@ idf_component_register(
"web_network_settings.c"
"web_display_settings.c"
"web_broker_settings.c"
"web_ssh_settings.c"
"web_admin_tickets.c"
"web_admin_transport.c"
"web_assets_data.c"
+16 -2
View File
@@ -21,6 +21,7 @@
#include "web_network_settings.h"
#include "web_display_settings.h"
#include "web_broker_settings.h"
#include "web_ssh_settings.h"
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
@@ -91,6 +92,7 @@ typedef enum {
ADMIN_REQUEST_NETWORK_SETTINGS,
ADMIN_REQUEST_DISPLAY_SETTINGS,
ADMIN_REQUEST_BROKER_SETTINGS,
ADMIN_REQUEST_SSH_SETTINGS,
} admin_request_origin_t;
typedef struct {
@@ -106,6 +108,7 @@ typedef struct {
uint32_t network_settings_id;
uint32_t display_settings_id;
uint32_t broker_settings_id;
uint32_t ssh_settings_id;
};
} admin_request_t;
@@ -711,6 +714,16 @@ esp_err_t admin_ssh_console_submit_broker_settings(uint32_t id)
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
esp_err_t admin_ssh_console_submit_ssh_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_SSH_SETTINGS, .ssh_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
static void worker_task(void *context)
{
(void)context;
@@ -721,12 +734,13 @@ 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_BROKER_SETTINGS || request.origin == ADMIN_REQUEST_SSH_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 web_broker_settings_execute(request.broker_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);
secure_wipe(&request, sizeof(request));
continue;
}
+1
View File
@@ -20,6 +20,7 @@ esp_err_t admin_ssh_console_submit_account_settings(uint32_t id);
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);
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
+67 -2
View File
@@ -100,6 +100,8 @@ static bool s_running;
static bool s_transitioning;
static bool s_desired_running;
static bool s_cleanup_pending;
/* Saturates independently of the internal completion sequence; never reset by counters. */
static uint32_t s_management_generation = 1U;
static uint32_t s_requested_sequence;
static uint32_t s_completed_sequence;
static esp_err_t s_command_result = ESP_ERR_INVALID_STATE;
@@ -837,7 +839,8 @@ static ssh_slot_t *find_free_slot(size_t *slot_index)
}
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
if (s_slots[index].state == SSH_TRANSPORT_SESSION_FREE) {
if (s_slots[index].state == SSH_TRANSPORT_SESSION_FREE &&
s_slots[index].generation < SSH_TRANSPORT_GENERATION_MAX) {
*slot_index = index;
return &s_slots[index];
}
@@ -907,7 +910,8 @@ static void accept_connections(void)
(void)setsockopt(socket_fd, IPPROTO_TCP, TCP_NODELAY,
&enabled, sizeof(enabled));
uint32_t generation = next_generation(slot->generation);
/* Exhausted slots are retired by find_free_slot(), never reused after wrap. */
uint32_t generation = slot->generation + 1U;
memset(slot, 0, sizeof(*slot));
slot->state = SSH_TRANSPORT_SESSION_HANDSHAKE;
slot->generation = generation;
@@ -1512,6 +1516,7 @@ static esp_err_t request_running_locked(bool desired)
return ESP_OK;
}
s_desired_running = desired;
if (s_management_generation != UINT32_MAX) ++s_management_generation;
s_transitioning = true;
s_requested_sequence = next_generation(s_requested_sequence);
sequence = s_requested_sequence;
@@ -1633,6 +1638,66 @@ esp_err_t ssh_transport_get_snapshot(ssh_transport_snapshot_t *snapshot)
return ESP_OK;
}
esp_err_t ssh_transport_get_management_snapshot(ssh_transport_management_snapshot_t *snapshot)
{
if (snapshot == NULL) return ESP_ERR_INVALID_ARG;
taskENTER_CRITICAL(&s_lock);
if (!s_initialized) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_INVALID_STATE;
}
memset(snapshot, 0, sizeof(*snapshot));
snapshot->generation = s_management_generation;
snapshot->running = s_running;
snapshot->transitioning = s_transitioning || s_cleanup_pending;
for (size_t i = 0; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) {
snapshot->sessions[i] = s_session_snapshots[i];
snapshot->sessions[i].close_requested |=
snapshot->sessions[i].active && s_external_close_id[i] == snapshot->sessions[i].session_id;
}
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
esp_err_t ssh_transport_manage_current(ssh_transport_management_action_t action,
uint32_t target, uint32_t generation)
{
if (!generation || generation == UINT32_MAX ||
action < SSH_TRANSPORT_MANAGE_START || action > SSH_TRANSPORT_MANAGE_DISCONNECT ||
((action == SSH_TRANSPORT_MANAGE_DISCONNECT) != (target != 0U))) return ESP_ERR_INVALID_ARG;
if (s_command_mutex == NULL) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_command_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
esp_err_t error = ESP_ERR_INVALID_STATE;
taskENTER_CRITICAL(&s_lock);
if (s_initialized && !s_transitioning && !s_cleanup_pending &&
generation == s_management_generation) {
if (action == SSH_TRANSPORT_MANAGE_DISCONNECT) {
error = ESP_ERR_NOT_FOUND;
for (size_t i = 0; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) {
const ssh_transport_session_snapshot_t *session = &s_session_snapshots[i];
if (session->active && session->session_id == target &&
!session->close_requested && session->state != SSH_TRANSPORT_SESSION_CLOSING &&
s_external_close_id[i] != target) {
s_external_close_id[i] = target;
error = ESP_OK;
break;
}
}
} else if (s_running != (action == SSH_TRANSPORT_MANAGE_START)) {
error = ESP_OK;
}
}
taskEXIT_CRITICAL(&s_lock);
/* The command mutex spans comparison and canonical lifecycle admission.
* No HTTPD work/lock is involved; only the SSH owner touches sockets/wolfSSH. */
if (error == ESP_OK) {
if (action == SSH_TRANSPORT_MANAGE_DISCONNECT) notify_task();
else error = request_running_locked(action == SSH_TRANSPORT_MANAGE_START);
}
xSemaphoreGive(s_command_mutex);
return error;
}
esp_err_t ssh_transport_clear_counters(void)
{
taskENTER_CRITICAL(&s_lock);
+20
View File
@@ -99,6 +99,26 @@ typedef struct {
ssh_transport_counters_t counters;
} ssh_transport_snapshot_t;
typedef enum {
SSH_TRANSPORT_MANAGE_START = 0,
SSH_TRANSPORT_MANAGE_STOP,
SSH_TRANSPORT_MANAGE_DISCONNECT,
} ssh_transport_management_action_t;
typedef struct {
uint32_t generation;
bool running;
bool transitioning;
ssh_transport_session_snapshot_t sessions[SSH_TRANSPORT_MAX_SESSIONS];
} ssh_transport_management_snapshot_t;
/* Compact published state only; no wolfSSH calls or task-stack scan. */
esp_err_t ssh_transport_get_management_snapshot(ssh_transport_management_snapshot_t *snapshot);
/* Dispatcher-only conditional admission; success on disconnect means owner notified,
* not peer receipt/cleanup. Lifecycle timeout does not cancel admitted work. */
esp_err_t ssh_transport_manage_current(ssh_transport_management_action_t action,
uint32_t target, uint32_t generation);
/* Installs wolfCrypt RNG/PSRAM hooks and starts the sole wolfSSH owner task. */
esp_err_t ssh_transport_init(void);
esp_err_t ssh_transport_start(void);
+15 -1
View File
@@ -28,6 +28,7 @@
#include "web_network_settings.h"
#include "web_display_settings.h"
#include "web_broker_settings.h"
#include "web_ssh_settings.h"
#include "web_admin_transport.h"
#include "web_session_store.h"
#include "web_cookie_auth.h"
@@ -415,6 +416,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_ssh_settings_uri = {
.uri = "/api/settings/ssh", .method = HTTP_GET, .handler = web_ssh_settings_handler,
};
static const httpd_uri_t s_ssh_operation_get_uri = {
.uri = "/api/settings/ssh-operation", .method = HTTP_GET, .handler = web_ssh_operation_handler,
};
static const httpd_uri_t s_ssh_operation_post_uri = {
.uri = "/api/settings/ssh-operation", .method = HTTP_POST, .handler = web_ssh_operation_handler,
};
static const httpd_uri_t s_broker_uri = {
.uri = "/api/settings/broker", .method = HTTP_GET, .handler = web_broker_settings_handler,
};
@@ -655,7 +665,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]) + 19U;
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 22U;
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
config.httpd.lru_purge_enable = false;
config.httpd.recv_wait_timeout = 1;
@@ -727,6 +737,10 @@ esp_err_t web_server_start(void)
web_httpd_register_optional_get(server, &s_broker_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_broker_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_broker_operation_get_uri.uri, HTTP_GET);
if (web_httpd_register_optional_get(server, &s_ssh_settings_uri) == ESP_OK &&
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 (error != ESP_OK) {
web_cookie_auth_stop();
+242
View File
@@ -0,0 +1,242 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_ssh_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 "ssh_transport.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"};
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;
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. */
static bool parse(const char *body, size_t length, ssh_operation_t *operation)
{
const char *keys[] = {"action", "generation", "target"};
unsigned seen = 0;
size_t pos = 0;
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
TAKE('{');
for (unsigned field = 0; field < 3; ++field) {
if (field) { TAKE(','); }
TAKE('"');
size_t start = pos;
while (pos < length && body[pos] != '"') ++pos;
if (pos == length) return false;
unsigned key = 0;
for (; key < 3; ++key)
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
if (key == 3 || (seen & (1U << key))) return false;
++pos; TAKE(':'); SPACE();
if (key == 0) {
TAKE('"'); start = pos;
while (pos < length && body[pos] != '"') ++pos;
if (pos == length) return false;
unsigned action = 0;
for (; action < 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 = (ssh_transport_management_action_t)action;
++pos;
} else {
uint32_t number = 0;
start = pos;
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
unsigned digit = (unsigned)(body[pos++] - '0');
if (number > (UINT32_MAX - digit) / 10U) return false;
number = number * 10U + digit;
}
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
if (key == 1) operation->generation = number;
else operation->target = number;
}
seen |= 1U << key;
}
TAKE('}'); SPACE();
#undef TAKE
#undef SPACE
return pos == length && seen == 7 && operation->generation &&
operation->generation != UINT32_MAX &&
((operation->action == SSH_TRANSPORT_MANAGE_DISCONNECT) == (operation->target != 0U));
}
void web_ssh_settings_execute(uint32_t id)
{
ssh_operation_t operation;
taskENTER_CRITICAL(&s_lock);
operation = s_operation;
taskEXIT_CRITICAL(&s_lock);
if (!id || operation.id != id || operation.state != PENDING) {
secure_wipe(&operation, sizeof(operation));
return;
}
bool current = false;
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, &current);
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);
state = error == ESP_OK ? OK :
(error == ESP_ERR_INVALID_STATE || error == ESP_ERR_NOT_FOUND) ? CONFLICT : FAILED;
}
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == PENDING) {
s_operation.state = state;
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
}
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_ssh_operation_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
bool 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;
}
ssh_operation_t operation = {0};
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_ssh_request\"}");
goto done;
}
operation.session = view.id;
operation.principal = view.principal;
operation.deadline = esp_timer_get_time() + 30000000LL;
operation.state = PENDING;
taskENTER_CRITICAL(&s_lock);
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
if (!busy) {
operation.id = ++s_next_id;
s_operation = operation;
}
taskEXIT_CRITICAL(&s_lock);
if (busy || admin_ssh_console_submit_ssh_settings(operation.id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
taskEXIT_CRITICAL(&s_lock);
error = httpd_resp_set_hdr(request, "Retry-After", "1");
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
secure_wipe(&operation, sizeof(operation));
goto done;
}
} else {
taskENTER_CRITICAL(&s_lock);
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);
secure_wipe(&operation, sizeof(operation));
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
esp_err_t web_ssh_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;
}
ssh_transport_management_snapshot_t snapshot;
error = ssh_transport_get_management_snapshot(&snapshot);
if (error != ESP_OK) {
error = respond(request, "503 Service Unavailable", "{\"error\":\"ssh_unavailable\"}");
goto done;
}
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");
if (written < 0 || (size_t)written >= sizeof(response)) { error = ESP_FAIL; goto done; }
size_t used = (size_t)written;
unsigned count = 0;
for (size_t i = 0; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) {
const ssh_transport_session_snapshot_t *session = &snapshot.sessions[i];
if (!session->active) continue;
char name[USER_DATABASE_USERNAME_CAPACITY * 2 + 1];
static const char hex[] = "0123456789abcdef";
size_t n = 0;
for (; session->principal_valid && n < USER_DATABASE_USERNAME_CAPACITY && session->username[n]; ++n) {
unsigned byte = (unsigned char)session->username[n];
name[n * 2] = hex[byte >> 4]; name[n * 2 + 1] = hex[byte & 15];
}
name[n * 2] = 0;
written = snprintf(response + used, sizeof(response) - used,
"%s{\"id\":%" PRIu32 ",\"state\":%u,\"route\":%u,\"name_hex\":\"%s\",\"closing\":%s}",
count++ ? "," : "", session->session_id, (unsigned)session->state,
(unsigned)session->route, name, session->close_requested ? "true" : "false");
if (written < 0 || (size_t)written >= sizeof(response) - used) { error = ESP_FAIL; goto done; }
used += (size_t)written;
}
written = snprintf(response + used, sizeof(response) - used, "]}");
error = written < 0 || (size_t)written >= sizeof(response) - used ? 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;
}
+9
View File
@@ -0,0 +1,9 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
/* Optional admin-only SSH status and login-isolated ordinary controls. */
esp_err_t web_ssh_settings_handler(httpd_req_t *request);
esp_err_t web_ssh_operation_handler(httpd_req_t *request);
void web_ssh_settings_execute(uint32_t id);
+84 -2
View File
@@ -190,7 +190,8 @@ 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></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></div>"
"<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"
"<p class=\"connection-detail\">Working OLED inactivity settings, not saved NVS values. Zero disables a transition. Each timeout is 086400 seconds; when both are enabled, Off must be later than Dim.</p>\n"
@@ -453,7 +454,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();\n"
" clearAccounts(); clearNetwork(); clearDisplay(); clearBroker(); clearSsh();\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"
@@ -469,6 +470,7 @@ static const char s_app_js[] =
" if (settingsDomain === 'network') return refreshNetwork();\n"
" if (settingsDomain === 'display') return refreshDisplay();\n"
" if (settingsDomain === 'broker') return refreshBroker();\n"
" if (settingsDomain === 'ssh') return refreshSsh();\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"
@@ -883,6 +885,85 @@ 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 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"
"function sshButtons() {\n"
" const busy = !!sshAbort, available = sshSnapshot && !sshSnapshot.transitioning && sshSnapshot.generation < 4294967295;\n"
" element('ssh-refresh').disabled = element('ssh-result').disabled = busy;\n"
" element('ssh-target').disabled = busy || sshPending || !available;\n"
" 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"
"}\n"
"function clearSsh() {\n"
" if (sshAbort) sshAbort.abort(); sshAbort = null; sshSnapshot = null; sshSelection = null;\n"
" element('ssh-target').value = ''; element('ssh-values').textContent = '';\n"
" for (let i = 0; i < 2; ++i) { const o = element('ssh-option-' + i); o.textContent = ''; o.hidden = o.disabled = true; }\n"
" element('ssh-detail').textContent = 'Select Refresh to read current SSH state.';\n"
" if (sshPending) element('ssh-operation-detail').textContent = 'Outcome pending or unknown. Check Result on return; navigation does not cancel admitted work.';\n"
" 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"
" 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"
"}\n"
"async function sshRequest(action, snapshotRead = false) {\n"
" if (settingsDomain !== 'ssh' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || sshAbort || (action && sshPending)) return;\n"
" let body; const detail = element(snapshotRead ? 'ssh-detail' : 'ssh-operation-detail');\n"
" if (action) {\n"
" if (!sshActions.includes(action) || !sshSnapshot || sshSnapshot.transitioning || sshSnapshot.generation === 4294967295) return;\n"
" const target = Number(element('ssh-target').value), row = sshSnapshot.sessions.find(s => s.id === target);\n"
" 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"
" 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"
" body = JSON.stringify(value);\n"
" }\n"
" const controller = new AbortController(), generation = workGeneration; sshAbort = controller; sshButtons();\n"
" const current = () => sshAbort === controller && settingsDomain === 'ssh' && selected === 'settings';\n"
" const deadline = window.setTimeout(() => { if (!current()) return; controller.abort(); sshAbort = null; sshSnapshot = null; if (sshSelection) sshSelection.stale = true; detail.textContent = 'Request timed out. Outcome may be unknown. Check Result and Refresh; no automatic retry.'; sshButtons(); }, 15000);\n"
" controller.signal.addEventListener('abort', () => window.clearTimeout(deadline), {once:true});\n"
" detail.textContent = snapshotRead ? 'Reading SSH; previous snapshot is stale until refreshed.' : 'Reading/submitting once. No automatic mutation retry.';\n"
" try {\n"
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
" if (action) { sshPending = true; sshAwaitingAck = true; if (sshSelection) sshSelection.stale = true; }\n"
" const {status, payload: v} = await api(snapshotRead ? '/api/settings/ssh' : '/api/settings/ssh-operation', generation, {method: action ? 'POST' : 'GET', body, signal: controller.signal, limit: snapshotRead ? 768 : 96, current});\n"
" if (snapshotRead) {\n"
" if (status !== 200 || !sshValid(v)) throw new Error('Invalid SSH snapshot');\n"
" 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 (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"
" }\n"
" element('ssh-target').value = sshSelection && !sshSelection.stale ? String(sshSelection.target) : '';\n"
" detail.textContent = (v.running ? 'SSH running' : 'SSH stopped') + (v.transitioning ? ' — transitioning or cleanup pending; use the admin shell for recovery.' : '') + '. ' + v.sessions.length + ' sessions. Refresh never renews selected target identity. ' + (sshSelection?.stale ? 'Selection stale; explicitly reselect.' : '') + (v.generation === 4294967295 ? ' Control generation exhausted; use the admin shell.' : '');\n"
" } else {\n"
" if (status !== (action ? 202 : 200) || !v || Object.keys(v).length !== 3 || !brokerUint(v.id) || !['none',...sshActions].includes(v.action) || !['idle','pending','ok','failed','cancelled','conflict'].includes(v.state) || ((v.id === 0) !== (v.state === 'idle')) || ((v.id === 0) !== (v.action === 'none')) || (action && (!v.id || v.action !== action || v.state !== 'pending')) || (!action && sshId && sshId === v.id && sshAction !== v.action)) throw new Error('Invalid SSH result');\n"
" 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"
" element('ssh-detail').textContent = 'Snapshot stale. Select Refresh to inspect current service state.';\n"
" }\n"
" } catch (error) {\n"
" if (live(generation) && current()) { sshSnapshot = null; if (sshSelection) sshSelection.stale = true; detail.textContent = (error.status ? error.message : 'SSH request unavailable or outcome unknown.') + ' Check Result and Refresh before any explicit retry. No automatic retry.'; }\n"
" } finally { window.clearTimeout(deadline); if (current()) { sshAbort = null; sshButtons(); } }\n"
"}\n"
"function refreshSsh() { return sshRequest(null, true); }\n"
"element('settings-ssh').addEventListener('click', () => selectSettingsDomain('ssh'));\n"
"element('ssh-refresh').addEventListener('click', refreshSsh);\n"
"element('ssh-result').addEventListener('click', () => sshRequest(null));\n"
"for (const action of sshActions) element('ssh-' + action).addEventListener('click', () => sshRequest(action));\n"
"element('ssh-target').addEventListener('change', () => {\n"
" const s = sshSnapshot?.sessions.find(s => s.id === Number(element('ssh-target').value));\n"
" sshSelection = s && !s.closing && s.state !== 3 && !sshAbort && !sshPending && !sshSnapshot.transitioning ? {target:s.id, generation:sshSnapshot.generation, route:s.route, name_hex:s.name_hex, stale:false} : null; sshButtons();\n"
"});\n"
"let settingsDomain = 'serial', accounts = [], accountsAbort = null, accountId = 0, accountPending = false, accountAwaitingAck = false, accountWarning = '';\n"
"let keysAbort = null, accountKeys = [], keysIdentity = '';\n"
"function keyIdentity() { const t = accounts[Number(element('account-target').value)]; return t ? JSON.stringify([t.username,t.user_id,t.auth_generation]) : ''; }\n"
@@ -962,6 +1043,7 @@ static const char s_app_js[] =
" clearSettings(); settingsDomain = domain; settingsHost.hidden = false;\n"
" 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('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"