Add Typed SSH Service Controls
Provide admin-only SSH status plus generation-safe start, stop, and single-session disconnect operations through the bounded dispatcher. Include Settings UI coverage, lifecycle safeguards, and host-side regression tests.
This commit is contained in:
@@ -31,6 +31,8 @@ typedef int *SemaphoreHandle_t;
|
||||
#define pdMS_TO_TICKS(x) (x)
|
||||
#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U
|
||||
static unsigned lock_depth, ticks, runs, actions;
|
||||
static uint32_t ssh_settings_executed;
|
||||
static void web_ssh_settings_execute(uint32_t id) { assert(!lock_depth); ssh_settings_executed = id; }
|
||||
static uint32_t broker_settings_executed;
|
||||
static void web_broker_settings_execute(uint32_t id) { assert(!lock_depth); broker_settings_executed = id; }
|
||||
static uint32_t serial_settings_executed, account_settings_executed, network_settings_executed, display_settings_executed;
|
||||
|
||||
@@ -394,5 +394,20 @@ int main(void)
|
||||
assert(broker_settings_executed == 51 && display_settings_executed == 52 && network_settings_executed == 53 && account_settings_executed == 54);
|
||||
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
|
||||
puts("PASS: Broker typed IDs, not-ready/full queue, routing and unchanged dispatcher capacity");
|
||||
assert(admin_ssh_console_submit_ssh_settings(0) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = false;
|
||||
assert(admin_ssh_console_submit_ssh_settings(1) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = true; queue_full = true;
|
||||
assert(admin_ssh_console_submit_ssh_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
|
||||
queue_full = false;
|
||||
assert(admin_ssh_console_submit_ssh_settings(61) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_broker_settings(62) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_network_settings(63) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_account_settings(64) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_ssh_settings(65) == ESP_ERR_TIMEOUT);
|
||||
pump(worker_task);
|
||||
assert(ssh_settings_executed == 61 && broker_settings_executed == 62 && network_settings_executed == 63 && account_settings_executed == 64);
|
||||
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
|
||||
puts("PASS: SSH typed ID dispatcher routing, zero-wait/full/not-ready, no command runner or capacity growth");
|
||||
puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exact SSH management/lifecycle/close/slot-selection functions, deterministic RTOS.
|
||||
No wolfSSH, sockets, real scheduling or target execution is claimed.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
source = (ROOT / 'src/ssh_transport.c').read_text()
|
||||
def function(name):
|
||||
match = re.search(r'^(?:static )?[^\n]+\b' + name + r'\([^;]*?\n\{.*?^\}', source, re.M | re.S)
|
||||
assert match, name
|
||||
return match.group() + '\n'
|
||||
header = '\n'.join(line for line in (ROOT / 'src/ssh_transport.h').read_text().splitlines() if not line.startswith(('#include', '#pragma once')))
|
||||
constants = '\n'.join(re.search(r'^#define ' + name + r' .+$', source, re.M).group() for name in ('SSH_TRANSPORT_GENERATION_MAX', 'SSH_TRANSPORT_COMMAND_TIMEOUT_MS', 'SSH_TRANSPORT_MAX_PENDING_HANDSHAKES'))
|
||||
username = re.search(r'^#define USER_DATABASE_USERNAME_CAPACITY .+$', (ROOT / 'src/user_database.h').read_text(), re.M).group()
|
||||
fakes = r'''
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
typedef int esp_err_t;
|
||||
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE, ESP_ERR_TIMEOUT, ESP_ERR_NOT_FOUND };
|
||||
typedef uint32_t session_broker_client_id_t;
|
||||
typedef int user_role_t;
|
||||
typedef int user_auth_method_t;
|
||||
'''
|
||||
state = r'''
|
||||
typedef struct { ssh_transport_session_state_t state; uint32_t generation, session_id; } ssh_slot_t;
|
||||
static ssh_slot_t s_slots[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
static ssh_transport_session_snapshot_t s_session_snapshots[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
static uint32_t s_external_close_id[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
static unsigned depth, mutex_storage, notifications, ticks;
|
||||
static unsigned *s_command_mutex = &mutex_storage;
|
||||
static bool s_initialized, s_running, s_transitioning, s_cleanup_pending, s_desired_running;
|
||||
static uint32_t s_management_generation, s_requested_sequence, s_completed_sequence;
|
||||
static int s_command_result;
|
||||
static bool owner_stalled, owner_fail;
|
||||
static int s_lock;
|
||||
#define taskENTER_CRITICAL(p) do { (void)(p); assert(depth++ == 0); } while(0)
|
||||
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(--depth == 0); } while(0)
|
||||
#define pdTRUE 1
|
||||
#define portMAX_DELAY 99999U
|
||||
#define pdMS_TO_TICKS(n) (n)
|
||||
typedef unsigned TickType_t;
|
||||
static int xSemaphoreTake(unsigned *m, unsigned wait) { assert(!depth); (void)wait; if (*m) return 0; *m = 1; return 1; }
|
||||
static void xSemaphoreGive(unsigned *m) { assert(!depth && *m); *m = 0; }
|
||||
static void notify_task(void) { assert(!depth); ++notifications; }
|
||||
static unsigned xTaskGetTickCount(void) { return ticks; }
|
||||
static void vTaskDelay(unsigned n) {
|
||||
assert(!depth && mutex_storage); ticks += n;
|
||||
if (!owner_stalled) {
|
||||
s_completed_sequence = s_requested_sequence; s_command_result = owner_fail ? ESP_FAIL : ESP_OK;
|
||||
s_running = owner_fail ? false : s_desired_running; s_transitioning = false; s_cleanup_pending = owner_fail;
|
||||
}
|
||||
}
|
||||
'''
|
||||
tests = r'''
|
||||
static void reset(void) {
|
||||
memset(s_slots, 0, sizeof(s_slots)); memset(s_session_snapshots, 0, sizeof(s_session_snapshots));
|
||||
memset(s_external_close_id, 0, sizeof(s_external_close_id));
|
||||
s_initialized = s_running = true; s_transitioning = s_cleanup_pending = owner_stalled = owner_fail = false;
|
||||
mutex_storage = notifications = ticks = 0; s_management_generation = 7; s_requested_sequence = s_completed_sequence = 0;
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
s_slots[i] = (ssh_slot_t){SSH_TRANSPORT_SESSION_ACTIVE, 2, make_session_id(i, 2)};
|
||||
s_session_snapshots[i] = (ssh_transport_session_snapshot_t){.active=true, .session_id=s_slots[i].session_id, .generation=2, .state=SSH_TRANSPORT_SESSION_ACTIVE};
|
||||
}
|
||||
}
|
||||
int main(void) {
|
||||
reset(); ssh_transport_management_snapshot_t v;
|
||||
assert(ssh_transport_get_management_snapshot(NULL) == ESP_ERR_INVALID_ARG);
|
||||
assert(ssh_transport_get_management_snapshot(&v) == ESP_OK && v.generation == 7 && v.running && !v.transitioning);
|
||||
assert(!notifications && !depth && !mutex_storage);
|
||||
s_initialized = false; assert(ssh_transport_get_management_snapshot(&v) == ESP_ERR_INVALID_STATE); s_initialized = true;
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_OK);
|
||||
assert(s_external_close_id[0] == 9 && !s_external_close_id[1] && notifications == 1);
|
||||
assert(ssh_transport_get_management_snapshot(&v) == ESP_OK && v.sessions[0].close_requested && !v.sessions[1].close_requested);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_NOT_FOUND);
|
||||
assert(consume_external_close(&s_slots[0], 0) && !s_external_close_id[0] && s_session_snapshots[0].close_requested);
|
||||
assert(!consume_external_close(&s_slots[1], 1));
|
||||
puts("PASS SSH atomic published snapshot/target close, duplicate rejection and unrelated-slot isolation");
|
||||
reset();
|
||||
s_external_close_id[0] = 5; assert(!consume_external_close(&s_slots[0], 0));
|
||||
s_slots[0].state = SSH_TRANSPORT_SESSION_FREE; assert(!consume_external_close(&s_slots[0], 0) && !s_external_close_id[0]);
|
||||
s_session_snapshots[0].active = false;
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_NOT_FOUND);
|
||||
s_session_snapshots[0].active = true; s_session_snapshots[0].session_id = 13;
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_NOT_FOUND);
|
||||
assert(!s_external_close_id[0] && !notifications);
|
||||
size_t index; s_slots[0].generation = SSH_TRANSPORT_GENERATION_MAX; s_slots[1].state = SSH_TRANSPORT_SESSION_FREE;
|
||||
assert(find_free_slot(&index) == &s_slots[1] && index == 1);
|
||||
s_slots[1].generation = SSH_TRANSPORT_GENERATION_MAX; assert(find_free_slot(&index) == NULL);
|
||||
assert(make_session_id(1, SSH_TRANSPORT_GENERATION_MAX) != 0);
|
||||
puts("PASS SSH disconnect/reuse/late owner close safety and generation exhaustion retires slots");
|
||||
reset();
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 6) == ESP_ERR_INVALID_STATE);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 7) == ESP_ERR_INVALID_STATE);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 9, 7) == ESP_ERR_INVALID_ARG);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 0, 7) == ESP_ERR_INVALID_ARG);
|
||||
assert(ssh_transport_manage_current(99, 0, 7) == ESP_ERR_INVALID_ARG);
|
||||
mutex_storage = 1; assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 7) == ESP_ERR_TIMEOUT); mutex_storage = 0;
|
||||
s_transitioning = true; assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_INVALID_STATE); s_transitioning = false;
|
||||
assert(!notifications);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 7) == ESP_OK && !s_running && s_management_generation == 8);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 7) == ESP_ERR_INVALID_STATE);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 8) == ESP_OK && s_running && s_management_generation == 9);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_INVALID_STATE);
|
||||
assert(ssh_transport_stop() == ESP_OK && s_management_generation == 10);
|
||||
assert(ssh_transport_start() == ESP_OK && s_management_generation == 11);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 9) == ESP_ERR_INVALID_STATE);
|
||||
puts("PASS SSH conditional start/stop, command mutex, CLI transitions and stop/start ABA fencing");
|
||||
reset(); owner_stalled = true;
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 7) == ESP_ERR_TIMEOUT && s_transitioning && !mutex_storage && s_management_generation == 8);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 8) == ESP_ERR_INVALID_STATE);
|
||||
assert(ssh_transport_get_management_snapshot(&v) == ESP_OK && v.transitioning);
|
||||
reset(); owner_fail = true;
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 7) == ESP_FAIL && s_cleanup_pending);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 8) == ESP_ERR_INVALID_STATE);
|
||||
owner_fail = false; assert(ssh_transport_stop() == ESP_OK && !s_cleanup_pending);
|
||||
s_management_generation = UINT32_MAX - 1;
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, UINT32_MAX - 1) == ESP_OK && s_management_generation == UINT32_MAX);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, UINT32_MAX) == ESP_ERR_INVALID_ARG);
|
||||
assert(ssh_transport_stop() == ESP_OK && s_management_generation == UINT32_MAX);
|
||||
puts("PASS SSH admitted timeout is not cancellation; failed cleanup and saturated versions preserve CLI recovery");
|
||||
}
|
||||
'''
|
||||
names = ('next_generation', 'make_session_id', 'consume_external_close', 'find_free_slot', 'request_running_locked', 'request_running', 'ssh_transport_start', 'ssh_transport_stop', 'ssh_transport_get_management_snapshot', 'ssh_transport_manage_current')
|
||||
# Guard the accept path, which is not executed with the socket double here.
|
||||
assert 'uint32_t generation = slot->generation + 1U;' in function('accept_connections')
|
||||
assert 'next_generation(slot->generation)' not in source
|
||||
with tempfile.TemporaryDirectory(prefix='ssh-management-') as directory:
|
||||
tmp = Path(directory)
|
||||
unit = fakes + username + '\n' + header + '\n' + constants + '\n' + state + '\n'.join(function(n) for n in names) + tests
|
||||
(tmp / 'test.c').write_text(unit)
|
||||
subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror', str(tmp / 'test.c'), '-o', str(tmp / 'test')], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / 'test')], check=True, timeout=10)
|
||||
@@ -37,8 +37,8 @@ def define(path, name):
|
||||
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
|
||||
source, re.M | re.S)
|
||||
# Non-array declarations have no brackets; explicit shape avoids silent omission.
|
||||
if len(uri_tables) != 30:
|
||||
raise RuntimeError('Review URI extraction: expected 28 descriptors and two tables')
|
||||
if len(uri_tables) != 33:
|
||||
raise RuntimeError('Review URI extraction: expected 31 descriptors and two tables')
|
||||
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
|
||||
source.index('static esp_err_t ensure_mutex(void)')]
|
||||
header = (ROOT / 'src/web_server.h').read_text()
|
||||
@@ -154,6 +154,26 @@ static esp_err_t display_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
registered[registered_count++] = uri;
|
||||
return ESP_OK;
|
||||
}
|
||||
HANDLER(web_ssh_settings_handler) HANDLER(web_ssh_operation_handler)
|
||||
static unsigned ssh_calls, ssh_allocations, ssh_fail_at;
|
||||
static esp_err_t ssh_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
assert(s == SERVER && auth_live && ssl_live && !locked);
|
||||
assert(!uri->is_websocket && !uri->handle_ws_control_frames && !uri->user_ctx);
|
||||
++ssh_calls;
|
||||
if (ssh_calls == 1) {
|
||||
assert(!strcmp(uri->uri, "/api/settings/ssh") && uri->method == HTTP_GET);
|
||||
assert(uri->handler == web_ssh_settings_handler);
|
||||
} else {
|
||||
assert(!strcmp(uri->uri, "/api/settings/ssh-operation"));
|
||||
assert(uri->method == (ssh_calls == 2 ? HTTP_GET : HTTP_POST));
|
||||
assert(uri->handler == web_ssh_operation_handler && ssh_calls <= 3);
|
||||
}
|
||||
/* Model the adapter's staged descriptor/name allocations, before publication. */
|
||||
for (unsigned allocation = 0; allocation < 2; ++allocation)
|
||||
if (++ssh_allocations == ssh_fail_at) return ESP_ERR_NO_MEM;
|
||||
registered[registered_count++] = uri;
|
||||
return ESP_OK;
|
||||
}
|
||||
HANDLER(web_broker_settings_handler) HANDLER(web_broker_operation_handler)
|
||||
static unsigned broker_calls, broker_allocations, broker_fail_at;
|
||||
static esp_err_t broker_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
@@ -209,7 +229,7 @@ static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t
|
||||
static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_t *config) {
|
||||
assert(!locked && auth_live && !ssl_live); ++ssl_starts;
|
||||
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable);
|
||||
assert(config->httpd.max_uri_handlers == 33 && config->port_secure == 443);
|
||||
assert(config->httpd.max_uri_handlers == 36 && config->port_secure == 443);
|
||||
assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
|
||||
assert(config->tls_handshake_timeout_ms == 5000);
|
||||
assert(config->user_cb == tls_session_callback);
|
||||
@@ -247,6 +267,7 @@ static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
}
|
||||
static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
assert(uri->method == HTTP_GET);
|
||||
if (uri->handler == web_ssh_settings_handler || uri->handler == web_ssh_operation_handler) return ssh_register(s, uri);
|
||||
if (uri->handler == web_broker_settings_handler || uri->handler == web_broker_operation_handler) return broker_register(s, uri);
|
||||
if (uri->handler == web_display_settings_handler || uri->handler == web_display_operation_handler) return display_register(s, uri);
|
||||
if (uri->handler == web_network_snapshot_handler || uri->handler == web_network_operation_handler)
|
||||
@@ -256,6 +277,7 @@ static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_u
|
||||
return httpd_register_uri_handler(s, uri);
|
||||
}
|
||||
static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
if (uri->handler == web_ssh_settings_handler || uri->handler == web_ssh_operation_handler) return ssh_register(s, uri);
|
||||
if (uri->handler == web_broker_settings_handler || uri->handler == web_broker_operation_handler) return broker_register(s, uri);
|
||||
if (uri->handler == web_display_operation_handler) return display_register(s, uri);
|
||||
if (uri->handler == web_network_operation_handler) return network_register(s, uri);
|
||||
@@ -284,7 +306,7 @@ static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t
|
||||
static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri, int method) {
|
||||
assert(!locked && s == SERVER && ssl_live && auth_live && serial_live);
|
||||
assert((registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST) ||
|
||||
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation") || !strcmp(uri, "/api/settings/network-operation") || !strcmp(uri, "/api/settings/display-operation") || !strcmp(uri, "/api/settings/broker-operation")) && method == HTTP_GET));
|
||||
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation") || !strcmp(uri, "/api/settings/network-operation") || !strcmp(uri, "/api/settings/display-operation") || !strcmp(uri, "/api/settings/broker-operation") || !strcmp(uri, "/api/settings/ssh-operation")) && method == HTTP_GET));
|
||||
++unregister_calls;
|
||||
for (unsigned i = 0; i < registered_count; ++i) {
|
||||
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
|
||||
@@ -355,6 +377,7 @@ static void reset(void) {
|
||||
network_calls = network_allocations = network_fail_at = 0;
|
||||
display_calls = display_allocations = display_fail_at = 0;
|
||||
broker_calls = broker_allocations = broker_fail_at = 0;
|
||||
ssh_calls = ssh_allocations = ssh_fail_at = 0;
|
||||
account_calls = account_fail_at = generation_calls = keys_calls = 0;
|
||||
generation_fail = keys_fail = false;
|
||||
}
|
||||
@@ -363,6 +386,7 @@ static void fresh_registration(void) {
|
||||
network_calls = network_allocations = 0;
|
||||
display_calls = display_allocations = 0;
|
||||
broker_calls = broker_allocations = 0;
|
||||
ssh_calls = ssh_allocations = 0;
|
||||
}
|
||||
static void start(void) {
|
||||
assert(web_server_start() == ESP_OK);
|
||||
@@ -392,6 +416,14 @@ static void display_complete(void) {
|
||||
assert(r && r->handler == web_display_operation_handler);
|
||||
}
|
||||
}
|
||||
static void ssh_complete(void) {
|
||||
assert(ssh_calls == 3 && ssh_allocations == 6);
|
||||
assert(route("/api/settings/ssh")->handler == web_ssh_settings_handler);
|
||||
for (int method = HTTP_GET; method <= HTTP_POST; ++method) {
|
||||
const httpd_uri_t *r = method_route("/api/settings/ssh-operation", method);
|
||||
assert(r && r->handler == web_ssh_operation_handler);
|
||||
}
|
||||
}
|
||||
static void broker_complete(void) {
|
||||
assert(broker_calls == 3 && broker_allocations == 6);
|
||||
assert(route("/api/settings/broker")->handler == web_broker_settings_handler);
|
||||
@@ -442,7 +474,7 @@ int main(void) {
|
||||
}
|
||||
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
|
||||
|
||||
reset(); start(); assert(registered_count == 33 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
|
||||
reset(); start(); assert(registered_count == 36 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
|
||||
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
@@ -496,7 +528,7 @@ int main(void) {
|
||||
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
|
||||
assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops);
|
||||
assert(!s_transitioning && s_last_error == ESP_OK && s_counters.starts == 1 && !s_counters.start_failures);
|
||||
assert(registered_count == 31 && unregister_calls == failure - 17);
|
||||
assert(registered_count == 34 && unregister_calls == failure - 17);
|
||||
for (unsigned i = 0; i < registered_count; ++i)
|
||||
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
|
||||
assert(route("/ws/serial")->handler == traced_websocket_handler);
|
||||
@@ -505,13 +537,13 @@ int main(void) {
|
||||
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
||||
assert(!admin_detaches && !admin_stoppeds);
|
||||
registration_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 33 && admin_attaches == 1 && s_counters.starts == 2);
|
||||
assert(registered_count == 36 && admin_attaches == 1 && s_counters.starts == 2);
|
||||
assert(web_server_stop() == ESP_OK && admin_stoppeds == 1);
|
||||
}
|
||||
puts("PASS optional positions 17..18 preserve M1, roll back ticket when needed and recover after stop/restart");
|
||||
|
||||
reset(); registration_fail_at = 18; unregister_fail = true;
|
||||
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 32);
|
||||
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 35);
|
||||
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
|
||||
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
|
||||
ticket = route("/api/admin/ws-ticket");
|
||||
@@ -523,7 +555,7 @@ int main(void) {
|
||||
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
||||
assert(!admin_detaches && !admin_stoppeds);
|
||||
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 33 && admin_attaches == 1 && web_server_stop() == ESP_OK);
|
||||
assert(registered_count == 36 && admin_attaches == 1 && web_server_stop() == ESP_OK);
|
||||
puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart");
|
||||
|
||||
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
|
||||
@@ -545,7 +577,7 @@ int main(void) {
|
||||
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
|
||||
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
|
||||
reset(); settings_fail = true; start();
|
||||
assert(settings_calls == 1 && registered_count == 32);
|
||||
assert(settings_calls == 1 && registered_count == 35);
|
||||
assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK);
|
||||
settings_fail = false; fresh_registration(); start();
|
||||
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
||||
@@ -553,7 +585,7 @@ int main(void) {
|
||||
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
|
||||
for (unsigned failure = 1; failure <= 2; ++failure) {
|
||||
reset(); operation_fail_at = failure; start();
|
||||
assert(registered_count == 31 && operation_calls == failure && unregister_calls == failure - 1);
|
||||
assert(registered_count == 34 && operation_calls == failure && unregister_calls == failure - 1);
|
||||
assert(auth_live && serial_live && admin_owned);
|
||||
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation"));
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
@@ -561,7 +593,7 @@ int main(void) {
|
||||
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports");
|
||||
for (unsigned failure = 1; failure <= 3; ++failure) {
|
||||
reset(); account_calls = 0; account_fail_at = failure; start();
|
||||
assert(account_calls == failure && registered_count == (failure == 1 ? 30 : 31));
|
||||
assert(account_calls == failure && registered_count == (failure == 1 ? 33 : 34));
|
||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(auth_live && serial_live && admin_owned);
|
||||
@@ -569,17 +601,17 @@ int main(void) {
|
||||
assert(strcmp(registered[i]->uri, "/api/settings/account-operation"));
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
account_fail_at = 0; account_calls = 0; fresh_registration(); start();
|
||||
assert(registered_count == 33 && account_calls == 3);
|
||||
assert(registered_count == 36 && account_calls == 3);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
|
||||
assert(registered_count == 32 && auth_live && serial_live && admin_owned);
|
||||
assert(registered_count == 35 && auth_live && serial_live && admin_owned);
|
||||
for (unsigned i = 0; i < registered_count; ++i)
|
||||
assert(strcmp(registered[i]->uri, "/api/settings/account-operation") || registered[i]->method == HTTP_GET);
|
||||
assert(web_server_stop() == ESP_OK); account_fail_at = 0;
|
||||
puts("PASS optional Accounts list/result/mutation allocation failures preserve transports and never expose mutation without reads (including failed unregister)");
|
||||
reset(); generation_fail = true; start();
|
||||
assert(generation_calls == 1 && registered_count == 32 && account_calls == 3);
|
||||
assert(generation_calls == 1 && registered_count == 35 && account_calls == 3);
|
||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
|
||||
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
|
||||
@@ -591,12 +623,12 @@ int main(void) {
|
||||
}
|
||||
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
||||
generation_fail = false; fresh_registration(); start();
|
||||
assert(generation_calls == 2 && registered_count == 33);
|
||||
assert(generation_calls == 2 && registered_count == 36);
|
||||
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
puts("PASS optional password generation allocation failure preserves account routes/auth/transports; restart recovers");
|
||||
reset(); keys_fail = true; start();
|
||||
assert(keys_calls == 1 && registered_count == 32 && account_calls == 3 && generation_calls == 1);
|
||||
assert(keys_calls == 1 && registered_count == 35 && account_calls == 3 && generation_calls == 1);
|
||||
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
|
||||
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
|
||||
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
@@ -611,7 +643,7 @@ int main(void) {
|
||||
}
|
||||
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
||||
keys_fail = false; fresh_registration(); start();
|
||||
assert(keys_calls == 2 && registered_count == 33);
|
||||
assert(keys_calls == 2 && registered_count == 36);
|
||||
assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
puts("PASS optional account keys allocation failure preserves account/generation/auth/transports; restart recovers");
|
||||
@@ -636,7 +668,7 @@ int main(void) {
|
||||
reset(); network_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(network_calls == failed_route && network_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 30 : 31));
|
||||
assert(registered_count == (failed_route == 1 ? 33 : 34));
|
||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||
assert(!method_route("/api/settings/network-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/network-operation", HTTP_POST));
|
||||
@@ -644,13 +676,13 @@ int main(void) {
|
||||
other_domains_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
network_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 33); network_complete();
|
||||
assert(registered_count == 36); network_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six Network descriptor/name allocation positions isolate failures and recover after restart");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); network_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 32 && unregister_calls == 1);
|
||||
assert(registered_count == 35 && unregister_calls == 1);
|
||||
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
|
||||
assert(method_route("/api/settings/network-operation", HTTP_GET)->handler == web_network_operation_handler);
|
||||
assert(!method_route("/api/settings/network-operation", HTTP_POST));
|
||||
@@ -660,7 +692,7 @@ int main(void) {
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; network_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 33); network_complete();
|
||||
assert(registered_count == 36); network_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS failed Network result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||
@@ -668,7 +700,7 @@ int main(void) {
|
||||
reset(); display_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(display_calls == failed_route && display_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 30 : 31));
|
||||
assert(registered_count == (failed_route == 1 ? 33 : 34));
|
||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||
assert(!method_route("/api/settings/display-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/display-operation", HTTP_POST));
|
||||
@@ -676,13 +708,13 @@ int main(void) {
|
||||
other_domains_complete(); network_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
display_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 33); display_complete();
|
||||
assert(registered_count == 36); display_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six Display descriptor/name allocation positions isolate failures and recover after restart");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); display_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 32 && unregister_calls == 1);
|
||||
assert(registered_count == 35 && unregister_calls == 1);
|
||||
assert(route("/api/settings/display")->handler == web_display_settings_handler);
|
||||
assert(method_route("/api/settings/display-operation", HTTP_GET)->handler == web_display_operation_handler);
|
||||
assert(!method_route("/api/settings/display-operation", HTTP_POST));
|
||||
@@ -692,7 +724,7 @@ int main(void) {
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; display_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 33); display_complete();
|
||||
assert(registered_count == 36); display_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS failed Display result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||
@@ -700,7 +732,7 @@ int main(void) {
|
||||
reset(); broker_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(broker_calls == failed_route && broker_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 30 : 31));
|
||||
assert(registered_count == (failed_route == 1 ? 33 : 34));
|
||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||
assert(!method_route("/api/settings/broker-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/broker-operation", HTTP_POST));
|
||||
@@ -708,13 +740,13 @@ int main(void) {
|
||||
other_domains_complete(); network_complete(); display_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
broker_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 33); broker_complete();
|
||||
assert(registered_count == 36); broker_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six Broker descriptor/name allocation positions isolate failures and recover after restart");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); broker_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 32 && unregister_calls == 1);
|
||||
assert(registered_count == 35 && unregister_calls == 1);
|
||||
assert(route("/api/settings/broker")->handler == web_broker_settings_handler);
|
||||
assert(method_route("/api/settings/broker-operation", HTTP_GET)->handler == web_broker_operation_handler);
|
||||
assert(!method_route("/api/settings/broker-operation", HTTP_POST));
|
||||
@@ -724,10 +756,42 @@ int main(void) {
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; broker_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 33); broker_complete();
|
||||
assert(registered_count == 36); broker_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS failed Broker result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||
for (unsigned failure = 1; failure <= 6; ++failure) {
|
||||
reset(); ssh_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(ssh_calls == failed_route && ssh_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 33 : 34));
|
||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||
assert(!method_route("/api/settings/ssh-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/ssh-operation", HTTP_POST));
|
||||
assert(!!method_route("/api/settings/ssh", HTTP_GET) == (failed_route != 1));
|
||||
other_domains_complete(); network_complete(); display_complete(); broker_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
ssh_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 36); ssh_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six SSH descriptor/name allocation positions isolate failures and recover after restart");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); ssh_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 35 && unregister_calls == 1);
|
||||
assert(route("/api/settings/ssh")->handler == web_ssh_settings_handler);
|
||||
assert(method_route("/api/settings/ssh-operation", HTTP_GET)->handler == web_ssh_operation_handler);
|
||||
assert(!method_route("/api/settings/ssh-operation", HTTP_POST));
|
||||
other_domains_complete(); network_complete(); display_complete(); broker_complete();
|
||||
ssl_stop_error = ESP_FAIL;
|
||||
assert(web_server_stop() == ESP_FAIL && s_server == SERVER);
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; ssh_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 36); ssh_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS failed SSH result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||
for (unsigned failure = 0; failure < 8; ++failure) {
|
||||
reset();
|
||||
if (failure == 0) settings_fail = true;
|
||||
@@ -735,10 +799,10 @@ int main(void) {
|
||||
else if (failure <= 5) account_fail_at = failure - 2;
|
||||
else if (failure == 6) generation_fail = true;
|
||||
else keys_fail = true;
|
||||
start(); network_complete(); display_complete(); broker_complete(); assert(web_server_stop() == ESP_OK);
|
||||
start(); network_complete(); display_complete(); broker_complete(); ssh_complete(); assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS every other settings route failure leaves the complete Network domain available");
|
||||
puts("25 lifecycle groups passed (16 required fatal positions, 19 optional routes, Network/Display/Broker allocation positions and failed unregister)");
|
||||
puts("27 lifecycle groups passed (16 required fatal positions, 22 optional routes, Network/Display/Broker/SSH allocation positions and failed unregister)");
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
|
||||
@@ -60,6 +60,7 @@ settings = "--settings" in sys.argv
|
||||
serial_settings = "--serial-settings" in sys.argv
|
||||
accounts = "--accounts" in sys.argv
|
||||
broker = "--broker" in sys.argv
|
||||
ssh_settings = "--ssh" in sys.argv
|
||||
display = "--display" in sys.argv
|
||||
if display:
|
||||
HEADERS["nvs_flash.h"] = '#pragma once\n#include "esp_err.h"\nesp_err_t nvs_flash_init(void);\n'
|
||||
@@ -248,7 +249,8 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
*(["-DHOST_ACCOUNTS"] if accounts else []),
|
||||
*(["-DHOST_NETWORK"] if network else []),
|
||||
*(["-DHOST_DISPLAY"] if display else []),
|
||||
*(["-DHOST_BROKER"] if broker else []),
|
||||
*(["-DHOST_BROKER"] if broker else []),
|
||||
*(["-DHOST_SSH_SETTINGS"] if ssh_settings else []),
|
||||
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
|
||||
"-o", str(tmp / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / "test")], check=True, timeout=20)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/* Production HTTP policy/store/settings; deterministic dispatcher/SSH owner doubles. */
|
||||
#include "../../src/web_ssh_settings.c"
|
||||
static bool on_dispatcher, queue_fail;
|
||||
static uint32_t queued_id;
|
||||
static unsigned mutations, snapshots;
|
||||
static esp_err_t owner_error;
|
||||
static ssh_transport_management_snapshot_t owner_snapshot;
|
||||
esp_err_t ssh_transport_get_management_snapshot(ssh_transport_management_snapshot_t *out) {
|
||||
assert(!host_lock_depth && !on_dispatcher); ++snapshots;
|
||||
*out = owner_snapshot; return owner_error;
|
||||
}
|
||||
esp_err_t ssh_transport_manage_current(ssh_transport_management_action_t action, uint32_t target, uint32_t generation) {
|
||||
assert(on_dispatcher && !host_lock_depth && generation == 7);
|
||||
assert(action <= SSH_TRANSPORT_MANAGE_DISCONNECT && target == (action == SSH_TRANSPORT_MANAGE_DISCONNECT ? 9U : 0U));
|
||||
++mutations; return owner_error;
|
||||
}
|
||||
esp_err_t admin_ssh_console_submit_ssh_settings(uint32_t id) {
|
||||
assert(id && !on_dispatcher && !host_lock_depth);
|
||||
if (queue_fail) return ESP_FAIL;
|
||||
queued_id = id; return ESP_OK;
|
||||
}
|
||||
static void operation_begin(const issued_t *identity, const char *body) {
|
||||
begin("/api/settings/ssh-operation", body ? HTTP_POST : HTTP_GET, body);
|
||||
same_origin(); if (body) add("Content-Type", "application/json");
|
||||
if (identity) {
|
||||
char cookie[100]; snprintf(cookie, sizeof(cookie), "__Host-sak-session=%s", identity->token);
|
||||
add("Cookie", cookie); if (body) add("X-CSRF-Token", identity->view.csrf);
|
||||
}
|
||||
}
|
||||
static void expect_ssh(const char *status, bool snapshot) {
|
||||
unsigned before = mutations;
|
||||
esp_err_t e = snapshot ? web_ssh_settings_handler(&req) : web_ssh_operation_handler(&req);
|
||||
assert(e == (send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
|
||||
if (strcmp(response_status, status)) fprintf(stderr, "expected %s got %s: %s\n", status, response_status, output);
|
||||
assert(!strcmp(response_status, status) && mutations == before);
|
||||
assert(strlen(output) < (snapshot ? 768 : 96)); zero(scratch, sizeof(scratch));
|
||||
}
|
||||
static void execute(void) { on_dispatcher = true; web_ssh_settings_execute(queued_id); on_dispatcher = false; }
|
||||
static const char *disconnect_body = "{\"action\":\"disconnect\",\"generation\":7,\"target\":9}";
|
||||
static void submit(const issued_t *who) {
|
||||
operation_begin(who, disconnect_body); expect_ssh("202 Accepted", false); assert(s_operation.state == PENDING);
|
||||
}
|
||||
static void ssh_settings_tests(void) {
|
||||
auth_reset(); issued_t admin = mint(&alice), user = mint(&bob), other = mint(&alice);
|
||||
receive_fragment = 64;
|
||||
operation_begin(NULL, disconnect_body); expect_ssh("401 Unauthorized", false);
|
||||
operation_begin(&user, disconnect_body); expect_ssh("403 Forbidden", false);
|
||||
operation_begin(&user, NULL); expect_ssh("403 Forbidden", false);
|
||||
for (unsigned mode = 0; mode < 8; ++mode) {
|
||||
operation_begin(&admin, disconnect_body);
|
||||
if (mode == 0) req.content_len = aux.remaining_len = 257;
|
||||
if (mode == 1) req.uri = "/api/settings/ssh-operation?x=1";
|
||||
if (mode == 2) req.method = HTTP_GET;
|
||||
if (mode == 3) add("X-CSRF-Token", "duplicate");
|
||||
if (mode == 4) add("Origin", "https://evil.example");
|
||||
if (mode == 5) add("Transfer-Encoding", "chunked");
|
||||
if (mode == 6) add("Content-Type", "text/plain");
|
||||
if (mode == 7) add("Sec-Fetch-Site", "cross-site");
|
||||
(void)web_ssh_operation_handler(&req);
|
||||
assert(response_status[0] == '4' && !s_next_id && !mutations);
|
||||
}
|
||||
puts("PASS SSH admin/cookie/Origin/CSRF and query/body/framing bounds");
|
||||
const char *invalid[] = {"{}", "[]", "{\"action\":\"disconnect\"}",
|
||||
"{\"action\":\"disconnect\",\"generation\":0,\"target\":9}",
|
||||
"{\"action\":\"disconnect\",\"generation\":4294967295,\"target\":9}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":0}",
|
||||
"{\"action\":\"start\",\"generation\":7,\"target\":9}",
|
||||
"{\"action\":\"stop\",\"generation\":7,\"target\":9}",
|
||||
"{\"action\":\"reboot\",\"generation\":7,\"target\":0}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":4294967296}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":09}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":9.0}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":9e0}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":-9}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"generation\":9}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":9,\"service\":\"web\"}"};
|
||||
for (unsigned i = 0; i < sizeof(invalid)/sizeof(*invalid); ++i) {
|
||||
operation_begin(&admin, invalid[i]); expect_ssh("400 Bad Request", false);
|
||||
}
|
||||
ssh_operation_t parsed = {0};
|
||||
for (size_t n = 0; n < strlen(disconnect_body); ++n) assert(!parse(disconnect_body, n, &parsed));
|
||||
assert(parse(disconnect_body, strlen(disconnect_body), &parsed));
|
||||
assert(!parse(disconnect_body, strlen(disconnect_body)+1, &parsed));
|
||||
const char *reordered = " { \"target\":4294967295, \"generation\":4294967294, \"action\":\"disconnect\" } ";
|
||||
assert(parse(reordered, strlen(reordered), &parsed));
|
||||
receive_fragment = 1; operation_begin(&admin, disconnect_body); expect_ssh("400 Bad Request", false); assert(body_offset == 4); receive_fragment = 64;
|
||||
char full[257]; memset(full, ' ', 256); memcpy(full, disconnect_body, strlen(disconnect_body)); full[256] = 0;
|
||||
queue_fail = true; operation_begin(&admin, full); expect_ssh("503 Service Unavailable", false); queue_fail = false;
|
||||
assert(body_offset == 256 && s_operation.state == IDLE);
|
||||
puts("PASS SSH strict typed actions/parser, integer/order/truncation and exact request/receive limits");
|
||||
owner_snapshot.generation = 7; owner_snapshot.running = true;
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
ssh_transport_session_snapshot_t *s = &owner_snapshot.sessions[i];
|
||||
s->active = s->principal_valid = true; s->session_id = i + 8; s->state = SSH_TRANSPORT_SESSION_ACTIVE;
|
||||
s->route = i ? SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE : SSH_TRANSPORT_ROUTE_BROKER;
|
||||
memset(s->username, '"', USER_DATABASE_USERNAME_CAPACITY);
|
||||
}
|
||||
for (unsigned mode = 0; mode < 4; ++mode) {
|
||||
unsigned before = snapshots;
|
||||
operation_begin(mode == 0 ? NULL : mode == 1 ? &user : &admin, NULL); req.uri = "/api/settings/ssh";
|
||||
owner_error = mode == 3 ? ESP_FAIL : ESP_OK;
|
||||
expect_ssh(mode == 0 ? "401 Unauthorized" : mode == 1 ? "403 Forbidden" : mode == 3 ? "503 Service Unavailable" : "200 OK", true);
|
||||
if (mode < 2) assert(snapshots == before);
|
||||
if (mode == 2) assert(strstr(output, "22222222222222222222222222222222") && !strstr(output, "socket") && !strstr(output, "principal"));
|
||||
}
|
||||
owner_error = ESP_OK;
|
||||
puts("PASS SSH bounded two-row safe snapshot and unavailable owner isolation");
|
||||
submit(&admin); uint32_t first = queued_id;
|
||||
operation_begin(&other, disconnect_body); expect_ssh("503 Service Unavailable", false);
|
||||
operation_begin(&other, NULL); expect_ssh("200 OK", false); assert(strstr(output, "idle"));
|
||||
execute(); assert(s_operation.state == OK && mutations == 1); zero(&s_operation.principal, sizeof(s_operation.principal));
|
||||
execute(); assert(mutations == 1);
|
||||
for (unsigned action = 0; action < 2; ++action) {
|
||||
char body[80]; snprintf(body, sizeof(body), "{\"action\":\"%s\",\"generation\":7,\"target\":0}", s_actions[action]);
|
||||
operation_begin(&admin, body); expect_ssh("202 Accepted", false); execute();
|
||||
operation_begin(&admin, NULL); expect_ssh("200 OK", false); assert(strstr(output, s_actions[action]));
|
||||
}
|
||||
esp_err_t failures[] = {ESP_ERR_INVALID_STATE, ESP_ERR_NOT_FOUND, ESP_FAIL};
|
||||
for (unsigned i = 0; i < 3; ++i) { owner_error = failures[i]; submit(&admin); execute(); assert(s_operation.state == (i < 2 ? CONFLICT : FAILED)); }
|
||||
owner_error = ESP_OK;
|
||||
puts("PASS SSH all three actions on dispatcher only, duplicate/stale IDs, conflicts and login-isolated results");
|
||||
submit(&admin); unsigned before = mutations;
|
||||
web_ssh_settings_execute(0); web_ssh_settings_execute(first); assert(mutations == before && s_operation.state == PENDING);
|
||||
now += 30000000; execute(); assert(s_operation.state == CANCELLED && mutations == before);
|
||||
submit(&admin); web_session_store_invalidate(admin.view.id); execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin); db_fail = true; execute(); db_fail = false; assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin); stale_user = alice.user_id; execute(); stale_user = 0; assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); now = admin.view.expires_at_us - 1; submit(&admin); now = admin.view.expires_at_us; execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin); web_cookie_auth_stop(); assert(web_cookie_auth_start() == ESP_OK); execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); operation_begin(&admin, NULL); expect_ssh("200 OK", false); assert(strstr(output, "idle"));
|
||||
assert(mutations == before);
|
||||
puts("PASS SSH queue deadline, expiry/revocation/currentness failure, HTTPS auth lifecycle fencing");
|
||||
send_fail = true; submit(&admin); send_fail = false; execute(); assert(s_operation.state == OK);
|
||||
operation_begin(&admin, NULL); expect_ssh("200 OK", false); assert(strstr(output, "ok"));
|
||||
s_next_id = UINT32_MAX; operation_begin(&admin, disconnect_body); expect_ssh("503 Service Unavailable", false);
|
||||
puts("PASS SSH lost acknowledgement retained result and nonwrapping operation IDs");
|
||||
}
|
||||
@@ -149,6 +149,9 @@ static void auth_reset(void) {
|
||||
#ifdef HOST_BROKER
|
||||
#include "broker_settings_test.c"
|
||||
#endif
|
||||
#ifdef HOST_SSH_SETTINGS
|
||||
#include "ssh_settings_test.c"
|
||||
#endif
|
||||
|
||||
int main(void) {
|
||||
assert(store_tests() == 0); auth_reset();
|
||||
@@ -314,6 +317,9 @@ int main(void) {
|
||||
#endif
|
||||
#ifdef HOST_BROKER
|
||||
broker_settings_tests();
|
||||
#endif
|
||||
#ifdef HOST_SSH_SETTINGS
|
||||
ssh_settings_tests();
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve
|
||||
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
|
||||
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
|
||||
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': [], '/api/settings/broker': [], '/api/settings/broker-operation': []};
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': [], '/api/settings/broker': [], '/api/settings/broker-operation': [], '/api/settings/ssh': [], '/api/settings/ssh-operation': []};
|
||||
const fits = [];
|
||||
let serial = 0, now = Date.now();
|
||||
class Clock extends Date { static now() { return now; } }
|
||||
@@ -1371,5 +1371,6 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
await require('./network.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
await require('./display.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
await require('./broker.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
await require('./ssh.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
|
||||
@@ -56,10 +56,10 @@ def check_layout(html):
|
||||
assert 'hidden' in ids['quick-header']['attrs']
|
||||
assert ids['network-password']['parent'] is ids['network-password-label']
|
||||
assert ids['network-password-mode']['parent'] is ids['network-password-mode-label']
|
||||
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values', 'broker-values'):
|
||||
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values', 'broker-values', 'ssh-values'):
|
||||
assert ids[ident]['tag'] == 'dl'
|
||||
assert 'settings-values' in classes(ids[ident])
|
||||
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-settings'):
|
||||
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-settings', 'ssh-settings'):
|
||||
nodes = list(descendants(ids[ident]))
|
||||
assert not any(n['tag'] == 'pre' for n in nodes)
|
||||
assert all('connection-detail' in classes(n) for n in nodes if n['tag'] == 'p')
|
||||
@@ -70,9 +70,9 @@ def check_layout(html):
|
||||
ancestor(n, 'settings-edit')
|
||||
except AssertionError:
|
||||
ancestor(n, 'serial-edit')
|
||||
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh', 'broker-refresh'):
|
||||
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh', 'broker-refresh', 'ssh-refresh'):
|
||||
assert ids[ident]['text'] == 'Refresh'
|
||||
for ident in ('serial-result', 'account-result', 'network-result', 'display-result', 'broker-result'):
|
||||
for ident in ('serial-result', 'account-result', 'network-result', 'display-result', 'broker-result', 'ssh-result'):
|
||||
assert ids[ident]['text'] == 'Check Operation Result'
|
||||
for ident in ('network-boot', 'network-enabled', 'account-password-saved'):
|
||||
assert 'settings-check' in classes(ids[ident]['parent'])
|
||||
@@ -101,7 +101,7 @@ def check_layout(html):
|
||||
):
|
||||
assert rule in css, rule
|
||||
assert '.settings-edit textarea{font:inherit;width:100%;min-width:0;' in css
|
||||
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all five settings views')
|
||||
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all six settings views')
|
||||
|
||||
|
||||
def check_browser_layout(html, tmp, executable):
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, html}) => {
|
||||
const path='/api/settings/ssh', op=path+'-operation';
|
||||
const row=(id=9, extra={})=>({id,state:2,route:1,name_hex:'3c696d673e',closing:false,...extra});
|
||||
const fixture=(extra={})=>({generation:7,running:true,transitioning:false,sessions:[row(),row(10,{route:2})],...extra});
|
||||
const reply=(state='pending',id=42,status=200,action='disconnect')=>new Response(JSON.stringify({id,action,state}),{status});
|
||||
const n=(b,id)=>b.nodes['ssh-'+id], posts=b=>b.calls.filter(c=>c.url===op&&c.method==='POST');
|
||||
async function open(v=fixture()) {const b=await adminBrowser();b.click('select-settings');await tick();b.queues[path].push(json(v));b.click('settings-ssh');await tick();return b;}
|
||||
function select(b,id=9){n(b,'target').value=String(id);n(b,'target').change();}
|
||||
async function refresh(b,v=fixture()){b.queues[path].push(json(v));b.click('ssh-refresh');await tick();}
|
||||
async function submit(b,action='disconnect'){if(action==='disconnect')select(b);b.window.confirm=()=>true;b.queues[op].push(reply('pending',42,202,action));b.click('ssh-'+action);await tick();}
|
||||
await test('SSH admin-only view, safe rows, no navigation/selection mutation and both terminal isolation',async()=>{
|
||||
const u=browser();u.start();await tick();u.click('settings-ssh');await tick();assert.equal(u.calls.filter(c=>c.url===path).length,0);
|
||||
const b=await open();assert.equal(b.nodes['ssh-settings'].hidden,false);
|
||||
assert.equal(n(b,'values').children[0].textContent,'9 / Serial / <img>');select(b);assert.equal(posts(b).length,0);
|
||||
for(let i=0;i<2;++i){b.sockets[i].emit('message',{data:Uint8Array.of(0,255,i).buffer});assert.deepEqual(b.terminals[i].writes.at(-1),[0,255,i]);b.terminals[i].input('blocked');assert.equal(b.sockets[i].sent.length,0);}
|
||||
assert.match(html,/Stop closes all SSH sessions/);assert.match(html,/HTTPS login, browser terminals, Wi-Fi, USB and UART0 are not stopped/);
|
||||
b.click('settings-broker');await tick();assert.equal(b.nodes['ssh-settings'].hidden,true);assert.equal(posts(b).length,0);
|
||||
});
|
||||
await test('SSH exact confirmed start stop targeted disconnect requests and manual bounded result flow',async()=>{
|
||||
for(const action of ['start','stop','disconnect']){
|
||||
const b=await open(fixture({running:action!=='start'}));if(action==='disconnect')select(b);
|
||||
let confirmation='';b.window.confirm=text=>{confirmation=text;return false;};b.click('ssh-'+action);await tick();assert.equal(posts(b).length,0);
|
||||
assert.match(confirmation,/Settings and host identity are unchanged/);assert.match(confirmation,action==='stop'?/ALL SSH sessions/:action==='start'?/port 22/:/only SSH session 9/);
|
||||
await submit(b,action);assert.deepEqual(JSON.parse(posts(b)[0].body),{action,generation:7,target:action==='disconnect'?9:0});assert.equal(posts(b)[0].headers['X-CSRF-Token'],'a'.repeat(64));
|
||||
b.click('ssh-'+action);await tick();assert.equal(posts(b).length,1);assert.match(n(b,'operation-detail').textContent,/Check Operation Result/);
|
||||
b.queues[op].push(reply('ok',42,200,action));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/execution time/);assert.ok(n(b,'start').disabled&&n(b,'stop').disabled&&n(b,'disconnect').disabled);
|
||||
await refresh(b);assert.equal(posts(b).length,1);assert.equal(n(b,'target').value,'');
|
||||
}
|
||||
});
|
||||
await test('SSH refresh never rebases explicit identity or resurrects stale and absent selections',async()=>{
|
||||
for(const v of [fixture({generation:8}),fixture({sessions:[row(13)]}),fixture({sessions:[row(9,{closing:true})]}),fixture({sessions:[row(9,{name_hex:'61'})]}),fixture({transitioning:true})]){
|
||||
const b=await open();select(b);await refresh(b,v);assert.ok(n(b,'disconnect').disabled);await refresh(b);assert.ok(n(b,'disconnect').disabled);assert.equal(n(b,'target').value,'');
|
||||
b.click('ssh-disconnect');await tick();assert.equal(posts(b).length,0);select(b);assert.equal(n(b,'disconnect').disabled,false);
|
||||
}
|
||||
const b=await open();select(b);await refresh(b,fixture({sessions:[row(10,{route:2}),row()]}));assert.equal(n(b,'target').value,'9');assert.equal(n(b,'disconnect').disabled,false);
|
||||
});
|
||||
await test('SSH invalid/unavailable/transition/exhausted snapshots fail closed without affecting terminals',async()=>{
|
||||
const invalid=[{},fixture({generation:0}),fixture({generation:4294967296}),fixture({running:1}),fixture({sessions:[row(),row()]}),fixture({sessions:[row(9,{name_hex:'zz'})]}),fixture({sessions:[row(9,{route:3})]}),fixture({sessions:[row(9,{id:0})]})];
|
||||
for(const v of invalid){const b=await open(v);assert.ok(n(b,'disconnect').disabled&&n(b,'stop').disabled&&n(b,'start').disabled);assert.match(n(b,'detail').textContent,/unavailable/);assert.equal(posts(b).length,0);}
|
||||
for(const extra of [{transitioning:true},{generation:4294967295}]){const b=await open(fixture(extra));assert.ok(n(b,'stop').disabled&&n(b,'start').disabled);}
|
||||
const b=await open();select(b);b.queues[path].push(failure(503));b.click('ssh-refresh');await tick();assert.ok(n(b,'disconnect').disabled);assert.equal(b.sockets.length,2);
|
||||
});
|
||||
await test('SSH single-flight whole-read deadline and navigation fence late snapshots without replay',async()=>{
|
||||
for(const stage of ['session','snapshot']){
|
||||
const b=await open(), d=deferred();select(b);b.queues[stage==='session'?'/api/session':path].push(d.promise);b.click('ssh-refresh');await tick();b.click('ssh-stop');b.click('ssh-refresh');await tick();assert.equal(posts(b).length,0);
|
||||
b.fire(15000);await tick();assert.match(n(b,'detail').textContent,/timed out/);await refresh(b);d.resolve(stage==='session'?session({role:'admin'}):json(fixture({generation:99})));await tick();select(b);await submit(b);assert.equal(JSON.parse(posts(b)[0].body).generation,7);
|
||||
}
|
||||
const b=await open(),d=deferred();b.queues[path].push(d.promise);b.click('ssh-refresh');await tick();b.click('settings-serial');await tick();d.resolve(json(fixture()));await tick();assert.equal(b.nodes['ssh-settings'].hidden,true);assert.equal(n(b,'values').children.length,0);assert.equal(posts(b).length,0);
|
||||
});
|
||||
await test('SSH captures confirmation before delayed auth and handles conflict failed cancelled without replay',async()=>{
|
||||
for(const state of ['conflict','failed','cancelled']){
|
||||
const b=await open(),d=deferred();select(b);b.window.confirm=()=>true;b.queues['/api/session'].push(d.promise);b.queues[op].push(reply('pending',42,202));b.click('ssh-disconnect');await tick();assert.equal(posts(b).length,0);d.resolve(session({role:'admin'}));await tick();assert.equal(JSON.parse(posts(b)[0].body).generation,7);
|
||||
b.queues[op].push(reply(state));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,state==='conflict'?/No action admitted/:state==='failed'?/may still finish/:/Rejected before execution/);assert.equal(posts(b).length,1);
|
||||
}
|
||||
});
|
||||
await test('SSH lost acknowledgement/result replacement and timeout retain uncertainty across navigation',async()=>{
|
||||
const b=await open();select(b);b.window.confirm=()=>true;b.queues[op].push(()=>{throw Error('lost');});b.click('ssh-disconnect');await tick();assert.ok(n(b,'disconnect').disabled);b.click('select-serial');b.queues[path].push(json(fixture()));b.click('select-settings');await tick();assert.equal(posts(b).length,1);
|
||||
b.queues[op].push(reply('ok',41));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/Acknowledgement was lost/);
|
||||
b.queues[op].push(reply('ok',43));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/Previous result replaced/);
|
||||
await refresh(b);const d=deferred();select(b);b.queues[op].push(d.promise);b.click('ssh-disconnect');await tick();b.fire(15000);await tick();assert.match(n(b,'operation-detail').textContent,/timed out/);assert.ok(n(b,'stop').disabled);d.resolve(reply('pending',44,202));await tick();assert.match(n(b,'operation-detail').textContent,/timed out/);assert.equal(posts(b).length,2);
|
||||
});
|
||||
await test('SSH revoked session and pagehide cancel UI work without affecting newer context via late401',async()=>{
|
||||
const b=await open();select(b);b.queues['/api/session'].push(failure(401));b.click('ssh-disconnect');await tick();assert.equal(posts(b).length,0);
|
||||
const c=await open(),d=deferred();c.queues[path].push(d.promise);c.click('ssh-refresh');await tick();c.emit('pagehide');d.resolve(failure(401));await tick();assert.equal(posts(c).length,0);assert.equal(n(c,'values').children.length,0);
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user