Add Explicit Session Exit Controls Mini-Features ä2+3

This commit is contained in:
2026-08-31 04:02:19 +02:00
parent c37fab67db
commit 06bf33b3cf
9 changed files with 77 additions and 16 deletions
+1 -1
View File
@@ -151,7 +151,7 @@ admin SSH line editor ----/ |
The dispatcher is the sole caller of `esp_console_run()`, serializing UART0 and all admin SSH commands. This is required because the console registry is treated as non-reentrant, but it also means a long command or interactive prompt blocks all administration entry routes. The dispatcher is the sole caller of `esp_console_run()`, serializing UART0 and all admin SSH commands. This is required because the console registry is treated as non-reentrant, but it also means a long command or interactive prompt blocks all administration entry routes.
For SSH, standard output/error is redirected to the invoking session's bounded output ring. `console_input` routes visible or hidden prompts to UART0 or the active SSH session. Session tokens include slot and generation so late queued work cannot attach to a reused SSH slot. Only the SSH owner task moves ring output through wolfSSH. For SSH, standard output/error is redirected to the invoking session's bounded output ring. `console_input` routes visible or hidden prompts to UART0 or the active SSH session. `exit` and Ctrl+D on an empty admin SSH line use bounded deferred self-disconnect after their acknowledgement drains; role-`user` SSH remains a binary-transparent serial stream. Session tokens include slot and generation so late queued work cannot attach to a reused SSH slot. Only the SSH owner task moves ring output through wolfSSH.
Remote reboot, SSH stop/disconnect, and host-key rotate/reset use deferred control. The control task waits up to ten seconds for command state plus administration and transport application buffers to clear, then adds a short delay; this is a bounded best-effort heuristic, not peer-delivery confirmation. UART0 invokes these actions synchronously. User mutations and their revocations are not part of this mechanism. UART0 linenoise and the SSH editor consume the same manually maintained completion matcher and candidate formatter, so the two administration routes cannot drift in offered or displayed ambiguous completions; the hints can still drift from command registration and are not an authorization list. Remote reboot, SSH stop/disconnect, and host-key rotate/reset use deferred control. The control task waits up to ten seconds for command state plus administration and transport application buffers to clear, then adds a short delay; this is a bounded best-effort heuristic, not peer-delivery confirmation. UART0 invokes these actions synchronously. User mutations and their revocations are not part of this mechanism. UART0 linenoise and the SSH editor consume the same manually maintained completion matcher and candidate formatter, so the two administration routes cannot drift in offered or displayed ambiguous completions; the hints can still drift from command registration and are not an authorization list.
+1 -1
View File
@@ -68,7 +68,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the
- Dependencies: user database, secure random, broker, successful Wi-Fi manager initialization at boot, mbedTLS/HTTPS server; actual network reachability is an operational prerequisite, not an initializer invariant - Dependencies: user database, secure random, broker, successful Wi-Fi manager initialization at boot, mbedTLS/HTTPS server; actual network reachability is an operational prerequisite, not an initializer invariant
- Flow: `browser -> HTTPS Basic auth -> ticket -> WebSocket -> web transport -> broker` - Flow: `browser -> HTTPS Basic auth -> ticket -> WebSocket -> web transport -> broker`
- Ownership: HTTPD owns socket send/close work; transport task owns broker mediation; two fixed WebSocket slots and four outstanding tickets. - Ownership: HTTPD owns socket send/close work; transport task owns broker mediation; two fixed WebSocket slots and four outstanding tickets.
- Security constraints: Basic-auth cache hits still revalidate principal currentness; changes to the authored inline loader must update its hard-coded CSP hash in the same change. - Security constraints: Basic-auth cache hits still revalidate principal currentness; browser Disconnect closes the WebSocket and pauses automatic reconnect until Reconnect is selected. Changes to the authored inline loader must update its hard-coded CSP hash in the same change.
- Asset constraint: `web_assets_data.c` is checked-in generated input to the build; do not hand-edit or regenerate casually. - Asset constraint: `web_assets_data.c` is checked-in generated input to the build; do not hand-edit or regenerate casually.
## SSH ## SSH
+5 -5
View File
@@ -46,12 +46,12 @@ These observations should be checked when touching the relevant area; they are n
## Active Task ## Active Task
- **Objective:** Keep UART1 configuration independent of USB CDC host line coding, keep Phase 0 RS-232 status under `debug`, and avoid disconnecting Wi-Fi for edits to disabled profiles. - **Objective:** Keep UART1 configuration independent of USB CDC host line coding, keep Phase 0 RS-232 status under `debug`, avoid disconnecting Wi-Fi for edits to disabled profiles, and add explicit web/admin-SSH session exit controls.
- **Relevant files:** `src/usb_cdc_transport.{c,h}`, `src/usb_console.c`, `src/rs232_hw_test.c`, `src/console_completion.c`, `src/wifi_manager.{c,h}`, related documentation. - **Relevant files:** `src/usb_cdc_transport.{c,h}`, `src/usb_console.c`, `src/rs232_hw_test.c`, `src/console_completion.c`, `src/wifi_manager.{c,h}`, `src/web_ui.c`, `src/admin_ssh_console.{c,h}`, `src/main.c`, related documentation.
- **Findings:** The USB transport converted cached CDC line coding into `serial_config_t` and called `serial_service_apply_config()` after writer acquisition. Root `status` was a Phase 0 diagnostic that needed to claim the RS-232 port and therefore failed while the production serial service owned it. Every Wi-Fi working-configuration update queued a radio restart, including edits to profiles disabled in both the old and new configurations. - **Findings:** The USB transport converted cached CDC line coding into `serial_config_t` and called `serial_service_apply_config()` after writer acquisition. Root `status` was a Phase 0 diagnostic that needed to claim the RS-232 port and therefore failed while the production serial service owned it. Every Wi-Fi working-configuration update queued a radio restart, including edits to profiles disabled in both the old and new configurations.
- **Decision:** Treat CDC line coding as diagnostic metadata only. UART1 configuration remains exclusively controlled by explicit `serial` commands and their NVS persistence. Keep all Phase 0 RS-232 diagnostics, including signal status, under `debug`. Treat disabled Wi-Fi profiles as staged configuration: their edits do not restart the radio; enable/disable transitions and changes to enabled station/AP policy retain controlled asynchronous restart behavior. - **Decision:** Treat CDC line coding as diagnostic metadata only. UART1 configuration remains exclusively controlled by explicit `serial` commands and their NVS persistence. Keep all Phase 0 RS-232 diagnostics, including signal status, under `debug`. Treat disabled Wi-Fi profiles as staged configuration: their edits do not restart the radio; enable/disable transitions and changes to enabled station/AP policy retain controlled asynchronous restart behavior. Browser Disconnect pauses reconnect until Reconnect is selected. `exit`/empty-line Ctrl+D are admin-SSH-only deferred self-disconnect actions; role-`user` SSH remains binary-transparent.
- **Changes completed:** Removed the pending line-coding apply path and its counters; retained the latest host setting for `usb status`. Replaced root `status` with `debug status`, updated completion and documentation. Added effective Wi-Fi policy comparison before queueing `MESSAGE_COMMAND_APPLY`. - **Changes completed:** Removed the pending line-coding apply path and its counters; retained the latest host setting for `usb status`. Replaced root `status` with `debug status`, updated completion and documentation. Added effective Wi-Fi policy comparison before queueing `MESSAGE_COMMAND_APPLY`. Added browser Disconnect and admin SSH `exit`/Ctrl+D session closing through the existing bounded drain path.
- **Remaining work:** Target-hardware verification: acquire/release USB writer ownership after changing a host terminal's line coding and confirm UART1 remains at the configured framing. Verify `debug status` works after `serial stop` and root `status` is unknown. While connected through Wi-Fi, edit a disabled profile and its secret without a reconnect; then enable it and verify the expected reconnect. - **Remaining work:** Target-hardware verification: acquire/release USB writer ownership after changing a host terminal's line coding and confirm UART1 remains at the configured framing. Verify `debug status` works after `serial stop` and root `status` is unknown. While connected through Wi-Fi, edit a disabled profile and its secret without a reconnect; then enable it and verify the expected reconnect. Verify browser Disconnect pauses automatic reconnect until Reconnect is selected, and admin SSH `exit`/empty Ctrl+D close cleanly.
- **Risks / things to remember:** Host terminal line-coding selectors no longer configure the physical RS-232 port; use `serial set`/`serial save` instead. `debug status` remains ownership-protected and requires UART1 to be stopped. Enabling a profile may disconnect an SSH administrative session. `pio run` passed after all changes. - **Risks / things to remember:** Host terminal line-coding selectors no longer configure the physical RS-232 port; use `serial set`/`serial save` instead. `debug status` remains ownership-protected and requires UART1 to be stopped. Enabling a profile may disconnect an SSH administrative session. `pio run` passed after all changes.
### Handoff template ### Handoff template
+2 -1
View File
@@ -8,6 +8,7 @@ UART0 and authenticated `admin` SSH sessions use the same registered command imp
|---|---| |---|---|
| `memory` | Show free memory, minimum free memory, and largest blocks for internal RAM, DMA-capable RAM, and PSRAM. | | `memory` | Show free memory, minimum free memory, and largest blocks for internal RAM, DMA-capable RAM, and PSRAM. |
| `reboot` | Drain console output briefly and restart the ESP32. | | `reboot` | Drain console output briefly and restart the ESP32. |
| `exit` | Close the current administrative SSH session after its acknowledgement drains; unavailable on UART0. Ctrl+D on an empty admin SSH command line does the same. |
## Role-based users ## Role-based users
@@ -143,7 +144,7 @@ UART0 and admin SSH submit to one bounded queue, and one dispatcher task is the
UART0 and admin SSH use shared whole-line Tab completion. A unique/common prefix expands inline; a Tab that cannot extend an ambiguous prefix prints the matching candidates and redraws the unchanged input line instead of cycling candidates. Admin SSH additionally supports four-entry per-session command history with Up/Down, inline cursor editing with Left/Right, Home/End (including Pos1/Ende terminal sequences), Backspace/Delete, Ctrl-C, and visible or no-echo interactive prompts. Its history is RAM-only, private to the session, and wiped on disconnect. Ping callbacks enqueue bounded typed results so all formatting remains on the dispatcher task. UART0 and admin SSH use shared whole-line Tab completion. A unique/common prefix expands inline; a Tab that cannot extend an ambiguous prefix prints the matching candidates and redraws the unchanged input line instead of cycling candidates. Admin SSH additionally supports four-entry per-session command history with Up/Down, inline cursor editing with Left/Right, Home/End (including Pos1/Ende terminal sequences), Backspace/Delete, Ctrl-C, and visible or no-echo interactive prompts. Its history is RAM-only, private to the session, and wiped on disconnect. Ping callbacks enqueue bounded typed results so all formatting remains on the dispatcher task.
`reboot`, `ssh stop`, session disconnect, and SSH host-key reset/rotation are deferred until the command acknowledgement has left both the administration output ring and transport TX buffer. The shell stops accepting another command while such an action is pending. SSH host-key replacement or service stop closes all SSH sessions; reconnect and verify the new fingerprint where applicable. Web recovery credentials/certificates, Wi-Fi secrets, and interactive user passwords/keys are available to authenticated administrators and must therefore be treated as remotely accessible administrative material. `user bootstrap` and `user recover --force` remain UART0-only. A connected administrator also cannot generate its own replacement password remotely, preventing the one-time password from being lost during self-revocation. SSH does not provide `exec`, SFTP, SCP, forwarding, or subsystems. `exit`, `reboot`, `ssh stop`, session disconnect, and SSH host-key reset/rotation are deferred until the command acknowledgement has left both the administration output ring and transport TX buffer. The shell stops accepting another command while such an action is pending. SSH host-key replacement or service stop closes all SSH sessions; reconnect and verify the new fingerprint where applicable. Web recovery credentials/certificates, Wi-Fi secrets, and interactive user passwords/keys are available to authenticated administrators and must therefore be treated as remotely accessible administrative material. `user bootstrap` and `user recover --force` remain UART0-only. A connected administrator also cannot generate its own replacement password remotely, preventing the one-time password from being lost during self-revocation. SSH does not provide `exec`, SFTP, SCP, forwarding, or subsystems.
## Hardware diagnostics ## Hardware diagnostics
+44 -3
View File
@@ -460,6 +460,28 @@ static void report_command_result(esp_err_t error, int command_result)
} }
} }
static int command_exit(int argc, char **argv)
{
(void)argv;
if (argc != 1) {
printf("Usage: exit\n");
return 1;
}
if (!admin_ssh_console_dispatch_is_remote()) {
printf("The exit command is available only from an administrative SSH session.\n");
return 1;
}
esp_err_t error = admin_ssh_console_dispatch_defer(
ADMIN_SSH_DEFER_DISCONNECT, s_dispatch_token.session_id);
if (error != ESP_OK) {
printf("Could not schedule SSH session close: %s\n", esp_err_to_name(error));
return 1;
}
printf("SSH session close scheduled after output drains.\n");
return 0;
}
static void dispatch_registered_command(admin_request_t *request) static void dispatch_registered_command(admin_request_t *request)
{ {
FILE *saved_stdout = stdout; FILE *saved_stdout = stdout;
@@ -724,6 +746,18 @@ esp_err_t admin_ssh_console_init(void)
return ESP_OK; return ESP_OK;
} }
esp_err_t admin_ssh_console_register_commands(void)
{
const esp_console_cmd_t command = {
.command = "exit",
.help = "Close the current administrative SSH session",
.hint = NULL,
.func = &command_exit,
.argtable = NULL,
};
return esp_console_cmd_register(&command);
}
esp_err_t admin_ssh_console_start_uart_frontend(void) esp_err_t admin_ssh_console_start_uart_frontend(void)
{ {
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
@@ -1003,10 +1037,15 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
secure_wipe(completed, sizeof(completed)); secure_wipe(completed, sizeof(completed));
continue; continue;
} }
if (value == '\r' || value == '\n') { bool exit_requested = value == 0x04U && session->input_length == 0U;
if (value == '\r' || value == '\n' || exit_requested) {
session->discard_next_lf = value == '\r'; session->discard_next_lf = value == '\r';
if (exit_requested) {
memcpy(request.line, "exit", sizeof("exit"));
} else {
history_commit_locked(session); history_commit_locked(session);
memcpy(request.line, session->input, session->input_length); memcpy(request.line, session->input, session->input_length);
}
request.origin = ADMIN_REQUEST_SSH; request.origin = ADMIN_REQUEST_SSH;
request.token = *token; request.token = *token;
request.principal = session->principal; request.principal = session->principal;
@@ -1015,8 +1054,10 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
session->input_cursor = 0U; session->input_cursor = 0U;
session->history_position = -1; session->history_position = -1;
session->command_pending = true; session->command_pending = true;
(void)append_output_locked(session, (const uint8_t *)"\r\n", (void)append_output_locked(session,
sizeof("\r\n") - 1U); (const uint8_t *)(exit_requested ? "^D\r\n" : "\r\n"),
exit_requested ? sizeof("^D\r\n") - 1U
: sizeof("\r\n") - 1U);
submit = true; submit = true;
} else if (value == 0x03U) { } else if (value == 0x03U) {
secure_wipe(session->input, sizeof(session->input)); secure_wipe(session->input, sizeof(session->input));
+2
View File
@@ -43,6 +43,8 @@ typedef struct {
/* Starts the single command worker. It is the sole esp_console_run() caller. */ /* Starts the single command worker. It is the sole esp_console_run() caller. */
esp_err_t admin_ssh_console_init(void); esp_err_t admin_ssh_console_init(void);
/* Register administration-shell-only root commands after esp_console initialization. */
esp_err_t admin_ssh_console_register_commands(void);
/* Called after all ESP-IDF commands are registered; starts the UART0 frontend. */ /* Called after all ESP-IDF commands are registered; starts the UART0 frontend. */
esp_err_t admin_ssh_console_start_uart_frontend(void); esp_err_t admin_ssh_console_start_uart_frontend(void);
+1 -1
View File
@@ -11,7 +11,7 @@
#include "linenoise/linenoise.h" #include "linenoise/linenoise.h"
static const char *const s_root_candidates[] = { static const char *const s_root_candidates[] = {
"help", "debug", "display", "serial", "broker", "usb", "user", "help", "exit", "debug", "display", "serial", "broker", "usb", "user",
"wifi", "web", "ssh", "ping", "nslookup", "traceroute", "reboot", "memory", "wifi", "web", "ssh", "ping", "nslookup", "traceroute", "reboot", "memory",
}; };
+1
View File
@@ -305,6 +305,7 @@ void app_main(void)
ESP_ERROR_CHECK(ssh_console_register_commands()); ESP_ERROR_CHECK(ssh_console_register_commands());
ESP_ERROR_CHECK(network_console_register_root_commands()); ESP_ERROR_CHECK(network_console_register_root_commands());
ESP_ERROR_CHECK(system_console_register_commands()); ESP_ERROR_CHECK(system_console_register_commands());
ESP_ERROR_CHECK(admin_ssh_console_register_commands());
/* Upgrade late UART terminals safely and add nested completion. */ /* Upgrade late UART terminals safely and add nested completion. */
console_completion_install(); console_completion_install();
ESP_ERROR_CHECK(admin_ssh_console_start_uart_frontend()); ESP_ERROR_CHECK(admin_ssh_console_start_uart_frontend());
+18 -2
View File
@@ -119,6 +119,7 @@ static const char s_index_html[] =
"<div class=\"button-row\">\n" "<div class=\"button-row\">\n"
"<button id=\"request-control\" class=\"button primary\" type=\"button\" disabled>Request control</button>\n" "<button id=\"request-control\" class=\"button primary\" type=\"button\" disabled>Request control</button>\n"
"<button id=\"release-control\" class=\"button danger\" type=\"button\" disabled>Release control</button>\n" "<button id=\"release-control\" class=\"button danger\" type=\"button\" disabled>Release control</button>\n"
"<button id=\"disconnect\" class=\"button danger\" type=\"button\">Disconnect</button>\n"
"<button id=\"reconnect\" class=\"button\" type=\"button\">Reconnect</button>\n" "<button id=\"reconnect\" class=\"button\" type=\"button\">Reconnect</button>\n"
"</div>\n" "</div>\n"
"<p id=\"input-state\" class=\"input-state\" data-enabled=\"false\" aria-live=\"polite\">" "<p id=\"input-state\" class=\"input-state\" data-enabled=\"false\" aria-live=\"polite\">"
@@ -151,6 +152,7 @@ static const char s_app_js[] =
"const connectionDetail = element('connection-detail');\n" "const connectionDetail = element('connection-detail');\n"
"const requestControl = element('request-control');\n" "const requestControl = element('request-control');\n"
"const releaseControl = element('release-control');\n" "const releaseControl = element('release-control');\n"
"const disconnectButton = element('disconnect');\n"
"const reconnectButton = element('reconnect');\n" "const reconnectButton = element('reconnect');\n"
"const terminalHost = element('terminal');\n" "const terminalHost = element('terminal');\n"
"const terminal = new Terminal({\n" "const terminal = new Terminal({\n"
@@ -171,6 +173,7 @@ static const char s_app_js[] =
"let ticketAbort = null;\n" "let ticketAbort = null;\n"
"let reconnectTimer = null;\n" "let reconnectTimer = null;\n"
"let reconnectDelay = 1000;\n" "let reconnectDelay = 1000;\n"
"let reconnectEnabled = true;\n"
"let connectionGeneration = 0;\n" "let connectionGeneration = 0;\n"
"let role = 'observer';\n" "let role = 'observer';\n"
"let clientId = null;\n" "let clientId = null;\n"
@@ -193,6 +196,7 @@ static const char s_app_js[] =
" terminal.options.disableStdin = !writer;\n" " terminal.options.disableStdin = !writer;\n"
" requestControl.disabled = !socketOpen() || writer;\n" " requestControl.disabled = !socketOpen() || writer;\n"
" releaseControl.disabled = !socketOpen() || !writer;\n" " releaseControl.disabled = !socketOpen() || !writer;\n"
" disconnectButton.disabled = unloading || (!reconnectEnabled && socket === null && ticketAbort === null);\n"
" inputState.dataset.enabled = writer ? 'true' : 'false';\n" " inputState.dataset.enabled = writer ? 'true' : 'false';\n"
" inputState.textContent = writer\n" " inputState.textContent = writer\n"
" ? 'Writer mode — terminal input is enabled.'\n" " ? 'Writer mode — terminal input is enabled.'\n"
@@ -215,7 +219,7 @@ static const char s_app_js[] =
" }\n" " }\n"
"};\n" "};\n"
"const scheduleReconnect = () => {\n" "const scheduleReconnect = () => {\n"
" if (unloading || reconnectTimer !== null) return;\n" " if (unloading || !reconnectEnabled || reconnectTimer !== null) return;\n"
" const delay = reconnectDelay;\n" " const delay = reconnectDelay;\n"
" reconnectDelay = Math.min(reconnectDelay * 2, 10000);\n" " reconnectDelay = Math.min(reconnectDelay * 2, 10000);\n"
" setConnection('Disconnected', 'bad', `Reconnecting in ${Math.ceil(delay / 1000)} second(s)…`);\n" " setConnection('Disconnected', 'bad', `Reconnecting in ${Math.ceil(delay / 1000)} second(s)…`);\n"
@@ -267,7 +271,7 @@ static const char s_app_js[] =
" return ticket;\n" " return ticket;\n"
"}\n" "}\n"
"async function connect() {\n" "async function connect() {\n"
" if (unloading) return;\n" " if (unloading || !reconnectEnabled) return;\n"
" clearReconnectTimer();\n" " clearReconnectTimer();\n"
" const generation = ++connectionGeneration;\n" " const generation = ++connectionGeneration;\n"
" if (ticketAbort !== null) ticketAbort.abort();\n" " if (ticketAbort !== null) ticketAbort.abort();\n"
@@ -331,7 +335,19 @@ static const char s_app_js[] =
"releaseControl.addEventListener('click', () => {\n" "releaseControl.addEventListener('click', () => {\n"
" if (role === 'writer' && socketOpen()) socket.send('release-writer');\n" " if (role === 'writer' && socketOpen()) socket.send('release-writer');\n"
"});\n" "});\n"
"disconnectButton.addEventListener('click', () => {\n"
" reconnectEnabled = false;\n"
" ++connectionGeneration;\n"
" clearReconnectTimer();\n"
" if (ticketAbort !== null) { ticketAbort.abort(); ticketAbort = null; }\n"
" if (socket !== null) { const previous = socket; socket = null; previous.close(); }\n"
" clientId = null;\n"
" clientIdField.textContent = '—';\n"
" setRole('observer');\n"
" setConnection('Disconnected', 'warn', 'Disconnected by user. Automatic reconnect is paused.');\n"
"});\n"
"reconnectButton.addEventListener('click', () => {\n" "reconnectButton.addEventListener('click', () => {\n"
" reconnectEnabled = true;\n"
" reconnectDelay = 1000;\n" " reconnectDelay = 1000;\n"
" connect();\n" " connect();\n"
"});\n" "});\n"