Add Serialized SSH Administrative Console

This commit is contained in:
2026-08-30 18:07:02 +02:00
parent 44e3962444
commit 0a1bbd6782
17 changed files with 1115 additions and 79 deletions
+2 -2
View File
@@ -14,7 +14,7 @@ ESP32-S3 firmware for a secure, multi-transport RS-232 adapter. It operates one
## Development status
Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented and Phase 7 target-hardware validated. Phase 8A's bounded role-based user database and UART0 administration are complete. Phase 8B's role-aware HTTPS passwords, SSH passwords/public keys, and per-account session revocation are target-hardware validated. See the [Roadmap](docs/roadmap.md) for phase status and validation details.
Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented and Phase 7 target-hardware validated. Phase 8A's bounded role-based user database and UART0 administration are complete. Phase 8B's role-aware HTTPS passwords, SSH passwords/public keys, and per-account session revocation are target-hardware validated. Phase 8C adds a bounded administrator SSH shell backed by the same serialized command registry as UART0 and awaits target-hardware validation. See the [Roadmap](docs/roadmap.md) for phase status and validation details.
## Documentation
@@ -73,7 +73,7 @@ The firmware provides an interactive UART0 console at `serial-tool>`. Run `help`
The console supports session history, line editing, cursor movement, and hierarchical Tab completion. After an unattended boot, attach an ANSI-capable terminal and press Enter once to enable enhanced editing; this avoids blocking while no terminal is attached.
Serial configuration and Wi-Fi edits remain in RAM until explicitly saved with `serial save` or `wifi save`. Manage role-based HTTPS/SSH passwords and SSH public keys with the physical UART0 `user` command group. `web credentials show` now exposes only the legacy migration/recovery credential, not an active Phase 8B network login.
Serial configuration and Wi-Fi edits remain in RAM until explicitly saved with `serial save` or `wifi save`. Manage role-based HTTPS/SSH passwords and SSH public keys with the `user` command group on UART0 or an authenticated admin SSH shell; interactive secrets and recovery operations remain UART0-only. `web credentials show` exposes only the legacy migration/recovery credential, not an active Phase 8B network login.
## Security notes
+10 -5
View File
@@ -1,6 +1,6 @@
# Command reference
Use these commands from the UART0 `serial-tool>` administration console. Run `help` for the available root commands and `<group> help` for a group summary. Configuration changes are RAM-only unless explicitly saved.
UART0 and authenticated `admin` SSH sessions use the same registered command implementations through one serialized dispatcher. Admin SSH exposes the registry subject to a transport policy; commands requiring physical recovery authority, raw UART0 prompts, asynchronous callback output, or mutation of the SSH service carrying the command remain UART0-only. Run `help` for root commands and `<group> help` for a group summary. Configuration changes are RAM-only unless explicitly saved.
## System
@@ -24,12 +24,13 @@ Use these commands from the UART0 `serial-tool>` administration console. Run `he
| `user role <username> <user|admin> --force` | Change a role; the final administrator cannot be demoted. |
| `user password <username>` | Set and confirm a new password without echo. |
| `user password <username> --generate` | Replace a password with a generated value displayed once. |
| `user key add <username>` | Prompt for one bounded OpenSSH public-key line. |
| `user key add <username>` | Prompt on physical UART0 for one bounded OpenSSH public-key line. |
| `user key add <username> <type> <base64>` | Import a key non-interactively; intended for authenticated admin SSH and also accepted on UART0. |
| `user key delete <username> <0..2> --force` | Delete one key by the index shown by `user show`. |
| `user key clear <username> --force` | Delete all public keys for an account. |
| `user recover --force` | When normal user-database initialization failed, explicitly replace its blob from the current legacy network credential. |
Usernames must match `[a-z][a-z0-9_-]{0,15}`. Passwords contain 1264 printable ASCII characters. The fixed database supports eight users and three SSH keys per user; initial key types are `ssh-ed25519` and `ecdsa-sha2-nistp256`. A key may belong to only one account. Password verifiers, salts, raw key blobs, and passwords are absent from ordinary status output. `Ctrl-C` cancels a password or key prompt, and generated passwords are shown once.
Usernames must match `[a-z][a-z0-9_-]{0,15}`. Passwords contain 1264 printable ASCII characters. The fixed database supports eight users and three SSH keys per user; initial key types are `ssh-ed25519` and `ecdsa-sha2-nistp256`. A key may be assigned to multiple accounts but cannot be duplicated within one account. Password verifiers, salts, raw key blobs, and passwords are absent from ordinary status output. `Ctrl-C` cancels a password or key prompt, and generated passwords are shown once.
On the first Phase 8A boot, the old shared `admin` credential is imported as a role-`user` account, not silently granted administrator rights. Run `user bootstrap` from physical UART0 to establish the administrator. Phase 8B now authenticates HTTPS and SSH passwords through this database and enables stored SSH public keys. Before bootstrap, `web credentials rotate --force` and `web reset --force` synchronize the migrated verifier; after bootstrap, that legacy credential is recovery-only and does not authenticate or alter role-based users.
@@ -130,14 +131,18 @@ HTTPS listens on port 443 only. Authenticate with any current user-database user
| `ssh` / `ssh help` | Show SSH command usage. |
| `ssh status` | Show service state and resource information. |
| `ssh start` / `ssh stop` | Start or stop the SSH server. |
| `ssh sessions` | List active SSH sessions with account, user role, authentication method, and broker role. |
| `ssh sessions` | List active SSH sessions with account, user role, authentication method, route, broker role where applicable, and admin-worker state. |
| `ssh disconnect <session-id>` | Disconnect one SSH session. |
| `ssh counters` / `ssh clear-counters` | Show or clear SSH counters. |
| `ssh host-key info` | Display the OpenSSH host-key fingerprint. |
| `ssh host-key rotate --force` | Replace the persistent SSH host key. |
| `ssh reset --force` | Explicitly replace invalid or missing SSH material. |
SSH listens on port 22 and accepts user-database passwords plus stored `ssh-ed25519` and `ecdsa-sha2-nistp256` public keys. wolfSSH verifies key possession after the database authorizes the username/key pair; unsigned key probes do not complete authentication. Both `user` and `admin` currently receive the same broker-backed interactive shell/PTY serial stream. The administrative SSH shell arrives in Phase 8C. SSH does not provide `exec`, SFTP, SCP, forwarding, or subsystems. Verify the host fingerprint from `ssh host-key info` before connecting.
SSH listens on port 22 and accepts user-database passwords plus stored `ssh-ed25519` and `ecdsa-sha2-nistp256` public keys. wolfSSH verifies key possession after the database authorizes the username/key pair; unsigned key probes do not complete authentication. A `user` receives the broker-backed UART1 serial stream. An `admin` receives the administration shell instead, does not become a broker client, and cannot acquire a UART1 writer lease.
UART0 and admin SSH submit to one bounded queue, and one dispatcher task is the sole caller of `esp_console_run()`. Consequently, allowed 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.
The transport policy keeps `reboot`, `ping`/`wifi ping`, user bootstrap/recovery and interactive password/key forms, Wi-Fi secret entry/display, web recovery credentials/certificate/reset operations, and SSH start/stop/disconnect/reset/host-key mutation on physical UART0. Use generated user/password forms and `user key add <username> <type> <base64>` remotely. A connected administrator 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. Verify the host fingerprint from `ssh host-key info` before connecting.
## Hardware diagnostics
+9 -5
View File
@@ -38,7 +38,7 @@ These constraints apply across all phases:
| 5B | Offline xterm.js WebSocket serial terminal | **Complete** |
| 6 | Authenticated SSH serial transport | **Complete** |
| 7 | Local display and button interface | **Complete** |
| 8 | Role-based users and administrative access | **In progress (8A8B complete; 8C8E planned)** |
| 8 | Role-based users and administrative access | **In progress (8A8B complete; 8C validation pending; 8D8E planned)** |
| 9 | Security and production hardening | **Planned** |
| 10 | Authenticated, rollback-capable OTA | **Planned** |
| 11 | BLE serial transport and provisioning evaluation | **Planned** |
@@ -179,7 +179,7 @@ The software-crypto build no longer reproduces the HTTPD watchdog stall. This va
## Current and planned phases
The order below is the current plan. Phase 7, Phase 8A, and Phase 8B are complete; later work remains planned or under evaluation. Detailed requirements should be finalized at the start of each phase, and optional features must not weaken the completed serial and recovery paths.
The order below is the current plan. Phase 7, Phase 8A, and Phase 8B are complete; Phase 8C is implemented and awaiting target-hardware validation; later work remains planned or under evaluation. Detailed requirements should be finalized at the start of each phase, and optional features must not weaken the completed serial and recovery paths.
### Phase 7 — Local display and buttons
@@ -263,10 +263,14 @@ Implementation sequence:
- **Completed SSH-key validation:** Ed25519 and ECDSA P-256 public-key login work for both roles, including normal unsigned probe followed by signed proof-of-possession. A public key may be assigned to multiple accounts but cannot be duplicated within one account; the SSH username selects the account principal. Unsupported or incorrect credentials remain rejected without granting a broker client.
- **Completed ticket and revocation validation:** WebSocket tickets are account-bound, one-time, and expire as intended. Password, role, key, delete, and username-recreation mutations promptly revoke only the affected user's tickets and active network sessions, release any affected writer lease, and leave unrelated users connected.
- **Completed recovery and concurrency validation:** The database-unavailable path fails closed and retains UART0 recovery. Concurrent USB CDC, WebSocket, SSH, UART1, and UART0 operation preserves normal serial writer/observer behavior. `web credentials rotate --force` remains recovery-only after bootstrap. `web reset --force` restarts HTTPS with a replacement certificate without revoking unrelated SSH sessions; browsers correctly require a fresh TLS certificate acceptance before reconnecting.
3. **Phase 8C — SSH administrative shell — Planned**
- Route authenticated `admin` SSH shell sessions to the same registered administrative command set as UART0, without creating a broker client. Normal users continue to receive the existing broker-backed serial stream.
- Serialize command parsing safely because ESP-IDF console internals are process-global. Use bounded per-session input/output queues and a separate command worker; only the SSH owner task may call wolfSSH APIs.
3. **Phase 8C — SSH administrative shell — Implemented; validation pending**
- Authenticated `admin` SSH shell sessions route to a bounded administration worker and never create a broker client or acquire a serial writer lease. Normal `user` sessions retain the existing broker-backed serial stream.
- UART0 and admin SSH now submit complete lines to one fixed-length request queue. A single dispatcher task is the sole caller of ESP-IDF's non-reentrant `esp_console_run()` and therefore executes the same registered command handlers for both entry routes. The former separately implemented reduced SSH command dispatcher has been removed.
- The worker uses fixed per-session command/input and output buffers. Queue records contain copied secret-free principals and generation-tagged session tokens; late work is discarded after disconnect, slot reuse, role change, password/key mutation, or deletion. Task-local standard streams route canonical handler output into the applicable bounded SSH ring, and only the SSH owner task calls wolfSSH APIs.
- Transport policy, rather than duplicate command implementations, restricts operations requiring physical recovery authority, raw UART0 prompts, asynchronous callback output, or mutation of the active SSH service. Remote-safe generated account/password operations and non-interactive Ed25519/ECDSA key import use the canonical `user` handler. Bootstrap, database recovery, manual password/key prompts, recovery credentials, Wi-Fi secrets, reboot, ping, and SSH identity/service mutation remain physical-console-only.
- `ssh sessions` and `ssh counters` identify broker versus admin-console routes, worker command state, queued admin output, admission failures, and input backpressure. Admin sessions are checked for a current `admin` principal before command execution and during the active-session reconciliation.
- Keep SFTP, SCP, `exec`, forwarding, subsystems, and unauthenticated shells disabled.
- Pending target-hardware validation: route separation from the broker, administrative command input/editing/output/backpressure, generated user/password/key management including the longest ECDSA P-256 import, physical-only command rejection, targeted self/other-user revocation during queued work, UART0/SSH administration serialization, and concurrent USB/WebSocket/user-SSH/admin-SSH operation.
4. **Phase 8D — Web user administration — Planned**
- Add an admin-only user-management interface and typed, bounded APIs for account CRUD, roles, password generation/change, SSH-key management, and revocation. Never expose a generic HTTP endpoint that executes arbitrary CLI text.
- Hide administrative navigation and controls for normal users, and enforce every authorization decision server-side so hidden UI is not treated as a security boundary.
+35
View File
@@ -120,3 +120,38 @@ Run `web status`, `ssh sessions`, `web counters`, `ssh counters`, and `broker cl
### 5. Concurrency regression
With USB CDC, two role-based network users, one WebSocket terminal, one SSH terminal, and UART1 traffic active, alternate writer ownership and mutate one account. Confirm binary transparency, observer isolation, bounded authentication/handshake behavior, UART0 responsiveness, and no unexpected disconnect of the unaffected user. Record memory, broker, web, SSH, and serial counters before and after. Repeat after reboot to verify passwords, roles, keys, and authentication methods persist.
## Phase 8C SSH administrative shell
Use one disposable `admin` and one disposable `user`. Keep UART0 attached throughout. The SSH server still accepts only shell sessions: `exec`, subsystem/SFTP/SCP, forwarding, and unauthenticated connections must remain rejected.
### 1. Route separation and normal shells
1. Connect as the normal user and confirm the existing broker-backed UART1 serial stream, broker client, and writer/observer behavior are unchanged.
2. Connect as the administrator and confirm the `admin@serial-tool>` prompt appears. Run `help`, `status`, `memory`, `serial status`, `wifi status`, `web status`, `broker status`, and `broker clients`. Compare representative output with UART0 and confirm both routes execute the same registered command implementations.
3. From UART0 run `ssh sessions` and `broker clients`. The user session must show `route=broker`; the admin session must show `route=admin-console`, `broker=0`, `broker-role=n/a`, and no writer lease. The admin session must not start UART1 or alter broker client/writer counts merely by connecting.
### 2. Bounded command processing
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 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.
### 3. Remote account administration
Run `user list`, `user show <name>`, `user add <name> user --generate`, `user password <name> --generate`, `user role <name> admin --force`, and the key delete/clear operations from the administrative shell. Confirm generated passwords appear once only on that authenticated channel, affected account sessions are revoked, and unrelated sessions remain connected.
Import both supported key types through the remote form:
```text
user key add <username> ssh-ed25519 <base64-blob>
user key add <username> ecdsa-sha2-nistp256 <base64-blob>
```
Confirm the full ECDSA P-256 command is accepted, fingerprints appear in `user show`, a duplicate on the same account is rejected, and the same key can be imported for a second account. Verify subsequent private-key SSH login uses the selected SSH username.
### 4. Physical-only boundary and revocation
Confirm `user bootstrap`, `user recover --force`, manually entered password/key workflows, Wi-Fi secret entry/display, legacy recovery credentials, web certificate/reset operations, `reboot`, `ping`, and SSH service/identity mutation remain unavailable from SSH and continue to work only on UART0 where applicable. Their rejection must occur before any handler attempts to read raw UART0 input.
While an administrative command is queued or running, use UART0 to change that admin's role/password/key or delete it. Confirm no second remote administrative command runs after the mutation, the SSH session is revoked promptly, and queued output is not delivered to a reused SSH slot. Repeat with a different account mutation and confirm the administrator remains connected.
Finally, issue commands concurrently from UART0 and admin SSH, including `user list`, long `help` output, and one UART0 interactive password or key prompt while an SSH command waits. Confirm the single dispatcher serializes all `esp_console_run()` calls, UART0 retains its line editing/history/completion, prompt input is consumed only from UART0, outputs are not mixed between transports, and there is no stack overflow, corrupted argument parsing, database damage, or broker disruption.
+2
View File
@@ -21,6 +21,8 @@ idf_component_register(
"session_broker.c"
"session_console.c"
"ssh_security.c"
"admin_command_gate.c"
"admin_ssh_console.c"
"ssh_transport.c"
"ssh_console.c"
"usb_cdc_transport.c"
+44
View File
@@ -0,0 +1,44 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Shared recursive gate for administrative command execution origins. */
#include "admin_command_gate.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static SemaphoreHandle_t s_gate;
esp_err_t admin_command_gate_take(void)
{
taskENTER_CRITICAL(&s_lock);
SemaphoreHandle_t gate = s_gate;
taskEXIT_CRITICAL(&s_lock);
if (gate == NULL) {
SemaphoreHandle_t candidate = xSemaphoreCreateRecursiveMutex();
if (candidate == NULL) {
return ESP_ERR_NO_MEM;
}
taskENTER_CRITICAL(&s_lock);
if (s_gate == NULL) {
s_gate = candidate;
candidate = NULL;
}
gate = s_gate;
taskEXIT_CRITICAL(&s_lock);
if (candidate != NULL) {
vSemaphoreDelete(candidate);
}
}
return xSemaphoreTakeRecursive(gate, portMAX_DELAY) == pdTRUE ? ESP_OK : ESP_FAIL;
}
void admin_command_gate_give(void)
{
taskENTER_CRITICAL(&s_lock);
SemaphoreHandle_t gate = s_gate;
taskEXIT_CRITICAL(&s_lock);
if (gate != NULL) {
(void)xSemaphoreGiveRecursive(gate);
}
}
+17
View File
@@ -0,0 +1,17 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Serializes trusted UART0 and authenticated SSH administrative mutations. */
#pragma once
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
esp_err_t admin_command_gate_take(void);
void admin_command_gate_give(void);
#ifdef __cplusplus
}
#endif
+628
View File
@@ -0,0 +1,628 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Serialized, bounded administrative SSH command worker. */
#include "admin_ssh_console.h"
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include "esp_console.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#include "linenoise/linenoise.h"
#include "secure_random.h"
#include "user_database.h"
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
#define ADMIN_SSH_CONSOLE_RESPONSE_RESERVE 512U
#define ADMIN_SSH_CONSOLE_REQUEST_QUEUE_LENGTH 4U
#define ADMIN_SSH_CONSOLE_TASK_STACK_SIZE 12288U
#define ADMIN_SSH_CONSOLE_TASK_PRIORITY 4U
#define ADMIN_UART_CONSOLE_TASK_STACK_SIZE 6144U
#define ADMIN_UART_CONSOLE_TASK_PRIORITY 3U
#define ADMIN_SSH_CONSOLE_MAX_ARGUMENTS 10U
typedef struct {
bool active;
bool command_pending;
bool executing;
admin_ssh_console_token_t token;
user_principal_t principal;
size_t input_length;
uint8_t input[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
size_t output_start;
size_t output_length;
uint8_t output[ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY];
} admin_session_t;
typedef enum {
ADMIN_REQUEST_SSH = 0,
ADMIN_REQUEST_UART0,
} admin_request_origin_t;
typedef struct {
admin_request_origin_t origin;
admin_ssh_console_token_t token;
user_principal_t principal;
TaskHandle_t completion_task;
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
} admin_request_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static admin_session_t s_sessions[ADMIN_SSH_CONSOLE_MAX_SESSIONS];
static StaticQueue_t s_request_queue_storage;
static uint8_t s_request_queue_bytes[ADMIN_SSH_CONSOLE_REQUEST_QUEUE_LENGTH *
sizeof(admin_request_t)];
static QueueHandle_t s_request_queue;
static TaskHandle_t s_task;
static TaskHandle_t s_uart_task;
static bool s_initialized;
static bool s_dispatch_ready;
/* Accessed only by the single dispatcher task while a callback is running. */
static bool s_dispatch_remote;
static bool s_dispatch_output_previous_cr;
static user_principal_t s_dispatch_principal;
bool admin_ssh_console_dispatch_is_remote(void)
{
return xTaskGetCurrentTaskHandle() == s_task && s_dispatch_remote;
}
const user_principal_t *admin_ssh_console_dispatch_principal(void)
{
return admin_ssh_console_dispatch_is_remote() ? &s_dispatch_principal : NULL;
}
static bool token_valid(const admin_ssh_console_token_t *token)
{
return token != NULL && token->slot_index < ADMIN_SSH_CONSOLE_MAX_SESSIONS &&
token->session_id != 0U && token->slot_generation != 0U;
}
static bool token_identity_matches(const admin_session_t *session,
const admin_ssh_console_token_t *token)
{
return token_valid(token) && session->token.session_id == token->session_id &&
session->token.slot_generation == token->slot_generation;
}
static bool token_matches(const admin_session_t *session,
const admin_ssh_console_token_t *token)
{
return session->active && token_identity_matches(session, token);
}
static bool append_output_locked(admin_session_t *session,
const uint8_t *data, size_t length)
{
if (data == NULL || length > ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - session->output_length) {
return false;
}
size_t write_offset = (session->output_start + session->output_length) %
ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY;
size_t first = length;
if (first > ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - write_offset) {
first = ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - write_offset;
}
memcpy(session->output + write_offset, data, first);
if (length > first) {
memcpy(session->output, data + first, length - first);
}
session->output_length += length;
return true;
}
static bool worker_write(const admin_ssh_console_token_t *token,
const char *text)
{
if (!token_valid(token) || text == NULL) {
return false;
}
size_t length = strlen(text);
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
bool written = token_matches(session, token) &&
append_output_locked(session, (const uint8_t *)text, length);
taskEXIT_CRITICAL(&s_lock);
return written;
}
static void print_prompt(const admin_ssh_console_token_t *token)
{
(void)worker_write(token, "admin@serial-tool> ");
}
static int ssh_output_write(void *cookie, const char *buffer, int length)
{
const admin_ssh_console_token_t *token = cookie;
if (!token_valid(token) || buffer == NULL || length <= 0) {
return length == 0 ? 0 : -1;
}
size_t offset = 0U;
TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(5000U);
while (offset < (size_t)length) {
const uint8_t value = (uint8_t)buffer[offset];
uint8_t translated[2] = {value, 0U};
size_t translated_length = 1U;
if (value == '\n' && !s_dispatch_output_previous_cr) {
translated[0] = '\r';
translated[1] = '\n';
translated_length = 2U;
}
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
bool active = token_matches(session, token);
size_t available = active
? ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - session->output_length
: 0U;
bool written = active && available >= translated_length &&
append_output_locked(session, translated, translated_length);
taskEXIT_CRITICAL(&s_lock);
if (!active) {
errno = EPIPE;
return offset == 0U ? -1 : (int)offset;
}
if (written) {
s_dispatch_output_previous_cr = value == '\r';
++offset;
continue;
}
if ((int32_t)(xTaskGetTickCount() - deadline) >= 0) {
errno = EAGAIN;
return offset == 0U ? -1 : (int)offset;
}
vTaskDelay(pdMS_TO_TICKS(5U));
}
return length;
}
static bool remote_command_allowed(const admin_request_t *request)
{
char copy[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
memcpy(copy, request->line, sizeof(copy));
char *argv[ADMIN_SSH_CONSOLE_MAX_ARGUMENTS] = {0};
/* Use exactly the same quote/escape parser as esp_console_run(). */
size_t argc = esp_console_split_argv(copy, argv, ADMIN_SSH_CONSOLE_MAX_ARGUMENTS);
bool allowed = argc > 0U;
if (allowed && strcmp(argv[0], "reboot") == 0) {
allowed = false;
} else if (allowed && strcmp(argv[0], "ping") == 0) {
allowed = false;
} else if (allowed && strcmp(argv[0], "user") == 0 && argc >= 2U) {
if (strcmp(argv[1], "bootstrap") == 0 || strcmp(argv[1], "recover") == 0) {
allowed = false;
} else if (strcmp(argv[1], "add") == 0) {
allowed = argc == 5U && strcmp(argv[4], "--generate") == 0;
} else if (strcmp(argv[1], "password") == 0) {
allowed = argc == 4U && strcmp(argv[3], "--generate") == 0;
} else if (strcmp(argv[1], "key") == 0 && argc >= 3U &&
strcmp(argv[2], "add") == 0) {
allowed = argc == 6U;
}
} else if (allowed && strcmp(argv[0], "wifi") == 0 && argc >= 2U) {
if (strcmp(argv[1], "ping") == 0) {
allowed = false;
} else if (strcmp(argv[1], "profile") == 0 && argc >= 3U &&
strcmp(argv[2], "secret") == 0) {
allowed = false;
} else if (strcmp(argv[1], "ap") == 0 && argc >= 3U &&
(strcmp(argv[2], "secret") == 0 ||
strcmp(argv[2], "show-secret") == 0)) {
allowed = false;
}
} else if (allowed && strcmp(argv[0], "web") == 0 && argc >= 2U) {
allowed = strcmp(argv[1], "credentials") != 0 &&
strcmp(argv[1], "certificate") != 0 &&
strcmp(argv[1], "reset") != 0;
} else if (allowed && strcmp(argv[0], "ssh") == 0 && argc >= 2U) {
allowed = strcmp(argv[1], "start") != 0 && strcmp(argv[1], "stop") != 0 &&
strcmp(argv[1], "disconnect") != 0 && strcmp(argv[1], "reset") != 0;
if (allowed && strcmp(argv[1], "host-key") == 0) {
allowed = argc == 3U && strcmp(argv[2], "info") == 0;
}
}
secure_wipe(copy, sizeof(copy));
return allowed;
}
static void report_command_result(esp_err_t error, int command_result)
{
if (error == ESP_ERR_NOT_FOUND) {
printf("Unrecognized command\n");
} else if (error == ESP_OK && command_result != 0) {
printf("Command returned non-zero error code: 0x%x (%s)\n",
command_result, esp_err_to_name(command_result));
} else if (error != ESP_OK && error != ESP_ERR_INVALID_ARG) {
printf("Internal error: %s\n", esp_err_to_name(error));
}
}
static void dispatch_registered_command(admin_request_t *request)
{
FILE *saved_stdout = stdout;
FILE *saved_stderr = stderr;
FILE *remote_stream = NULL;
if (request->origin == ADMIN_REQUEST_SSH) {
remote_stream = funopen(&request->token, NULL, ssh_output_write, NULL, NULL);
if (remote_stream == NULL) {
(void)worker_write(&request->token, "Could not create command output stream.\r\n");
return;
}
setvbuf(remote_stream, NULL, _IONBF, 0);
stdout = remote_stream;
stderr = remote_stream;
s_dispatch_output_previous_cr = false;
s_dispatch_remote = true;
s_dispatch_principal = request->principal;
} else {
s_dispatch_remote = false;
secure_wipe(&s_dispatch_principal, sizeof(s_dispatch_principal));
}
int command_result = 0;
esp_err_t error = esp_console_run((const char *)request->line, &command_result);
report_command_result(error, command_result);
fflush(stdout);
s_dispatch_remote = false;
s_dispatch_output_previous_cr = false;
secure_wipe(&s_dispatch_principal, sizeof(s_dispatch_principal));
if (remote_stream != NULL) {
stdout = saved_stdout;
stderr = saved_stderr;
fclose(remote_stream);
}
}
static void worker_task(void *context)
{
(void)context;
for (;;) {
admin_request_t request;
if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) {
continue;
}
if (request.origin == ADMIN_REQUEST_UART0) {
dispatch_registered_command(&request);
if (request.completion_task != NULL) {
xTaskNotifyGive(request.completion_task);
}
secure_wipe(&request, sizeof(request));
continue;
}
bool current = false;
esp_err_t auth_error = user_database_principal_is_current(&request.principal, &current);
bool active;
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[request.token.slot_index];
active = token_matches(session, &request.token) && session->command_pending &&
!session->executing;
if (active) {
session->executing = true;
}
taskEXIT_CRITICAL(&s_lock);
bool authorized = active && auth_error == ESP_OK && current &&
request.principal.role == USER_ROLE_ADMIN &&
remote_command_allowed(&request);
if (authorized) {
dispatch_registered_command(&request);
} else if (active) {
(void)worker_write(&request.token,
auth_error == ESP_OK && current
? "Command is restricted to physical UART0.\r\n"
: "Administrative authorization is no longer current; closing session.\r\n");
}
bool prompt = false;
taskENTER_CRITICAL(&s_lock);
session = &s_sessions[request.token.slot_index];
if (token_matches(session, &request.token)) {
session->executing = false;
session->command_pending = false;
prompt = auth_error == ESP_OK && current &&
request.principal.role == USER_ROLE_ADMIN;
} else if (!session->active && session->executing &&
token_identity_matches(session, &request.token)) {
/* A disconnect invalidated this executing request; erase buffered secrets. */
secure_wipe(session, sizeof(*session));
}
taskEXIT_CRITICAL(&s_lock);
if (prompt) {
print_prompt(&request.token);
}
secure_wipe(&request, sizeof(request));
}
}
static void uart_frontend_task(void *context)
{
(void)context;
setvbuf(stdin, NULL, _IONBF, 0);
linenoiseSetMaxLineLen(ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY);
printf("\r\nType 'help' to get the list of commands.\r\n"
"Use UP/DOWN arrows for history and TAB for completion.\r\n");
for (;;) {
char *line = linenoise("serial-tool> ");
if (line == NULL) {
continue;
}
if (line[0] != '\0') {
(void)linenoiseHistoryAdd(line);
}
admin_request_t request = {
.origin = ADMIN_REQUEST_UART0,
.completion_task = xTaskGetCurrentTaskHandle(),
};
strlcpy((char *)request.line, line, sizeof(request.line));
linenoiseFree(line);
(void)ulTaskNotifyTake(pdTRUE, 0U);
if (xQueueSend(s_request_queue, &request, portMAX_DELAY) == pdTRUE) {
(void)ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
} else {
printf("Administrative command queue unavailable.\n");
}
secure_wipe(&request, sizeof(request));
}
}
esp_err_t admin_ssh_console_init(void)
{
taskENTER_CRITICAL(&s_lock);
if (s_initialized) {
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
taskEXIT_CRITICAL(&s_lock);
s_request_queue = xQueueCreateStatic(ADMIN_SSH_CONSOLE_REQUEST_QUEUE_LENGTH,
sizeof(admin_request_t), s_request_queue_bytes,
&s_request_queue_storage);
if (s_request_queue == NULL) {
return ESP_ERR_NO_MEM;
}
if (xTaskCreate(worker_task, "admin_ssh_console", ADMIN_SSH_CONSOLE_TASK_STACK_SIZE,
NULL, ADMIN_SSH_CONSOLE_TASK_PRIORITY, &s_task) != pdPASS) {
s_task = NULL;
return ESP_ERR_NO_MEM;
}
taskENTER_CRITICAL(&s_lock);
s_initialized = true;
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
esp_err_t admin_ssh_console_start_uart_frontend(void)
{
taskENTER_CRITICAL(&s_lock);
bool initialized = s_initialized;
bool already_started = s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!initialized) {
return ESP_ERR_INVALID_STATE;
}
if (already_started) {
return ESP_OK;
}
if (xTaskCreate(uart_frontend_task, "admin_uart_console",
ADMIN_UART_CONSOLE_TASK_STACK_SIZE, NULL,
ADMIN_UART_CONSOLE_TASK_PRIORITY, &s_uart_task) != pdPASS) {
s_uart_task = NULL;
return ESP_ERR_NO_MEM;
}
taskENTER_CRITICAL(&s_lock);
s_dispatch_ready = true;
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
const user_principal_t *principal)
{
if (!token_valid(token) || principal == NULL || principal->role != USER_ROLE_ADMIN) {
return ESP_ERR_INVALID_ARG;
}
taskENTER_CRITICAL(&s_lock);
bool ready = s_initialized && s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready) {
return ESP_ERR_INVALID_STATE;
}
bool current = false;
if (user_database_principal_is_current(principal, &current) != ESP_OK || !current) {
return ESP_ERR_INVALID_STATE;
}
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
if (session->executing) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_INVALID_STATE;
}
secure_wipe(session, sizeof(*session));
session->active = true;
session->token = *token;
session->principal = *principal;
static const char banner[] =
"ESP32 Serial Swiss Army Knife administrative SSH shell\r\n";
static const char prompt[] =
"Run 'help' for supported remote administrative commands.\r\nadmin@serial-tool> ";
(void)append_output_locked(session, (const uint8_t *)banner, sizeof(banner) - 1U);
(void)append_output_locked(session, (const uint8_t *)prompt, sizeof(prompt) - 1U);
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
void admin_ssh_console_close(const admin_ssh_console_token_t *token)
{
if (!token_valid(token)) {
return;
}
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
bool matched = token_matches(session, token);
if (matched) {
session->active = false;
if (!session->executing) {
secure_wipe(session, sizeof(*session));
}
}
taskEXIT_CRITICAL(&s_lock);
}
bool admin_ssh_console_accepts_input(const admin_ssh_console_token_t *token)
{
if (!token_valid(token)) {
return false;
}
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
bool accepts = token_matches(session, token) && !session->command_pending &&
session->output_length <= ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY -
ADMIN_SSH_CONSOLE_RESPONSE_RESERVE;
taskEXIT_CRITICAL(&s_lock);
return accepts;
}
bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
const uint8_t *data, size_t length,
size_t *consumed)
{
if (consumed == NULL || !token_valid(token) || (data == NULL && length != 0U)) {
return false;
}
*consumed = 0U;
for (size_t index = 0U; index < length; ++index) {
admin_request_t request = {0};
bool submit = false;
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
if (!token_matches(session, token) || session->command_pending) {
taskEXIT_CRITICAL(&s_lock);
return *consumed != 0U;
}
uint8_t value = data[index];
if (session->output_length >= ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY -
ADMIN_SSH_CONSOLE_RESPONSE_RESERVE) {
taskEXIT_CRITICAL(&s_lock);
return *consumed != 0U;
}
if (value == '\r' || value == '\n') {
if (value == '\n' && session->input_length == 0U) {
++*consumed;
taskEXIT_CRITICAL(&s_lock);
continue;
}
memcpy(request.line, session->input, session->input_length);
request.origin = ADMIN_REQUEST_SSH;
request.token = *token;
request.principal = session->principal;
session->input_length = 0U;
session->command_pending = true;
(void)append_output_locked(session, (const uint8_t *)"\r\n",
sizeof("\r\n") - 1U);
submit = true;
} else if (value == 0x03U) {
session->input_length = 0U;
(void)append_output_locked(session, (const uint8_t *)"^C\r\n",
sizeof("^C\r\n") - 1U);
(void)append_output_locked(session, (const uint8_t *)"admin@serial-tool> ",
sizeof("admin@serial-tool> ") - 1U);
} else if (value == 0x08U || value == 0x7fU) {
if (session->input_length > 0U) {
--session->input_length;
(void)append_output_locked(session, (const uint8_t *)"\b \b",
sizeof("\b \b") - 1U);
}
} else if (value >= 0x20U && value <= 0x7eU) {
if (session->input_length >= ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY) {
session->input_length = 0U;
(void)append_output_locked(session, (const uint8_t *)
"\r\nCommand too long; discarded.\r\nadmin@serial-tool> ",
sizeof("\r\nCommand too long; discarded.\r\nadmin@serial-tool> ") - 1U);
} else {
session->input[session->input_length++] = value;
(void)append_output_locked(session, &value, 1U);
}
}
++*consumed;
taskEXIT_CRITICAL(&s_lock);
if (submit && xQueueSend(s_request_queue, &request, 0U) != pdTRUE) {
taskENTER_CRITICAL(&s_lock);
session = &s_sessions[token->slot_index];
if (token_matches(session, token)) {
session->command_pending = false;
(void)append_output_locked(session, (const uint8_t *)
"Administrative command queue is busy.\r\nadmin@serial-tool> ",
sizeof("Administrative command queue is busy.\r\nadmin@serial-tool> ") - 1U);
}
taskEXIT_CRITICAL(&s_lock);
}
secure_wipe(&request, sizeof(request));
}
return true;
}
esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *token,
uint8_t *data, size_t capacity,
size_t *received)
{
if (received == NULL || data == NULL || capacity == 0U || !token_valid(token)) {
return ESP_ERR_INVALID_ARG;
}
*received = 0U;
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
if (!token_matches(session, token)) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_NOT_FOUND;
}
size_t copied = session->output_length < capacity ? session->output_length : capacity;
size_t first = copied;
if (first > ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - session->output_start) {
first = ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - session->output_start;
}
memcpy(data, session->output + session->output_start, first);
if (copied > first) {
memcpy(data + first, session->output, copied - first);
}
session->output_start = (session->output_start + copied) %
ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY;
session->output_length -= copied;
*received = copied;
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
esp_err_t admin_ssh_console_get_session_snapshot(
const admin_ssh_console_token_t *token,
admin_ssh_console_session_snapshot_t *snapshot)
{
if (snapshot == NULL || !token_valid(token)) {
return ESP_ERR_INVALID_ARG;
}
memset(snapshot, 0, sizeof(*snapshot));
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
if (!token_matches(session, token)) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_NOT_FOUND;
}
snapshot->active = true;
snapshot->command_pending = session->command_pending;
snapshot->input_pending = session->input_length != 0U;
snapshot->output_pending = session->output_length != 0U;
snapshot->input_length = session->input_length;
snapshot->output_length = session->output_length;
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
+65
View File
@@ -0,0 +1,65 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Bounded, transport-neutral administrative command worker for SSH sessions. */
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#include "user_database.h"
#ifdef __cplusplus
extern "C" {
#endif
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
typedef struct {
uint8_t slot_index;
uint32_t session_id;
uint32_t slot_generation;
} admin_ssh_console_token_t;
typedef struct {
bool active;
bool command_pending;
bool input_pending;
bool output_pending;
size_t input_length;
size_t output_length;
} admin_ssh_console_session_snapshot_t;
/* Starts the single command worker. It is the sole esp_console_run() caller. */
esp_err_t admin_ssh_console_init(void);
/* Called after all ESP-IDF commands are registered; starts the UART0 frontend. */
esp_err_t admin_ssh_console_start_uart_frontend(void);
/* Valid only while a registered command callback runs on the dispatcher task. */
bool admin_ssh_console_dispatch_is_remote(void);
const user_principal_t *admin_ssh_console_dispatch_principal(void);
/* The token and principal are copied; no SSH or socket objects cross this boundary. */
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
const user_principal_t *principal);
void admin_ssh_console_close(const admin_ssh_console_token_t *token);
/* Called only by the SSH owner task. Returns false when input must be backpressured. */
bool admin_ssh_console_accepts_input(const admin_ssh_console_token_t *token);
bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
const uint8_t *data, size_t length,
size_t *consumed);
/* Called only by the SSH owner task; copies already-produced output without blocking. */
esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *token,
uint8_t *data, size_t capacity,
size_t *received);
esp_err_t admin_ssh_console_get_session_snapshot(
const admin_ssh_console_token_t *token,
admin_ssh_console_session_snapshot_t *snapshot);
#ifdef __cplusplus
}
#endif
+5
View File
@@ -6,6 +6,7 @@
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "driver/uart.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
@@ -31,6 +32,10 @@ static esp_err_t read_input(const char *prompt, uint8_t *output, size_t capacity
if (prompt == NULL || output == NULL || output_length == NULL || capacity == 0U) {
return ESP_ERR_INVALID_ARG;
}
if (admin_ssh_console_dispatch_is_remote()) {
printf("Interactive input is restricted to physical UART0.\n");
return ESP_ERR_NOT_SUPPORTED;
}
*output_length = 0U;
memset(output, 0, capacity);
esp_err_t error = prepare_prompt(prompt);
+9 -4
View File
@@ -1,6 +1,7 @@
#include <string.h>
#include "driver/uart.h"
#include "admin_ssh_console.h"
#include "console_completion.h"
#include "esp_console.h"
#include "esp_err.h"
@@ -64,6 +65,8 @@ void app_main(void)
ESP_ERROR_CHECK(status_led_init());
ESP_ERROR_CHECK(rs232_port_owner_init());
ESP_ERROR_CHECK(rs232_hw_test_init());
/* Reserve the shared UART0 dispatcher before optional SSH/network services. */
ESP_ERROR_CHECK(admin_ssh_console_init());
/* The optional display can fail without affecting UART0 or serial transports. */
esp_err_t local_display_error = local_display_init();
@@ -274,8 +277,9 @@ void app_main(void)
esp_console_repl_config_t repl_config = ESP_CONSOLE_REPL_CONFIG_DEFAULT();
repl_config.prompt = "serial-tool> ";
repl_config.max_cmdline_length = 160;
repl_config.task_stack_size = 8192;
repl_config.max_cmdline_length = ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY;
/* The stock REPL task remains dormant; our shared frontend owns line dispatch. */
repl_config.task_stack_size = 2048;
/*
* UART0 remains dedicated to development and diagnostics. The external
@@ -304,8 +308,9 @@ void app_main(void)
ESP_ERROR_CHECK(system_console_register_commands());
/* Upgrade late UART terminals safely and add nested completion. */
console_completion_install();
ESP_ERROR_CHECK(esp_console_start_repl(repl));
ESP_ERROR_CHECK(admin_ssh_console_start_uart_frontend());
ESP_LOGI(TAG, "Interactive test console ready at %d baud", CONSOLE_BAUD_RATE);
ESP_LOGI(TAG, "Shared UART0/SSH administration console ready at %d baud",
CONSOLE_BAUD_RATE);
ESP_LOGI(TAG, "Type 'help' for commands; native USB starts UART1 only when its host port opens");
}
+26 -12
View File
@@ -50,6 +50,15 @@ static const char *auth_method_name(user_auth_method_t method)
: method == USER_AUTH_METHOD_SSH_PUBLIC_KEY ? "public-key" : "unknown";
}
static const char *route_name(ssh_transport_session_route_t route)
{
return route == SSH_TRANSPORT_ROUTE_BROKER
? "broker"
: route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE
? "admin-console"
: "none";
}
static int print_sessions(const ssh_transport_snapshot_t *snapshot)
{
printf("SSH sessions: active=%" PRIu32 "/%u\n",
@@ -60,21 +69,22 @@ static int print_sessions(const ssh_transport_snapshot_t *snapshot)
continue;
}
printf(" id=%" PRIu32 " slot=%u peer=%s state=%s auth=%s account=%s"
" user-role=%s method=%s broker=%" PRIu32
" broker-role=%s rx-pending=%s tx-pending=%s closing=%s\n",
" user-role=%s method=%s route=%s broker=%" PRIu32
" broker-role=%s admin-command=%s admin-output=%" PRIu32
" rx-pending=%s tx-pending=%s closing=%s\n",
session->session_id, (unsigned int)index, session->peer,
state_name(session->state), session->authenticated ? "yes" : "no",
session->principal_valid ? session->username : "-",
session->principal_valid
? user_role_to_string(session->user_role)
: "-",
session->principal_valid
? auth_method_name(session->auth_method)
: "-",
session->broker_client_id,
session->broker_client_id == SESSION_BROKER_NO_CLIENT
? "unattached"
: (session->writer ? "writer" : "observer"),
session->principal_valid ? user_role_to_string(session->user_role) : "-",
session->principal_valid ? auth_method_name(session->auth_method) : "-",
route_name(session->route), session->broker_client_id,
session->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE
? "n/a"
: (session->broker_client_id == SESSION_BROKER_NO_CLIENT
? "unattached"
: (session->writer ? "writer" : "observer")),
session->admin_command_pending ? "running" : "idle",
session->admin_output_pending,
session->rx_pending ? "yes" : "no",
session->tx_pending ? "yes" : "no",
session->close_requested ? "yes" : "no");
@@ -135,6 +145,10 @@ static int show_counters(void)
counter->disconnections, counter->writer_requests,
counter->writer_grants, counter->writer_denials,
counter->writer_revocations);
printf("Admin console: admissions=%" PRIu64 " admission-failures=%" PRIu64
" input-backpressure=%" PRIu64 "\n",
counter->admin_console_admissions, counter->admin_console_admission_failures,
counter->admin_console_input_rejections);
printf("Stream: rx=%" PRIu64 " accepted=%" PRIu64
" rejected=%" PRIu64 " tx=%" PRIu64
" io-failures=%" PRIu64 " session-revocations=%" PRIu64 "\n",
+148 -11
View File
@@ -9,6 +9,7 @@
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_timer.h"
@@ -57,6 +58,7 @@ typedef struct {
int socket_fd;
WOLFSSH *ssh;
session_broker_client_id_t broker_client_id;
ssh_transport_session_route_t route;
user_principal_t principal;
user_principal_t pending_principal;
bool principal_valid;
@@ -125,6 +127,16 @@ static void notify_task(void)
}
}
static admin_ssh_console_token_t admin_console_token(const ssh_slot_t *slot,
size_t slot_index)
{
return (admin_ssh_console_token_t){
.slot_index = (uint8_t)slot_index,
.session_id = slot->session_id,
.slot_generation = slot->generation,
};
}
static void publish_slot(const ssh_slot_t *slot, size_t slot_index)
{
ssh_transport_session_snapshot_t snapshot = {
@@ -140,11 +152,21 @@ static void publish_slot(const ssh_slot_t *slot, size_t slot_index)
.socket_fd = slot->socket_fd,
.broker_client_id = slot->broker_client_id,
.state = slot->state,
.route = slot->route,
.user_role = slot->principal_valid ? slot->principal.role : USER_ROLE_USER,
.auth_method = slot->principal_valid
? slot->principal.method
: USER_AUTH_METHOD_PASSWORD,
};
if (slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE) {
admin_ssh_console_session_snapshot_t admin_snapshot;
admin_ssh_console_token_t token = admin_console_token(slot, slot_index);
if (admin_ssh_console_get_session_snapshot(&token, &admin_snapshot) == ESP_OK) {
snapshot.admin_command_pending = admin_snapshot.command_pending;
snapshot.admin_output_pending = (uint32_t)admin_snapshot.output_length;
snapshot.tx_pending = snapshot.tx_pending || admin_snapshot.output_pending;
}
}
if (slot->principal_valid) {
memcpy(snapshot.username, slot->principal.username,
slot->principal.username_length);
@@ -442,6 +464,11 @@ static void close_socket(int *socket_fd)
static bool cleanup_slot(ssh_slot_t *slot)
{
if (slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE) {
size_t slot_index = (size_t)(slot - s_slots);
admin_ssh_console_token_t token = admin_console_token(slot, slot_index);
admin_ssh_console_close(&token);
}
if (slot->ssh != NULL) {
(void)wolfSSH_shutdown(slot->ssh);
wolfSSH_free(slot->ssh);
@@ -886,14 +913,33 @@ static void process_handshake(ssh_slot_t *slot, size_t slot_index)
return;
}
esp_err_t error = connect_broker(slot, slot_index);
if (error != ESP_OK) {
add_counter(&s_counters.broker_failures, 1U);
request_slot_close(slot, false);
esp_err_t error;
if (slot->principal.role == USER_ROLE_USER) {
error = connect_broker(slot, slot_index);
if (error != ESP_OK) {
add_counter(&s_counters.broker_failures, 1U);
request_slot_close(slot, false);
return;
}
slot->route = SSH_TRANSPORT_ROUTE_BROKER;
} else if (slot->principal.role == USER_ROLE_ADMIN) {
admin_ssh_console_token_t token = admin_console_token(slot, slot_index);
error = admin_ssh_console_open(&token, &slot->principal);
if (error != ESP_OK) {
add_counter(&s_counters.admin_console_admission_failures, 1U);
request_slot_close(slot, false);
return;
}
slot->route = SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE;
add_counter(&s_counters.admin_console_admissions, 1U);
} else {
request_slot_close(slot, true);
return;
}
if (!slot_principal_is_current(slot)) {
disconnect_failed_admission(slot);
if (slot->route == SSH_TRANSPORT_ROUTE_BROKER) {
disconnect_failed_admission(slot);
}
request_slot_close(slot, true);
return;
}
@@ -1086,12 +1132,98 @@ static bool read_broker_output(ssh_slot_t *slot)
return received == 0U ? true : flush_client_output(slot);
}
static void process_active(ssh_slot_t *slot)
static bool reconcile_admin_principal(ssh_slot_t *slot)
{
bool healthy = service_wolfssh_io(slot) &&
drain_broker_events(slot) && reconcile_writer(slot) &&
flush_client_output(slot) && read_broker_output(slot) &&
flush_client_input(slot) && receive_client_input(slot);
int64_t now = esp_timer_get_time();
if (now - slot->last_reconcile_us < SSH_TRANSPORT_RECONCILE_INTERVAL_US) {
return true;
}
slot->last_reconcile_us = now;
if (!slot_principal_is_current(slot) || slot->principal.role != USER_ROLE_ADMIN) {
request_slot_close(slot, true);
return false;
}
return true;
}
static bool flush_admin_input(ssh_slot_t *slot, size_t slot_index)
{
if (slot->rx_offset >= slot->rx_length) {
slot->rx_offset = 0U;
slot->rx_length = 0U;
return true;
}
admin_ssh_console_token_t token = admin_console_token(slot, slot_index);
size_t consumed = 0U;
bool accepted = admin_ssh_console_feed_input(
&token, slot->rx_buffer + slot->rx_offset,
slot->rx_length - slot->rx_offset, &consumed);
if (consumed > 0U) {
slot->rx_offset += consumed;
add_counter(&s_counters.rx_accepted_bytes, consumed);
}
if (slot->rx_offset >= slot->rx_length) {
slot->rx_offset = 0U;
slot->rx_length = 0U;
}
if (!accepted && consumed == 0U) {
add_counter(&s_counters.admin_console_input_rejections, 1U);
}
return true;
}
static bool receive_admin_input(ssh_slot_t *slot, size_t slot_index)
{
if (slot->rx_length != 0U) {
return flush_admin_input(slot, slot_index);
}
admin_ssh_console_token_t token = admin_console_token(slot, slot_index);
if (!admin_ssh_console_accepts_input(&token)) {
return true;
}
slot->io_read_budget = SSH_TRANSPORT_WOLFSSH_READ_BUDGET;
int result = wolfSSH_stream_read(slot->ssh, slot->rx_buffer,
sizeof(slot->rx_buffer));
if (result > 0) {
slot->rx_offset = 0U;
slot->rx_length = (size_t)result;
add_counter(&s_counters.rx_bytes, (uint64_t)result);
return flush_admin_input(slot, slot_index);
}
return result == 0 || wolfssh_would_block(slot->ssh, result);
}
static bool read_admin_output(ssh_slot_t *slot, size_t slot_index)
{
if (slot->tx_length != 0U) {
return true;
}
admin_ssh_console_token_t token = admin_console_token(slot, slot_index);
size_t received = 0U;
esp_err_t error = admin_ssh_console_read_output(&token, slot->tx_buffer,
sizeof(slot->tx_buffer), &received);
if (error != ESP_OK && error != ESP_ERR_NOT_FOUND) {
return false;
}
slot->tx_offset = 0U;
slot->tx_length = received;
return error == ESP_OK;
}
static void process_active(ssh_slot_t *slot, size_t slot_index)
{
bool healthy = service_wolfssh_io(slot);
if (healthy && slot->route == SSH_TRANSPORT_ROUTE_BROKER) {
healthy = drain_broker_events(slot) && reconcile_writer(slot) &&
flush_client_output(slot) && read_broker_output(slot) &&
flush_client_input(slot) && receive_client_input(slot);
} else if (healthy && slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE) {
healthy = reconcile_admin_principal(slot) && flush_client_output(slot) &&
read_admin_output(slot, slot_index) && flush_client_output(slot) &&
receive_admin_input(slot, slot_index);
} else if (healthy) {
healthy = false;
}
if (!healthy) {
add_counter(&s_counters.io_failures, 1U);
request_slot_close(slot, false);
@@ -1117,7 +1249,7 @@ static void process_slots(void)
if (slot->state == SSH_TRANSPORT_SESSION_HANDSHAKE) {
process_handshake(slot, index);
} else if (slot->state == SSH_TRANSPORT_SESSION_ACTIVE) {
process_active(slot);
process_active(slot, index);
}
publish_slot(slot, index);
}
@@ -1186,6 +1318,11 @@ esp_err_t ssh_transport_init(void)
error = ESP_ERR_NO_MEM;
goto fail;
}
error = admin_ssh_console_init();
if (error != ESP_OK) {
vSemaphoreDelete(command_mutex);
goto fail;
}
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
s_slots[index].state = SSH_TRANSPORT_SESSION_FREE;
s_slots[index].socket_fd = -1;
+12
View File
@@ -27,6 +27,12 @@ typedef enum {
SSH_TRANSPORT_SESSION_CLOSING,
} ssh_transport_session_state_t;
typedef enum {
SSH_TRANSPORT_ROUTE_NONE = 0,
SSH_TRANSPORT_ROUTE_BROKER,
SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE,
} ssh_transport_session_route_t;
typedef struct {
uint64_t starts;
uint64_t start_failures;
@@ -52,6 +58,9 @@ typedef struct {
uint64_t tx_bytes;
uint64_t io_failures;
uint64_t session_revocations;
uint64_t admin_console_admissions;
uint64_t admin_console_admission_failures;
uint64_t admin_console_input_rejections;
} ssh_transport_counters_t;
typedef struct {
@@ -62,11 +71,14 @@ typedef struct {
bool close_requested;
bool rx_pending;
bool tx_pending;
bool admin_command_pending;
uint32_t admin_output_pending;
uint32_t session_id;
uint32_t generation;
int socket_fd;
session_broker_client_id_t broker_client_id;
ssh_transport_session_state_t state;
ssh_transport_session_route_t route;
user_role_t user_role;
user_auth_method_t auth_method;
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
+97 -40
View File
@@ -7,6 +7,8 @@
#include <stdlib.h>
#include <string.h>
#include "admin_command_gate.h"
#include "admin_ssh_console.h"
#include "console_input.h"
#include "esp_console.h"
#include "mbedtls/base64.h"
@@ -18,6 +20,9 @@
#define USER_CONSOLE_KEY_LINE_CAPACITY 256U
/* `user` commands are serialized by the administration gate. */
static user_database_snapshot_t s_user_snapshot;
static void print_usage(void)
{
printf("Usage:\n");
@@ -30,6 +35,7 @@ static void print_usage(void)
printf(" user role <username> <user|admin> --force\n");
printf(" user password <username> [--generate]\n");
printf(" user key add <username>\n");
printf(" user key add <username> <type> <base64>\n");
printf(" user key delete <username> <0..2> --force\n");
printf(" user key clear <username> --force\n");
}
@@ -88,23 +94,22 @@ static void print_user(const user_database_user_snapshot_t *user)
static int show_users(const char *selected)
{
user_database_snapshot_t snapshot;
esp_err_t error = user_database_get_snapshot(&snapshot);
esp_err_t error = user_database_get_snapshot(&s_user_snapshot);
if (error != ESP_OK) {
printf("User database unavailable: %s\n", esp_err_to_name(error));
return 1;
}
if (selected == NULL) {
printf("User database: generation=%lu users=%u/%u admins=%u bootstrapped=%s\n",
(unsigned long)snapshot.generation,
(unsigned int)snapshot.user_count,
(unsigned long)s_user_snapshot.generation,
(unsigned int)s_user_snapshot.user_count,
USER_DATABASE_MAX_USERS,
(unsigned int)snapshot.admin_count,
snapshot.admin_bootstrapped ? "yes" : "no");
(unsigned int)s_user_snapshot.admin_count,
s_user_snapshot.admin_bootstrapped ? "yes" : "no");
}
bool found = false;
for (size_t index = 0U; index < USER_DATABASE_MAX_USERS; ++index) {
const user_database_user_snapshot_t *user = &snapshot.users[index];
const user_database_user_snapshot_t *user = &s_user_snapshot.users[index];
if (!user->active ||
(selected != NULL &&
(strlen(selected) != user->username_length ||
@@ -118,7 +123,7 @@ static int show_users(const char *selected)
printf("User '%s' not found.\n", selected);
return 1;
}
if (!snapshot.admin_bootstrapped) {
if (!s_user_snapshot.admin_bootstrapped) {
printf("Administrative network access is not bootstrapped; use 'user bootstrap'.\n");
}
return 0;
@@ -297,6 +302,42 @@ static bool key_delimiter(uint8_t value)
return value == ' ' || value == '\t';
}
static int add_key_parts(const char *username,
const uint8_t *type, size_t type_length,
const uint8_t *encoded, size_t encoded_length)
{
uint8_t blob[USER_DATABASE_SSH_KEY_BLOB_CAPACITY] = {0};
size_t blob_length = 0U;
int decoded = mbedtls_base64_decode(blob, sizeof(blob), &blob_length,
encoded, encoded_length);
if (decoded != 0 || !user_database_key_valid(type, type_length, blob, blob_length)) {
printf("Unsupported or malformed key; use ssh-ed25519 or ecdsa-sha2-nistp256.\n");
secure_wipe(blob, sizeof(blob));
return 1;
}
uint8_t key_index = 0U;
esp_err_t error = user_database_add_ssh_key(
(const uint8_t *)username, strlen(username), type, type_length,
blob, blob_length, &key_index);
secure_wipe(blob, sizeof(blob));
if (error != ESP_OK) {
if (error == USER_DATABASE_ERR_DUPLICATE_SSH_KEY) {
printf("Could not add SSH key: that public key is already assigned to this account.\n");
} else if (error == ESP_ERR_NO_MEM) {
printf("Could not add SSH key: the account already has %u keys.\n",
USER_DATABASE_MAX_SSH_KEYS_PER_USER);
} else {
printf("Could not add SSH key: %s\n", esp_err_to_name(error));
}
return 1;
}
revoke_user_network_sessions(username);
printf("SSH public key added at index %u. Public-key login is active.\n",
(unsigned int)key_index);
return 0;
}
static int add_key(const char *username)
{
uint8_t line[USER_CONSOLE_KEY_LINE_CAPACITY] = {0};
@@ -338,42 +379,15 @@ static int add_key(const char *username)
size_t encoded_length = encoded_end == NULL
? remaining
: (size_t)(encoded_end - encoded);
uint8_t blob[USER_DATABASE_SSH_KEY_BLOB_CAPACITY] = {0};
size_t blob_length = 0U;
int decoded = mbedtls_base64_decode(blob, sizeof(blob), &blob_length,
encoded, encoded_length);
if (decoded != 0 ||
!user_database_key_valid(line, type_length, blob, blob_length)) {
printf("Unsupported or malformed key; use ssh-ed25519 or ecdsa-sha2-nistp256.\n");
secure_wipe(blob, sizeof(blob));
secure_wipe(line, sizeof(line));
return 1;
}
uint8_t key_index = 0U;
error = user_database_add_ssh_key((const uint8_t *)username, strlen(username),
line, type_length, blob, blob_length, &key_index);
secure_wipe(blob, sizeof(blob));
int result = add_key_parts(username, line, type_length, encoded, encoded_length);
secure_wipe(line, sizeof(line));
if (error != ESP_OK) {
if (error == USER_DATABASE_ERR_DUPLICATE_SSH_KEY) {
printf("Could not add SSH key: that public key is already assigned to this account.\n");
} else if (error == ESP_ERR_NO_MEM) {
printf("Could not add SSH key: the account already has %u keys.\n",
USER_DATABASE_MAX_SSH_KEYS_PER_USER);
} else {
printf("Could not add SSH key: %s\n", esp_err_to_name(error));
}
return 1;
}
revoke_user_network_sessions(username);
printf("SSH public key added at index %u. Public-key login is active.\n",
(unsigned int)key_index);
return 0;
return result;
}
static int command_user(int argc, char **argv)
static int command_user_inner(int argc, char **argv)
{
bool remote = admin_ssh_console_dispatch_is_remote();
const user_principal_t *principal = admin_ssh_console_dispatch_principal();
if (argc == 1 || (argc == 2 && strcmp(argv[1], "status") == 0) ||
(argc == 2 && strcmp(argv[1], "list") == 0)) {
return show_users(NULL);
@@ -383,6 +397,10 @@ static int command_user(int argc, char **argv)
}
if (argc == 3 && strcmp(argv[1], "recover") == 0 &&
strcmp(argv[2], "--force") == 0) {
if (remote) {
printf("User database recovery is restricted to physical UART0.\n");
return 1;
}
return recover_database();
}
if ((argc == 2 || argc == 3) && strcmp(argv[1], "bootstrap") == 0) {
@@ -391,6 +409,10 @@ static int command_user(int argc, char **argv)
print_usage();
return 1;
}
if (remote) {
printf("Administrator bootstrap is restricted to physical UART0.\n");
return 1;
}
return bootstrap(generated);
}
if ((argc == 4 || argc == 5) && strcmp(argv[1], "add") == 0) {
@@ -399,6 +421,10 @@ static int command_user(int argc, char **argv)
print_usage();
return 1;
}
if (remote && !generated) {
printf("Interactive password entry is restricted to physical UART0; use --generate.\n");
return 1;
}
return add_user(argv[2], argv[3], generated);
}
if (argc == 4 && strcmp(argv[1], "delete") == 0 &&
@@ -438,12 +464,31 @@ static int command_user(int argc, char **argv)
print_usage();
return 1;
}
if (remote && !generated) {
printf("Interactive password entry is restricted to physical UART0; use --generate.\n");
return 1;
}
if (remote && generated && principal != NULL &&
strlen(argv[2]) == principal->username_length &&
memcmp(argv[2], principal->username, principal->username_length) == 0) {
printf("Remote generated-password changes for the current admin are disabled; use UART0.\n");
return 1;
}
return change_password(argv[2], generated);
}
if (argc == 4 && strcmp(argv[1], "key") == 0 &&
strcmp(argv[2], "add") == 0) {
if (remote) {
printf("Interactive SSH key entry is restricted to physical UART0; provide type and Base64 arguments.\n");
return 1;
}
return add_key(argv[3]);
}
if (argc == 6 && strcmp(argv[1], "key") == 0 &&
strcmp(argv[2], "add") == 0) {
return add_key_parts(argv[3], (const uint8_t *)argv[4], strlen(argv[4]),
(const uint8_t *)argv[5], strlen(argv[5]));
}
if (argc == 6 && strcmp(argv[1], "key") == 0 &&
strcmp(argv[2], "delete") == 0 && strcmp(argv[5], "--force") == 0) {
uint8_t index;
@@ -477,6 +522,18 @@ static int command_user(int argc, char **argv)
return 1;
}
static int command_user(int argc, char **argv)
{
esp_err_t error = admin_command_gate_take();
if (error != ESP_OK) {
printf("Administrative command gate unavailable: %s\n", esp_err_to_name(error));
return 1;
}
int result = command_user_inner(argc, argv);
admin_command_gate_give();
return result;
}
esp_err_t user_console_register_commands(void)
{
const esp_console_cmd_t command = {
+1
View File
@@ -146,6 +146,7 @@ esp_err_t user_database_remove_ssh_key(const uint8_t *username,
esp_err_t user_database_clear_ssh_keys(const uint8_t *username,
size_t username_length);
bool user_database_username_valid(const uint8_t *username, size_t length);
bool user_database_password_valid(const uint8_t *password, size_t length);
bool user_database_key_valid(const uint8_t *key_type, size_t key_type_length,
+5
View File
@@ -9,6 +9,7 @@
#include <stdlib.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "driver/uart.h"
#include "esp_console.h"
#include "esp_err.h"
@@ -242,6 +243,10 @@ static esp_err_t apply_candidate(wifi_app_config_t *candidate)
static esp_err_t read_secret_no_echo(uint8_t *secret, uint8_t *secret_len)
{
if (admin_ssh_console_dispatch_is_remote()) {
printf("Interactive secret entry is restricted to physical UART0.\n");
return ESP_ERR_NOT_SUPPORTED;
}
uint8_t buffer[WIFI_CONSOLE_SECRET_CAPACITY];
size_t length = 0U;
memset(buffer, 0, sizeof(buffer));