Extend browser admin lifecycle actions

Support browser reboot and HTTPS stop through deferred control, plus
exact
`web certificate rotate --force` handoff to the dispatcher. Add typed
request
validation and focused boundary and lifecycle coverage.
This commit is contained in:
2026-09-07 09:36:38 +02:00
parent 17520b15b7
commit 326119812f
23 changed files with 699 additions and 47 deletions
+138
View File
@@ -0,0 +1,138 @@
/* Typed deferred work exercises the production dispatcher/control state machine. */
static admin_ssh_console_token_t token={.session_id=7, .slot_generation=1,
.transport=ADMIN_CONSOLE_TRANSPORT_WEB};
static user_principal_t principal={.role=USER_ROLE_ADMIN, .auth_generation=1};
static bool live=true, close_in_action;
static unsigned validations, invalidate_at;
static esp_err_t action_result;
static bool current(const admin_ssh_console_token_t *t, const user_principal_t *p) {
assert(!lock_depth && t->session_id==7 && p->auth_generation==1);
if (++validations==invalidate_at) live=false;
return live;
}
static bool drained(const admin_ssh_console_token_t *t) {
assert(!lock_depth && current_task==s_control_task && t->session_id==7);
return owner_drained;
}
static esp_err_t perform(const admin_ssh_console_token_t *t,
admin_ssh_deferred_action_type_t action, uint32_t arg);
static const admin_console_owner_t owner={
.supported_actions=1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
.dispatcher_actions=1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
.is_current=current, .drained=drained, .perform=perform,
};
static esp_err_t perform(const admin_ssh_console_token_t *t,
admin_ssh_deferred_action_type_t action, uint32_t arg) {
assert(!lock_depth && current_task==s_task && current_task!=s_control_task);
assert(action==ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE && arg==0);
assert(s_sessions[0].executing && s_sessions[0].deferred_action_pending);
assert(!admin_ssh_console_accepts_input(t));
size_t consumed=99;
assert(!admin_ssh_console_feed_input(t,(const uint8_t *)"ignored",7,&consumed) && !consumed);
++actions;
if (close_in_action) {
admin_ssh_console_close(t);
admin_ssh_console_token_t replacement=*t; ++replacement.slot_generation;
assert(admin_ssh_console_open_owned(&replacement,&principal,&owner)==ESP_ERR_INVALID_STATE);
}
return action_result;
}
static void pump(void (*task)(void *)) {
current_task=task==control_task ? s_control_task : s_task;
if (!setjmp(loop_done)) task(NULL);
}
static void clear_output(void) {
uint8_t data[4096]; size_t n;
assert(admin_ssh_console_read_output(&token,data,sizeof(data),&n)==ESP_OK);
}
static void reopen_certificate_session(void) {
admin_ssh_console_close(&token); ++token.slot_generation;
live=principal_current=owner_drained=true; validations=invalidate_at=0;
close_in_action=false; action_result=ESP_OK; ticks=0;
assert(admin_ssh_console_open_owned(&token,&principal,&owner)==ESP_OK);
clear_output();
}
static esp_err_t schedule(void) {
current_task=s_task; s_dispatch_remote=true; s_dispatch_token=token;
s_dispatch_principal=principal;
s_sessions[0].executing=s_sessions[0].command_pending=true;
esp_err_t result=admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,0);
s_sessions[0].executing=s_sessions[0].command_pending=false;
s_dispatch_remote=false;
return result;
}
static void revoke_delay(void) { if (ticks>=200) live=false; }
static void reuse_delay(void) {
if (ticks>=200) { delay_hook=NULL; reopen_certificate_session(); }
}
static void assert_pending(void) {
admin_ssh_console_session_snapshot_t snapshot;
assert(admin_ssh_console_get_session_snapshot(&token,&snapshot)==ESP_OK);
assert(snapshot.deferred_action_pending && !admin_ssh_console_accepts_input(&token));
}
static void uart_observes_pending(void) {
assert(current_task==s_task && actions==0); assert_pending();
}
int main(void) {
assert(admin_ssh_console_init()==ESP_OK);
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
s_task=(void *)1; s_control_task=(void *)2;
/* Union overlay preserves the old queue item allocation on this ABI. */
struct old_request { 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]; };
assert(sizeof(admin_request_t)==sizeof(struct old_request));
assert(s_request_queue->capacity==4 && s_control_queue->capacity==2);
reopen_certificate_session(); queue_full=true;
assert(schedule()==ESP_ERR_TIMEOUT && !s_sessions[0].deferred_action_pending && !actions);
queue_full=false;
assert(schedule()==ESP_OK); assert_pending();
/* Drain waits for acknowledgement, then times out without enqueue/mutation. */
s_sessions[0].output_length=1; pump(control_task);
assert(ticks==10000 && !actions && !s_request_queue->count && !s_sessions[0].deferred_action_pending);
clear_output(); ticks=0;
assert(schedule()==ESP_OK);
admin_request_t uart={.origin=ADMIN_REQUEST_UART0, .line="memory"};
for (unsigned i=0;i<4;++i) assert(xQueueSend(s_request_queue,&uart,0));
pump(control_task);
assert(!actions && !s_sessions[0].deferred_action_pending && s_request_queue->count==4);
assert(s_sessions[0].output_length); pump(worker_task); clear_output();
puts("PASS: unchanged queue item/depths, admission and handoff queue failure before mutation, ack drain cancellation");
assert(schedule()==ESP_OK);
assert(xQueueSend(s_request_queue,&uart,0));
pump(control_task); assert_pending(); assert(!actions && s_request_queue->count==2);
command_hook=uart_observes_pending; pump(worker_task); command_hook=NULL;
assert(actions==1 && !s_sessions[0].deferred_action_pending && !s_sessions[0].executing);
assert(runs==5); /* Typed work never calls esp_console_run. */
puts("PASS: control only hands off, queued UART first, crypto callback exclusively serialized on dispatcher, input gated through callback");
for (unsigned cancellation=0;cancellation<7;++cancellation) {
reopen_certificate_session(); assert(schedule()==ESP_OK);
if (cancellation==0) delay_hook=revoke_delay;
if (cancellation==1) delay_hook=reuse_delay;
pump(control_task); delay_hook=NULL;
if (cancellation==2) live=false;
if (cancellation==3) principal_current=false;
if (cancellation==4) reopen_certificate_session();
if (cancellation==5) admin_ssh_console_close(&token);
if (cancellation==6) invalidate_at=2; /* Last check after executing reservation. */
pump(worker_task);
assert(actions==1 && !s_sessions[0].executing);
if (cancellation==1 || cancellation==4) {
assert(s_sessions[0].active && !s_sessions[0].deferred_action_pending && !s_sessions[0].output_length);
}
}
puts("PASS: delay/queued revoke, account revoke, close/reuse, final execution check; no output into replacements");
reopen_certificate_session(); action_result=ESP_ERR_NO_MEM; assert(schedule()==ESP_OK);
pump(control_task); pump(worker_task);
assert(actions==2 && !s_sessions[0].deferred_action_pending && admin_ssh_console_accepts_input(&token));
uint8_t out[512]={0}; size_t n;
assert(admin_ssh_console_read_output(&token,out,sizeof(out)-1,&n)==ESP_OK);
assert(strstr((char *)out,"Deferred remote action failed: fake"));
reopen_certificate_session(); close_in_action=true; assert(schedule()==ESP_OK);
pump(control_task); pump(worker_task);
admin_session_t empty={0}; assert(!memcmp(&empty,&s_sessions[0],sizeof(empty)) && actions==3);
puts("PASS: action error reaches deferred result, input resumes on failure, self-detach reserves slot until return and wipes state");
}
+6 -4
View File
@@ -20,7 +20,7 @@ typedef struct {
typedef unsigned TickType_t;
typedef void *TaskHandle_t;
typedef int portMUX_TYPE;
typedef struct { size_t size; unsigned count; unsigned char bytes[2048]; } StaticQueue_t;
typedef struct { size_t size; unsigned count, capacity; unsigned char bytes[2048]; } StaticQueue_t;
typedef StaticQueue_t *QueueHandle_t;
typedef int StaticSemaphore_t;
typedef int *SemaphoreHandle_t;
@@ -56,11 +56,13 @@ static void vTaskDelete(TaskHandle_t t) { (void)t; }
static void xTaskNotifyGive(TaskHandle_t t) { (void)t; }
static unsigned ulTaskNotifyTake(int b, unsigned t) { (void)b; (void)t; return 1; }
static QueueHandle_t xQueueCreateStatic(unsigned n, size_t s, uint8_t *b, StaticQueue_t *q)
{ (void)n; (void)b; q->size = s; return q; }
{ (void)b; q->size = s; q->capacity = n; assert(n*s <= sizeof(q->bytes)); return q; }
static int xQueueSend(QueueHandle_t q, const void *p, unsigned t)
{ (void)t; if (queue_full) return 0; assert(!q->count); memcpy(q->bytes,p,q->size); q->count=1; return 1; }
{ (void)t; if (queue_full || q->count==q->capacity) return 0;
memcpy(q->bytes+q->count*q->size,p,q->size); ++q->count; return 1; }
static int xQueueReceive(QueueHandle_t q, void *p, unsigned t)
{ (void)t; if (!q->count) longjmp(loop_done,1); memcpy(p,q->bytes,q->size); q->count=0; return 1; }
{ (void)t; if (!q->count) longjmp(loop_done,1); memcpy(p,q->bytes,q->size);
--q->count; memmove(q->bytes,q->bytes+q->size,q->count*q->size); return 1; }
static SemaphoreHandle_t xSemaphoreCreateBinaryStatic(StaticSemaphore_t *s) { return s; }
static int xSemaphoreTake(SemaphoreHandle_t s, unsigned t)
{ assert(!lock_depth); if (t && !*s) { ticks+=t; if (prompt_hook) prompt_hook(); }
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Actual canonical stop/reboot handlers with deterministic side-effect doubles."""
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
def function(path, name):
source = path.read_text()
start = source.index('static int ' + name + '(')
return source[start:source.index('\n}', start) + 2]
header = '\n'.join(line for line in (ROOT / 'src/admin_ssh_console.h').read_text().splitlines()
if not line.startswith(('#include', '#pragma once')))
prelude = r'''
#include <assert.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
typedef int esp_err_t;
enum { ESP_OK=0, ESP_FAIL=-1, ESP_ERR_TIMEOUT=7 };
typedef struct { int unused; } user_principal_t;
'''
fakes = r'''
static bool remote, web;
static unsigned stops, reboots, scheduled, waits, rotations;
static esp_err_t schedule_result, stop_result;
static admin_ssh_deferred_action_type_t last_action;
bool admin_ssh_console_dispatch_is_remote(void) { return remote; }
bool admin_ssh_console_dispatch_is_web(void) { return remote && web; }
esp_err_t admin_ssh_console_dispatch_defer(admin_ssh_deferred_action_type_t action, uint32_t argument) {
assert(remote && !argument); ++scheduled; last_action=action; return schedule_result;
}
static const char *esp_err_to_name(esp_err_t error) { (void)error; return "fake"; }
static esp_err_t web_server_stop(void) { ++stops; return stop_result; }
static esp_err_t web_server_start(void) { assert(false); return ESP_FAIL; }
static esp_err_t web_server_clear_counters(void) { assert(false); return ESP_FAIL; }
static esp_err_t web_serial_transport_clear_counters(void) { assert(false); return ESP_FAIL; }
static void esp_restart(void) { ++reboots; }
static void vTaskDelay(unsigned delay) { assert(delay==100); ++waits; }
#define pdMS_TO_TICKS(ms) (ms)
static void print_usage(void) { assert(false); }
static int show_status(void) { assert(false); return 1; }
static int show_counters(void) { assert(false); return 1; }
static int show_credentials(void) { assert(false); return 1; }
static int show_certificate(void) { assert(false); return 1; }
static int rotate_credentials(void) { assert(false); return 1; }
static int rotate_certificate(void) { ++rotations; return 0; }
static int reset_material(void) { assert(false); return 1; }
static bool force_is_present(int argc, char **argv, int expected) {
return argc == expected && !strcmp(argv[expected - 1], "--force");
}
'''
tests = r'''
int main(void) {
char *stop[]={"web", "stop"};
remote=web=true;
assert(command_web(2,stop)==0 && scheduled==1 && !stops && last_action==ADMIN_CONSOLE_DEFER_WEB_STOP);
schedule_result=ESP_ERR_TIMEOUT;
assert(command_web(2,stop)==1 && scheduled==2 && !stops);
schedule_result=ESP_OK;
web=false; /* SSH preserves its synchronous HTTPS path. */
assert(command_web(2,stop)==0 && stops==1 && scheduled==2);
remote=false;
assert(command_web(2,stop)==0 && stops==2 && scheduled==2);
stop_result=ESP_FAIL;
assert(command_web(2,stop)==1 && stops==3);
remote=true;
assert(command_reboot(1,NULL)==0 && scheduled==3 && !reboots && last_action==ADMIN_SSH_DEFER_REBOOT);
web=true;
assert(command_reboot(1,NULL)==0 && scheduled==4 && !reboots && last_action==ADMIN_SSH_DEFER_REBOOT);
schedule_result=ESP_FAIL;
assert(command_reboot(1,NULL)==1 && scheduled==5 && !reboots);
assert(command_reboot(2,NULL)==1 && scheduled==5 && !reboots);
remote=false;
assert(command_reboot(1,NULL)==0 && reboots==1 && waits==1 && scheduled==5);
char *rotate[]={"web", "certificate", "rotate", "--force", "extra"};
remote=web=true; schedule_result=ESP_OK;
assert(command_web(4,rotate)==0 && scheduled==6 && !rotations &&
last_action==ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE);
schedule_result=ESP_ERR_TIMEOUT;
assert(command_web(4,rotate)==1 && scheduled==7 && !rotations);
assert(command_web(3,rotate)==1 && scheduled==7 && !rotations);
assert(command_web(5,rotate)==1 && scheduled==7 && !rotations);
web=false;
assert(command_web(4,rotate)==0 && rotations==1 && scheduled==7);
remote=false;
assert(command_web(4,rotate)==0 && rotations==2 && scheduled==7);
puts("PASS: canonical WEB stop/certificate deferred, exact force required, SSH/UART unchanged, reboot and queue failure isolation");
}
'''
with tempfile.TemporaryDirectory(prefix='console-lifecycle-') as directory:
tmp = Path(directory)
(tmp / 'test.c').write_text(prelude + header + fakes +
function(ROOT / 'src/web_console.c', 'command_web') +
function(ROOT / 'src/system_console.c', 'command_reboot') + tests)
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)
+8
View File
@@ -27,6 +27,14 @@ with tempfile.TemporaryDirectory(prefix="admin-console-boundary-") as directory:
"-g", str(path / "test.c"), parser,
"-o", str(path / "test")], check=True, timeout=30)
subprocess.run([str(path / "test")], check=True, timeout=10)
unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text()
+ strip_includes(header) + "\n" + strip_includes(source)
+ (ROOT / "tests/admin_console_boundary/certificate.c").read_text())
(path / "certificate.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-g", str(path / "certificate.c"), parser,
"-o", str(path / "certificate")], check=True, timeout=30)
subprocess.run([str(path / "certificate")], check=True, timeout=10)
ssh = (ROOT / "src/ssh_transport.c").read_text()
adapter = ssh[ssh.index("static admin_ssh_console_token_t admin_console_token("):
ssh.index("static void *ssh_malloc(")]
+10 -1
View File
@@ -149,7 +149,7 @@ static void test_shared_admission(void)
assert(s_sessions[0].owner==&owner && s_sessions[1].owner==&second_owner);
unsigned before=runs;
clear_output(&web);
feed(&web,"\"web\" \"stop\"\r"); pump(worker_task);
feed(&web,"\"web\" \"reset\" --force\r"); pump(worker_task);
assert(runs==before && !s_control_queue->count);
uint8_t diagnostic[512]={0}; size_t received=0;
assert(admin_ssh_console_read_output(&web,diagnostic,sizeof(diagnostic)-1,&received)==ESP_OK);
@@ -235,12 +235,21 @@ int main(void)
secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); prompt_hook=NULL;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
setup_dispatch(); clear_output(&a);
assert(!admin_ssh_console_dispatch_is_web());
s_dispatch_token.transport = ADMIN_CONSOLE_TRANSPORT_WEB;
assert(admin_ssh_console_dispatch_is_web());
s_dispatch_remote = false; assert(!admin_ssh_console_dispatch_is_web());
s_dispatch_remote = true; s_dispatch_token = a;
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_WEB_STOP,0)==ESP_ERR_NOT_SUPPORTED);
assert(admin_ssh_console_dispatch_defer((admin_ssh_deferred_action_type_t)32,0)==ESP_ERR_NOT_SUPPORTED);
assert(admin_ssh_console_dispatch_defer(ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_SUPPORTED);
assert(!s_sessions[0].deferred_action_pending);
queue_full=true;
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_ERR_TIMEOUT);
assert(!s_sessions[0].deferred_action_pending); queue_full=false;
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_OK);
admin_ssh_console_session_snapshot_t pending;
assert(admin_ssh_console_get_session_snapshot(&a, &pending)==ESP_OK && pending.deferred_action_pending);
assert(!admin_ssh_console_feed_input(&a,(const uint8_t *)"x",1,&n) && n==0);
s_sessions[0].command_pending=false; owner_drained=false; ticks=0;
pump(control_task); assert(ticks==10000 && actions==0);