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:
@@ -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");
|
||||
}
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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)
|
||||
@@ -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(")]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -55,17 +55,24 @@ int main(void) {
|
||||
const char *web_allowed[] = {
|
||||
"", " ", "help", "memory", "exit", "user", "user status", "user list",
|
||||
"user show admin", "\"user\" \"show\" \"bootstrap\"",
|
||||
"web status", "wifi status", "mdns status", "\"web\" \"status\"",
|
||||
"web status", "web stop", "reboot", "\"reboot\"", "\"web\" \"stop\"",
|
||||
"wifi status", "mdns status", "\"web\" \"status\"",
|
||||
"web certificate rotate --force",
|
||||
" \"web\" \"certificate\" \"rotate\" \"--force\" ",
|
||||
"ssh status", "ssh sessions", "ssh counters", "ssh host-key info", "ssh start",
|
||||
};
|
||||
const char *web_denied[] = {
|
||||
"web", "web help", "web start", "web stop", "web counters", "web clear-counters",
|
||||
"web", "web help", "web start", "web stop extra", "web counters", "web clear-counters",
|
||||
"web credentials show", "web credentials rotate --force", "web certificate info",
|
||||
"web certificate rotate --force", "web reset --force", "web status extra",
|
||||
"web certificate rotate", "web certificate rotate --force extra",
|
||||
"web certificate rotate --force --force", "web certificate rotate --Force",
|
||||
"web certificate rotate --forcex", "web certificate --force rotate",
|
||||
"\"web\" \"certificate\" \"rotate\" \"--force extra\"",
|
||||
"web reset --force", "web status extra",
|
||||
"wifi", "wifi profiles", "wifi scan", "wifi start", "wifi stop", "wifi save",
|
||||
"wifi load", "wifi defaults", "wifi reset", "wifi ping example.org",
|
||||
"mdns", "mdns suffix test", "mdns save", "mdns load", "mdns defaults", "mdns reset",
|
||||
"reboot", "reboot --force", "user bootstrap", "user recover --force",
|
||||
"reboot --force", "user bootstrap", "user recover --force",
|
||||
"user add other admin --generate", "user delete other --force",
|
||||
"user role other user --force", "user password admin --generate",
|
||||
"user password other", "user key add admin", "user key clear admin --force",
|
||||
@@ -73,7 +80,7 @@ int main(void) {
|
||||
"ssh stop", "ssh disconnect 7", "ssh host-key rotate --force", "ssh reset --force",
|
||||
" \"user\" \"password\" \"admin\" \"--generate\"",
|
||||
"\"web\" \"credentials\" \"show\"", "\"wifi\" \"stop\"",
|
||||
"\"mdns\" \"reset\"", "\"reboot\"", "\"ssh\" \"stop\"",
|
||||
"\"mdns\" \"reset\"", "\"reboot\" extra", "\"ssh\" \"stop\"",
|
||||
"\"ssh\" \"host-key\" \"rotate\" --force", "\"user\" \"recover\" --force",
|
||||
};
|
||||
for (size_t i=0; i<sizeof(web_allowed)/sizeof(web_allowed[0]); ++i) {
|
||||
|
||||
@@ -17,7 +17,15 @@ transport functions are not copied or reimplemented. Temporary output is removed
|
||||
production entry points and inspects private state for lifecycle/wipe assertions.
|
||||
No firmware build, network access or device operation is performed.
|
||||
|
||||
## Results recorded 2026-09-06
|
||||
## Latest reported results — 8D.7 second certificate slice
|
||||
|
||||
Implementer reports `run.py --tickets` PASS **25 transport / 12 ticket groups**, including certificate owner routing, currentness rejection and commit → stop → start short-circuit/error behavior. The owner's `dispatcher_actions` mask selects the existing 12 KiB dispatcher, not the 4 KiB control task. `tests/admin_console_boundary/run.py` (including `certificate.c`) separately covers typed deferred handoff/pending gate/executing reservation; `lifecycle.py` covers canonical handlers and unchanged SSH/UART0 behavior. Policy, server lifecycle **11**, cookie `--admin` and store `--serial` also pass as reported. Independent review has no actionable findings; sanitizer validation is unavailable due to missing libasan/libubsan. No hardware validation is claimed or performed by this documentation update.
|
||||
|
||||
Current WEB policy allows exact parsed `web status`, `web stop`, `web certificate rotate --force`, and `reboot`/self-close; other web forms, account mutations, network mutations and restricted SSH lifecycle/key mutations remain blocked. Certificate drain/acknowledgement bounds do not bound queued execution or prove browser receipt. See `docs/phase8d7_implementation.md` for final parent build/resources, trust/relogin/failure checklist and authorized next bounded slice; M2 acceptance remains pending.
|
||||
|
||||
## Earlier results recorded 2026-09-06
|
||||
|
||||
8D.7 first slice: `run.py --tickets` passes **23 transport / 12 ticket groups**. Adds WEB stop/reboot owner routing, stale/revoked action rejection and stop-error propagation, pending-input discard before poll and cancellation-during-receive with/without an occupied RX buffer. `python3 tests/admin_console_boundary/lifecycle.py` separately checks the production canonical handlers and unchanged SSH/UART0 behavior. Dependencies remain doubled; no target stop/reboot is executed. See `docs/phase8d7_implementation.md` for scope and pending validation.
|
||||
|
||||
Final continuation: `run.py --tickets` passes **19 transport / 12 ticket groups**,
|
||||
including the HTTPD-owned shutdown retry/reuse regression. `server_lifecycle.py`
|
||||
@@ -59,7 +67,8 @@ After the production empty-frame, input-deadline and timer-generation fixes:
|
||||
- At most one outstanding transport poll; byte-preserving input, partial input
|
||||
consumption/retry, consumed-input wiping, output delivery and TX wiping.
|
||||
- Nonfinal/text/oversized frames and another frame while RX is occupied fail
|
||||
closed; stalled input closes after the five-second deadline. Pending bytes are
|
||||
closed outside deferral; during observed deferral bounded input is discarded
|
||||
without cancelling the scheduled action. Stalled input closes after the five-second deadline. Pending bytes are
|
||||
not fed at or after the deadline even if the console can now consume them.
|
||||
- Session/account notification isolation, idle currentness failure, invalidation
|
||||
during currentness checking and between output consumption and send. Notifier
|
||||
|
||||
@@ -4,6 +4,19 @@ static int server_storage;
|
||||
#define SERVER ((void *)&server_storage)
|
||||
static bool httpd_owner, alloc_fail, timer_fail, auth_allowed, session_current;
|
||||
static bool console_live, console_full, queue_fail, send_fail, upgrade_fail;
|
||||
static bool deferred_pending, cancel_on_receive;
|
||||
static unsigned reboots, web_stops;
|
||||
static esp_err_t web_stop_result, rotate_result, web_start_result;
|
||||
static unsigned rotations, web_starts;
|
||||
static esp_err_t web_security_rotate_certificate(void) {
|
||||
OUTSIDE(); assert(!httpd_owner && !web_stops && !web_starts); ++rotations; return rotate_result;
|
||||
}
|
||||
static esp_err_t web_server_start(void) {
|
||||
OUTSIDE(); assert(!httpd_owner && rotations && web_stops == 1 && web_stop_result == ESP_OK);
|
||||
++web_starts; return web_start_result;
|
||||
}
|
||||
static void esp_restart(void) { OUTSIDE(); assert(!httpd_owner); ++reboots; }
|
||||
esp_err_t web_server_stop(void) { OUTSIDE(); assert(!httpd_owner); ++web_stops; return web_stop_result; }
|
||||
static bool ticket_live, upgrade_requested, revoke_on_open, revoke_on_send;
|
||||
static unsigned upgrades, closes, sends, queues, wipes, checks, receive_headers;
|
||||
static size_t feed_limit, fed_length, output_length;
|
||||
@@ -93,7 +106,8 @@ esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *t, uint
|
||||
}
|
||||
esp_err_t admin_ssh_console_get_session_snapshot(const admin_ssh_console_token_t *t,
|
||||
admin_ssh_console_session_snapshot_t *s) {
|
||||
OUTSIDE(); assert(t); *s = (admin_ssh_console_session_snapshot_t){.active = console_live}; return ESP_OK;
|
||||
OUTSIDE(); assert(t); *s = (admin_ssh_console_session_snapshot_t){.active = console_live,
|
||||
.deferred_action_pending = deferred_pending}; return ESP_OK;
|
||||
}
|
||||
static esp_err_t httpd_queue_work(httpd_handle_t h, void (*fn)(void *), void *arg) {
|
||||
OUTSIDE(); assert(h == SERVER); ++queues;
|
||||
@@ -122,7 +136,9 @@ static esp_err_t httpd_ws_recv_frame(httpd_req_t *r, httpd_ws_frame_t *f, size_t
|
||||
if (f->len == 0) { ++receive_headers; f->len = incoming.len; f->final = incoming.final; f->type = incoming.type; }
|
||||
if (!capacity || !f->len) return ESP_OK;
|
||||
if (f->len > capacity) return ESP_ERR_INVALID_ARG;
|
||||
memcpy(f->payload, incoming.payload, f->len); return ESP_OK;
|
||||
memcpy(f->payload, incoming.payload, f->len);
|
||||
if (cancel_on_receive) deferred_pending = false;
|
||||
return ESP_OK;
|
||||
}
|
||||
static esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *s) { io(); (void)r; snprintf(status, sizeof(status), "%s", s); return ESP_OK; }
|
||||
static esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *s) { io(); (void)r; assert(!strcmp(s, "application/json")); return ESP_OK; }
|
||||
|
||||
@@ -14,7 +14,9 @@ static void reset(void) {
|
||||
httpd_owner = true; alloc_fail = timer_fail = false;
|
||||
auth_allowed = session_current = upgrade_requested = true;
|
||||
console_live = console_full = queue_fail = send_fail = upgrade_fail = shutdown_fail = false;
|
||||
ticket_live = revoke_on_open = revoke_on_send = false;
|
||||
ticket_live = revoke_on_open = revoke_on_send = deferred_pending = false;
|
||||
reboots = web_stops = rotations = web_starts = 0;
|
||||
web_stop_result = rotate_result = web_start_result = ESP_OK; cancel_on_receive = false;
|
||||
upgrades = closes = sends = queues = wipes = checks = receive_headers = 0;
|
||||
feed_limit = SIZE_MAX; fed_length = output_length = sent_length = 0;
|
||||
memset(fed, 0, sizeof(fed)); memset(output, 0, sizeof(output)); memset(sent, 0, sizeof(sent));
|
||||
@@ -213,7 +215,7 @@ int main(void) {
|
||||
ok("submission fence timeout/retry and stopped retirement of unexecuted callback");
|
||||
|
||||
reset(); start(); admit();
|
||||
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_NOT_SUPPORTED);
|
||||
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_STOP, 0) == ESP_ERR_NOT_SUPPORTED);
|
||||
assert(console_live && !s_slot.close_requested);
|
||||
s_slot.sending = true; assert(!owner_drained(&s_slot.token)); s_slot.sending = false;
|
||||
assert(owner_drained(&s_slot.token)); httpd_owner = false;
|
||||
@@ -221,6 +223,86 @@ int main(void) {
|
||||
assert(!console_live && !closes); httpd_owner = true;
|
||||
ok("unsupported deferred action has no side effects; self-close notifier and drain guard");
|
||||
|
||||
reset(); start(); admit(); httpd_owner = false;
|
||||
assert(s_owner.supported_actions & (1U << ADMIN_SSH_DEFER_REBOOT));
|
||||
assert(s_owner.supported_actions & (1U << ADMIN_CONSOLE_DEFER_WEB_STOP));
|
||||
admin_ssh_console_token_t stale = s_slot.token; ++stale.slot_generation;
|
||||
assert(owner_perform(&stale, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_NOT_FOUND && !reboots);
|
||||
session_current = false;
|
||||
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_WEB_STOP, 0) == ESP_ERR_NOT_FOUND && !web_stops);
|
||||
session_current = true; check_hook = revoke_check;
|
||||
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_NOT_FOUND && !reboots);
|
||||
httpd_owner = true;
|
||||
ok("deferred lifecycle rechecks current session and generation after drain/delay; revoked work cannot act");
|
||||
|
||||
reset(); start(); admit(); httpd_owner = false;
|
||||
web_stop_result = ESP_ERR_TIMEOUT;
|
||||
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_WEB_STOP, 0) == ESP_ERR_TIMEOUT);
|
||||
assert(web_stops == 1 && !reboots && !closes);
|
||||
web_stop_result = ESP_OK;
|
||||
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_WEB_STOP, 0) == ESP_OK && web_stops == 2);
|
||||
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_OK && reboots == 1);
|
||||
httpd_owner = true;
|
||||
ok("HTTPS stop/reboot marshal to lifecycle APIs outside HTTPD/locks; stop failure propagates");
|
||||
|
||||
for (unsigned failure=0; failure<4; ++failure) {
|
||||
reset(); start(); admit(); httpd_owner=false;
|
||||
assert(s_owner.dispatcher_actions == (1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE));
|
||||
assert(s_owner.supported_actions & s_owner.dispatcher_actions);
|
||||
if (failure==1) rotate_result=ESP_FAIL;
|
||||
if (failure==2) web_stop_result=ESP_ERR_TIMEOUT;
|
||||
if (failure==3) web_start_result=ESP_ERR_NO_MEM;
|
||||
esp_err_t expected[]={ESP_OK, ESP_FAIL, ESP_ERR_TIMEOUT, ESP_ERR_NO_MEM};
|
||||
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE, 0)==expected[failure]);
|
||||
assert(rotations==1 && web_stops==(failure!=1) && web_starts==(failure==0 || failure==3));
|
||||
assert(!closes && !sends);
|
||||
}
|
||||
ok("certificate dispatcher mask, transactional API then stop/start, first-error propagation and no IO");
|
||||
|
||||
for (unsigned invalid=0; invalid<5; ++invalid) {
|
||||
reset(); start(); admit(); httpd_owner=false;
|
||||
admin_ssh_console_token_t token=s_slot.token;
|
||||
if (invalid==0) ++token.slot_generation;
|
||||
if (invalid==1) session_current=false;
|
||||
if (invalid==2) ++auth_view.principal.auth_generation;
|
||||
if (invalid==3) check_hook=revoke_check;
|
||||
if (invalid==4) ++s_slot.session;
|
||||
assert(owner_perform(&token, ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE, 0)==ESP_ERR_NOT_FOUND);
|
||||
assert(!rotations && !web_stops && !web_starts && !closes);
|
||||
}
|
||||
ok("certificate rejects stale token, revoked cookie/principal, revocation during check and replaced session");
|
||||
|
||||
reset(); start(); admit(); incoming.payload = bytes; incoming.len = sizeof(bytes);
|
||||
feed_limit = 0;
|
||||
assert(frame_handler(&request) == ESP_OK && s_payload->rx_length);
|
||||
deferred_pending = true;
|
||||
/* A second frame can beat the next poll after the command becomes deferred. */
|
||||
assert(frame_handler(&request) == ESP_OK && console_live && !s_slot.close_requested);
|
||||
tick(); work();
|
||||
assert(!fed_length && !s_payload->rx_length && zeroed(s_payload->rx, sizeof(s_payload->rx)));
|
||||
assert(console_live && !s_slot.close_requested);
|
||||
assert(frame_handler(&request) == ESP_OK && !s_payload->rx_length && !fed_length);
|
||||
deferred_pending = false;
|
||||
assert(frame_handler(&request) == ESP_OK && s_payload->rx_length); /* Held input. */
|
||||
deferred_pending = true; tick(); work(); /* Poll also discards independently. */
|
||||
assert(!s_payload->rx_length && zeroed(s_payload->rx, sizeof(s_payload->rx)));
|
||||
deferred_pending = false; feed_limit = SIZE_MAX; tick(); work();
|
||||
assert(!fed_length); /* Failed/cancelled deferred work must not replay held input. */
|
||||
assert(frame_handler(&request) == ESP_OK && fed_length == sizeof(bytes));
|
||||
ok("pending actions discard buffered/new input, preserve drain, and never replay it on cancellation");
|
||||
|
||||
for (unsigned buffered = 0; buffered < 2; ++buffered) {
|
||||
reset(); start(); admit(); incoming.payload = bytes; incoming.len = sizeof(bytes);
|
||||
if (buffered) {
|
||||
feed_limit = 0; assert(frame_handler(&request) == ESP_OK && s_payload->rx_length);
|
||||
}
|
||||
feed_limit = SIZE_MAX; deferred_pending = cancel_on_receive = true;
|
||||
assert(frame_handler(&request) == ESP_OK && !deferred_pending && !fed_length);
|
||||
assert(!s_payload->rx_length && zeroed(s_payload->rx, sizeof(s_payload->rx)));
|
||||
assert(console_live && !s_slot.close_requested);
|
||||
}
|
||||
ok("deferral observed before payload read stays discarded when cancellation races receive, with/without buffered tail");
|
||||
|
||||
reset(); start(); admit(); incoming.len = 0;
|
||||
assert(frame_handler(&request) == ESP_OK);
|
||||
assert(receive_headers == 1 && !fed_length && console_live && !s_slot.close_requested);
|
||||
|
||||
@@ -14,6 +14,11 @@ static void (*timer_poll)(void *), (*pending_poll)(void *);
|
||||
static void *pending_argument;
|
||||
static httpd_req_t connected;
|
||||
static unsigned admin_closes;
|
||||
/* These endpoint tests never dispatch lifecycle commands. */
|
||||
void esp_restart(void) { assert(false); }
|
||||
esp_err_t web_server_stop(void) { assert(false); return ESP_FAIL; }
|
||||
esp_err_t web_server_start(void) { assert(false); return ESP_FAIL; }
|
||||
esp_err_t web_security_rotate_certificate(void) { assert(false); return ESP_FAIL; }
|
||||
void *heap_caps_calloc(size_t n, size_t size, unsigned caps) {
|
||||
assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); return calloc(n, size);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *, const char *);
|
||||
|
||||
admin = "--admin" in sys.argv
|
||||
if admin:
|
||||
HEADERS["esp_system.h"] = "#pragma once\nvoid esp_restart(void);\n"
|
||||
HEADERS["esp_heap_caps.h"] = """#pragma once
|
||||
#include <stddef.h>
|
||||
#define MALLOC_CAP_SPIRAM 1
|
||||
|
||||
Reference in New Issue
Block a user