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
+11 -2
View File
@@ -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
+18 -2
View File
@@ -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; }
+84 -2
View File
@@ -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);