Share completion and add SSH line editing

This commit is contained in:
2026-08-30 22:45:58 +02:00
parent 2f383cd283
commit 21d1b12f31
5 changed files with 180 additions and 60 deletions
+1 -1
View File
@@ -142,7 +142,7 @@ SSH listens on port 22 and accepts user-database passwords plus stored `ssh-ed25
UART0 and admin SSH submit to one bounded queue, and one dispatcher task is the sole caller of `esp_console_run()`. Consequently, SSH commands execute the canonical UART0 handlers and produce the same status and mutation behavior rather than using a second command implementation. Remote output is routed into the authenticated session's bounded output ring; only the SSH transport task accesses wolfSSH. UART0 and admin SSH submit to one bounded queue, and one dispatcher task is the sole caller of `esp_console_run()`. Consequently, SSH commands execute the canonical UART0 handlers and produce the same status and mutation behavior rather than using a second command implementation. Remote output is routed into the authenticated session's bounded output ring; only the SSH transport task accesses wolfSSH.
Admin SSH supports four-entry per-session command history with Up/Down, bounded whole-line Tab completion, Backspace/Ctrl-C, and visible or no-echo interactive prompts. 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. Admin SSH supports four-entry per-session command history with Up/Down, shared whole-line Tab completion using the same hints as UART0, inline cursor editing with Left/Right, Home/End (including Pos1/Ende terminal sequences), Backspace/Delete, Ctrl-C, and visible or no-echo interactive prompts. 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. `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.
+1 -1
View File
@@ -137,7 +137,7 @@ Use one disposable `admin` and one disposable `user`. Keep UART0 attached throug
Exercise printable input, backspace, Ctrl-C, CR/LF, an empty line, and a line longer than the documented limit. Confirm the command line is bounded, overflow is discarded through a clear diagnostic, and a new prompt remains usable. Run `help`, `user list`, and `broker clients` in a normal ANSI terminal and confirm every line starts in column zero: canonical LF output must be normalized to CRLF without doubling handlers that already emit CRLF. Exercise printable input, backspace, Ctrl-C, CR/LF, an empty line, and a line longer than the documented limit. Confirm the command line is bounded, overflow is discarded through a clear diagnostic, and a new prompt remains usable. Run `help`, `user list`, and `broker clients` in a normal ANSI terminal and confirm every line starts in column zero: canonical LF output must be normalized to CRLF without doubling handlers that already emit CRLF.
Run at least five distinct commands, then use Up/Down to navigate the four-entry per-session history, return to a saved draft with Down, and confirm older entries are bounded out. Verify history does not survive reconnect and is not shared with a second administrator. Exercise Tab on root and nested prefixes such as `us`, `user l`, `wifi ap sh`, and `ssh host-key i`; confirm unique/common prefixes redraw cleanly without inserting escape-sequence bytes into the command. Run at least five distinct commands, then use Up/Down to navigate the four-entry per-session history, return to a saved draft with Down, and confirm older entries are bounded out. Verify history does not survive reconnect and is not shared with a second administrator. Exercise Tab on root and nested prefixes such as `us`, `user l`, `wifi ap sh`, `wifi next`, and `ssh host-key i`; confirm the same hints are offered by UART0 and SSH, and that unique/common prefixes redraw cleanly without inserting escape-sequence bytes into the command. Type a command, use Left/Right to insert and delete characters in its middle, then use Home/End and the terminal's Pos1/Ende keys; verify cursor placement, Backspace, and Delete change exactly the intended byte before submission.
Run an unsupported command and confirm it is rejected without affecting UART0 or the serial broker. Run the full root `help` output to exercise output-ring draining. With the SSH client temporarily unable to read output, confirm the worker applies input backpressure rather than accepting an unbounded command/output backlog; inspect `ssh counters` for admin-console admission and input-backpressure values. Run an unsupported command and confirm it is rejected without affecting UART0 or the serial broker. Run the full root `help` output to exercise output-ring draining. With the SSH client temporarily unable to read output, confirm the worker applies input backpressure rather than accepting an unbounded command/output backlog; inspect `ssh counters` for admin-console admission and input-backpressure values.
+113 -13
View File
@@ -49,6 +49,7 @@ typedef struct {
admin_ssh_console_token_t token; admin_ssh_console_token_t token;
user_principal_t principal; user_principal_t principal;
size_t input_length; size_t input_length;
size_t input_cursor;
uint8_t input[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U]; uint8_t input[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
uint8_t history[ADMIN_SSH_CONSOLE_HISTORY_DEPTH] uint8_t history[ADMIN_SSH_CONSOLE_HISTORY_DEPTH]
[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U]; [ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
@@ -57,6 +58,8 @@ typedef struct {
uint8_t draft[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U]; uint8_t draft[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
size_t draft_length; size_t draft_length;
uint8_t escape_state; uint8_t escape_state;
uint8_t escape_parameters[4];
size_t escape_parameter_length;
bool discard_next_lf; bool discard_next_lf;
admin_prompt_state_t prompt_state; admin_prompt_state_t prompt_state;
bool prompt_hidden; bool prompt_hidden;
@@ -185,12 +188,25 @@ static void print_prompt(const admin_ssh_console_token_t *token)
static bool redraw_line_locked(admin_session_t *session) static bool redraw_line_locked(admin_session_t *session)
{ {
static const char prefix[] = "\r\x1b[2Kadmin@serial-tool> "; static const char prefix[] = "\r\x1b[2Kadmin@serial-tool> ";
size_t required = sizeof(prefix) - 1U + session->input_length; char cursor_back[16] = {0};
size_t tail_length = session->input_length - session->input_cursor;
size_t cursor_back_length = 0U;
if (tail_length > 0U) {
int written = snprintf(cursor_back, sizeof(cursor_back), "\x1b[%uD",
(unsigned int)tail_length);
if (written < 0 || (size_t)written >= sizeof(cursor_back)) {
return false;
}
cursor_back_length = (size_t)written;
}
size_t required = sizeof(prefix) - 1U + session->input_length + cursor_back_length;
if (required > ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - session->output_length) { if (required > ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - session->output_length) {
return append_output_locked(session, (const uint8_t *)"\a", 1U); return append_output_locked(session, (const uint8_t *)"\a", 1U);
} }
(void)append_output_locked(session, (const uint8_t *)prefix, sizeof(prefix) - 1U); (void)append_output_locked(session, (const uint8_t *)prefix, sizeof(prefix) - 1U);
return append_output_locked(session, session->input, session->input_length); (void)append_output_locked(session, session->input, session->input_length);
return cursor_back_length == 0U ||
append_output_locked(session, (const uint8_t *)cursor_back, cursor_back_length);
} }
static void history_commit_locked(admin_session_t *session) static void history_commit_locked(admin_session_t *session)
@@ -210,6 +226,35 @@ static void history_commit_locked(admin_session_t *session)
} }
} }
static void history_move_locked(admin_session_t *session, bool older);
static void editor_key_locked(admin_session_t *session, uint8_t key)
{
if (key == 'A' || key == 'B') {
history_move_locked(session, key == 'A');
return;
}
if (key == 'C' && session->input_cursor < session->input_length) {
++session->input_cursor;
} else if (key == 'D' && session->input_cursor > 0U) {
--session->input_cursor;
} else if (key == 'H') {
session->input_cursor = 0U;
} else if (key == 'F') {
session->input_cursor = session->input_length;
} else if (key == 'X' && session->input_cursor < session->input_length) {
memmove(session->input + session->input_cursor,
session->input + session->input_cursor + 1U,
session->input_length - session->input_cursor);
--session->input_length;
session->history_position = -1;
} else {
(void)append_output_locked(session, (const uint8_t *)"\a", 1U);
return;
}
(void)redraw_line_locked(session);
}
static void history_move_locked(admin_session_t *session, bool older) static void history_move_locked(admin_session_t *session, bool older)
{ {
if (older) { if (older) {
@@ -226,6 +271,7 @@ static void history_move_locked(admin_session_t *session, bool older)
memcpy(session->input, session->history[session->history_position], memcpy(session->input, session->history[session->history_position],
sizeof(session->input)); sizeof(session->input));
session->input_length = strlen((const char *)session->input); session->input_length = strlen((const char *)session->input);
session->input_cursor = session->input_length;
} else { } else {
if (session->history_position < 0) { if (session->history_position < 0) {
(void)append_output_locked(session, (const uint8_t *)"\a", 1U); (void)append_output_locked(session, (const uint8_t *)"\a", 1U);
@@ -235,10 +281,12 @@ static void history_move_locked(admin_session_t *session, bool older)
if (session->history_position < 0) { if (session->history_position < 0) {
memcpy(session->input, session->draft, sizeof(session->input)); memcpy(session->input, session->draft, sizeof(session->input));
session->input_length = session->draft_length; session->input_length = session->draft_length;
session->input_cursor = session->input_length;
} else { } else {
memcpy(session->input, session->history[session->history_position], memcpy(session->input, session->history[session->history_position],
sizeof(session->input)); sizeof(session->input));
session->input_length = strlen((const char *)session->input); session->input_length = strlen((const char *)session->input);
session->input_cursor = session->input_length;
} }
} }
(void)redraw_line_locked(session); (void)redraw_line_locked(session);
@@ -855,13 +903,45 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
} }
session->discard_next_lf = false; session->discard_next_lf = false;
if (session->escape_state != 0U) { if (session->escape_state != 0U) {
if (session->escape_state == 1U && (value == '[' || value == 'O')) { if (session->escape_state == 1U) {
if (value == '[') {
session->escape_state = 2U; session->escape_state = 2U;
} else if (session->escape_state == 2U) { session->escape_parameter_length = 0U;
if (value == 'A' || value == 'B') { } else if (value == 'O') {
history_move_locked(session, value == 'A'); session->escape_state = 3U;
} } else {
session->escape_state = 0U; session->escape_state = 0U;
}
} else if (session->escape_state == 3U) {
editor_key_locked(session, value);
session->escape_state = 0U;
} else if (value >= 'A' && value <= 'Z') {
editor_key_locked(session, value);
session->escape_state = 0U;
} else if (value == '~') {
uint8_t key = 0U;
if (session->escape_parameter_length > 0U) {
switch (session->escape_parameters[0]) {
case '1':
case '7':
key = 'H';
break;
case '3':
key = 'X';
break;
case '4':
case '8':
key = 'F';
break;
default:
break;
}
}
editor_key_locked(session, key);
session->escape_state = 0U;
} else if ((value == ';' || (value >= '0' && value <= '9')) &&
session->escape_parameter_length < sizeof(session->escape_parameters)) {
session->escape_parameters[session->escape_parameter_length++] = value;
} else { } else {
session->escape_state = 0U; session->escape_state = 0U;
} }
@@ -876,6 +956,12 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
continue; continue;
} }
if (value == '\t') { if (value == '\t') {
if (session->input_cursor != session->input_length) {
(void)append_output_locked(session, (const uint8_t *)"\a", 1U);
++*consumed;
taskEXIT_CRITICAL(&s_lock);
continue;
}
char current[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U]; char current[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
memcpy(current, session->input, sizeof(current)); memcpy(current, session->input, sizeof(current));
++*consumed; ++*consumed;
@@ -890,6 +976,7 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
if (expanded) { if (expanded) {
strlcpy((char *)session->input, completed, sizeof(session->input)); strlcpy((char *)session->input, completed, sizeof(session->input));
session->input_length = strlen((const char *)session->input); session->input_length = strlen((const char *)session->input);
session->input_cursor = session->input_length;
session->history_position = -1; session->history_position = -1;
(void)redraw_line_locked(session); (void)redraw_line_locked(session);
} else { } else {
@@ -910,6 +997,7 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
request.principal = session->principal; request.principal = session->principal;
secure_wipe(session->input, sizeof(session->input)); secure_wipe(session->input, sizeof(session->input));
session->input_length = 0U; session->input_length = 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, (const uint8_t *)"\r\n",
@@ -918,29 +1006,41 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
} else if (value == 0x03U) { } else if (value == 0x03U) {
secure_wipe(session->input, sizeof(session->input)); secure_wipe(session->input, sizeof(session->input));
session->input_length = 0U; session->input_length = 0U;
session->input_cursor = 0U;
session->history_position = -1; session->history_position = -1;
(void)append_output_locked(session, (const uint8_t *)"^C\r\n", (void)append_output_locked(session, (const uint8_t *)"^C\r\n",
sizeof("^C\r\n") - 1U); sizeof("^C\r\n") - 1U);
(void)append_output_locked(session, (const uint8_t *)"admin@serial-tool> ", (void)append_output_locked(session, (const uint8_t *)"admin@serial-tool> ",
sizeof("admin@serial-tool> ") - 1U); sizeof("admin@serial-tool> ") - 1U);
} else if (value == 0x08U || value == 0x7fU) { } else if (value == 0x08U || value == 0x7fU) {
if (session->input_length > 0U) { if (session->input_cursor > 0U) {
session->input[--session->input_length] = 0U; memmove(session->input + session->input_cursor - 1U,
session->input + session->input_cursor,
session->input_length - session->input_cursor + 1U);
--session->input_cursor;
--session->input_length;
session->history_position = -1; session->history_position = -1;
(void)append_output_locked(session, (const uint8_t *)"\b \b", (void)redraw_line_locked(session);
sizeof("\b \b") - 1U);
} }
} else if (value >= 0x20U && value <= 0x7eU) { } else if (value >= 0x20U && value <= 0x7eU) {
if (session->input_length >= ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY) { if (session->input_length >= ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY) {
session->input_length = 0U; session->input_length = 0U;
session->input_cursor = 0U;
(void)append_output_locked(session, (const uint8_t *) (void)append_output_locked(session, (const uint8_t *)
"\r\nCommand too long; discarded.\r\nadmin@serial-tool> ", "\r\nCommand too long; discarded.\r\nadmin@serial-tool> ",
sizeof("\r\nCommand too long; discarded.\r\nadmin@serial-tool> ") - 1U); sizeof("\r\nCommand too long; discarded.\r\nadmin@serial-tool> ") - 1U);
} else { } else {
memmove(session->input + session->input_cursor + 1U,
session->input + session->input_cursor,
session->input_length - session->input_cursor + 1U);
session->input[session->input_cursor++] = value;
++session->input_length;
session->history_position = -1; session->history_position = -1;
session->input[session->input_length++] = value; if (session->input_cursor == session->input_length) {
session->input[session->input_length] = 0U;
(void)append_output_locked(session, &value, 1U); (void)append_output_locked(session, &value, 1U);
} else {
(void)redraw_line_locked(session);
}
} }
} }
++*consumed; ++*consumed;
+53 -40
View File
@@ -7,7 +7,6 @@
#include <string.h> #include <string.h>
#include <unistd.h> #include <unistd.h>
#include "esp_console.h"
#include "linenoise/linenoise.h" #include "linenoise/linenoise.h"
static const char *const s_root_candidates[] = { static const char *const s_root_candidates[] = {
@@ -144,6 +143,7 @@ static const char *const s_completion_candidates[] = {
"wifi start", "wifi start",
"wifi stop", "wifi stop",
"wifi reconnect", "wifi reconnect",
"wifi next-profile",
"wifi save", "wifi save",
"wifi load", "wifi load",
"wifi defaults", "wifi defaults",
@@ -202,10 +202,12 @@ static const char *const s_completion_candidates[] = {
"ssh reset --force", "ssh reset --force",
}; };
bool console_completion_expand(const char *line, char *completed, size_t capacity) void console_completion_visit(const char *line,
console_completion_visitor_t visitor,
void *context)
{ {
if (line == NULL || completed == NULL || capacity == 0U) { if (line == NULL || visitor == NULL) {
return false; return;
} }
size_t line_length = strlen(line); size_t line_length = strlen(line);
const char *const *candidates = strchr(line, ' ') == NULL const char *const *candidates = strchr(line, ' ') == NULL
@@ -215,33 +217,56 @@ bool console_completion_expand(const char *line, char *completed, size_t capacit
? sizeof(s_root_candidates) / sizeof(s_root_candidates[0]) ? sizeof(s_root_candidates) / sizeof(s_root_candidates[0])
: sizeof(s_completion_candidates) / : sizeof(s_completion_candidates) /
sizeof(s_completion_candidates[0]); sizeof(s_completion_candidates[0]);
const char *first = NULL;
size_t common_length = 0U;
for (size_t index = 0U; index < candidate_count; ++index) { for (size_t index = 0U; index < candidate_count; ++index) {
const char *candidate = candidates[index]; const char *candidate = candidates[index];
if (strncmp(candidate, line, line_length) != 0) { if (strlen(candidate) > line_length &&
continue; strncmp(candidate, line, line_length) == 0 &&
!visitor(candidate, context)) {
return;
} }
if (first == NULL) { }
first = candidate; }
common_length = strlen(candidate);
continue; typedef struct {
const char *line;
const char *first;
size_t common_length;
} completion_expand_context_t;
static bool collect_common_prefix(const char *candidate, void *context)
{
completion_expand_context_t *result = context;
if (result->first == NULL) {
result->first = candidate;
result->common_length = strlen(candidate);
return true;
} }
size_t candidate_length = strlen(candidate); size_t candidate_length = strlen(candidate);
if (common_length > candidate_length) { if (result->common_length > candidate_length) {
common_length = candidate_length; result->common_length = candidate_length;
} }
size_t offset = line_length; size_t offset = strlen(result->line);
while (offset < common_length && first[offset] == candidate[offset]) { while (offset < result->common_length && result->first[offset] == candidate[offset]) {
++offset; ++offset;
} }
common_length = offset; result->common_length = offset;
} return true;
if (first == NULL || common_length <= line_length || common_length >= capacity) { }
bool console_completion_expand(const char *line, char *completed, size_t capacity)
{
if (line == NULL || completed == NULL || capacity == 0U) {
return false; return false;
} }
memcpy(completed, first, common_length); completion_expand_context_t result = {.line = line};
completed[common_length] = '\0'; console_completion_visit(line, collect_common_prefix, &result);
size_t line_length = strlen(line);
if (result.first == NULL || result.common_length <= line_length ||
result.common_length >= capacity) {
return false;
}
memcpy(completed, result.first, result.common_length);
completed[result.common_length] = '\0';
return true; return true;
} }
@@ -261,27 +286,15 @@ static ssize_t console_read_with_late_terminal_upgrade(int file_descriptor,
return received; return received;
} }
static bool add_linenoise_completion(const char *candidate, void *context)
{
linenoiseAddCompletion(context, candidate);
return true;
}
static void console_completion_callback(const char *buffer, linenoiseCompletions *completions) static void console_completion_callback(const char *buffer, linenoiseCompletions *completions)
{ {
/* Preserve ESP-IDF completion for registered root command names. */ console_completion_visit(buffer, add_linenoise_completion, completions);
if (strchr(buffer, ' ') == NULL) {
esp_console_get_completion(buffer, completions);
return;
}
const size_t buffer_length = strlen(buffer);
for (size_t index = 0;
index < sizeof(s_completion_candidates) / sizeof(s_completion_candidates[0]);
++index) {
const char *const candidate = s_completion_candidates[index];
const size_t candidate_length = strlen(candidate);
/* linenoise expects the complete replacement line, not only its suffix. */
if (candidate_length > buffer_length &&
strncmp(candidate, buffer, buffer_length) == 0) {
linenoiseAddCompletion(completions, candidate);
}
}
} }
void console_completion_install(void) void console_completion_install(void)
+7
View File
@@ -12,6 +12,13 @@ extern "C" {
/* Install late-terminal upgrade handling and project-specific completion. */ /* Install late-terminal upgrade handling and project-specific completion. */
void console_completion_install(void); void console_completion_install(void);
typedef bool (*console_completion_visitor_t)(const char *candidate, void *context);
/* Visit the same matching hint candidates used by both UART0 and admin SSH. */
void console_completion_visit(const char *line,
console_completion_visitor_t visitor,
void *context);
/* Bounded longest-prefix completion shared by the UART and admin SSH frontends. */ /* Bounded longest-prefix completion shared by the UART and admin SSH frontends. */
bool console_completion_expand(const char *line, char *completed, size_t capacity); bool console_completion_expand(const char *line, char *completed, size_t capacity);