feat: add bounded admin WebSocket backend (Phase 8D.5)

- Require current admin cookie sessions, Origin checks and single-use
  tickets
- Reuse the shared console with session-aware authorization and slot
  allocation
- Add HTTPD-owned I/O, bounded buffering and revocation cleanup
- Prevent LRU eviction of serial clients and stale admin socket closure
- Reject unsupported web-shell mutations before side effects
- Add host regressions, a smoke client and resource accounting

Validated by user sign-off after a 15-minute full-client soak at 230400
baud, with a few broker drops under heavy output. Browser UI remains
for Phase 8D.6; numeric memory reserves remain open.
This commit is contained in:
2026-09-06 14:41:41 +02:00
parent e5dce12ed4
commit aeb2043396
37 changed files with 3651 additions and 91 deletions
+24
View File
@@ -7,6 +7,21 @@
static char query[48];
static unsigned broker_connections, broker_disconnects, writes, closes;
static unsigned admin_revocations;
static web_session_id_t expected_invalidated_id, last_admin_id;
static size_t last_admin_username_length;
void web_admin_transport_revoke(web_session_id_t id, const uint8_t *username, size_t length)
{
assert(!host_lock_depth);
++admin_revocations;
last_admin_id = id;
last_admin_username_length = username ? length : 0;
if (expected_invalidated_id) {
bool current = true;
assert(web_session_store_is_current(expected_invalidated_id, &current) == ESP_ERR_NOT_FOUND && !current);
expected_invalidated_id = 0;
}
}
static esp_err_t close_result = ESP_OK;
static httpd_req_t request = { .handle = (void *)1 };
static void (*connect_hook)(void);
@@ -96,7 +111,9 @@ int main(void) {
assert(web_serial_transport_mint_ticket(&bob, a.view.id, ta, sizeof(ta)) != ESP_OK);
web_serial_slot_t *sa = connect_session(&a), *sb = connect_session(&b);
ticket_for(&a, ta);
expected_invalidated_id = a.view.id;
assert(web_serial_transport_revoke_web_session(a.view.id) == ESP_OK);
assert(!expected_invalidated_id && last_admin_id == a.view.id && !last_admin_username_length);
assert(sa->close_requested && !sb->close_requested); absent(&a); present(&b); present(&c);
assert(consume_ticket(ta, a.view.id, &p, &consumed) == ESP_OK && !consumed);
assert(consume_ticket(tb, b.view.id, &p, &consumed) == ESP_OK && consumed);
@@ -107,7 +124,9 @@ int main(void) {
issued_t d = mint(&alice); sa = connect_session(&d);
assert(web_serial_transport_revoke_web_session(a.view.id) == ESP_OK && !sa->close_requested);
ticket_for(&b, tb); ticket_for(&d, ta);
expected_invalidated_id = b.view.id;
assert(web_serial_transport_revoke_user((const uint8_t *)"alice", 5) == ESP_OK);
assert(!expected_invalidated_id && !last_admin_id && last_admin_username_length == 5);
assert(sa->close_requested && sb->close_requested); absent(&b); absent(&d); present(&c);
assert(consume_ticket(ta, d.view.id, &p, &consumed) == ESP_OK && !consumed);
assert(consume_ticket(tb, b.view.id, &p, &consumed) == ESP_OK && !consumed);
@@ -144,10 +163,15 @@ int main(void) {
serial_reset(); web_session_store_stop();
assert(web_serial_transport_mint_ticket(&alice, 0, ta, sizeof(ta)) != ESP_OK);
serial_reset(); a = mint(&alice); b = mint(&bob);
expected_invalidated_id = a.view.id;
assert(web_serial_transport_revoke_sessions() == ESP_OK); absent(&a); absent(&b);
assert(!expected_invalidated_id && !last_admin_id && !last_admin_username_length);
assert(snapshot().initialized); assert(!host_lock_depth && closes > 0);
serial_reset(); a = mint(&alice); b = mint(&bob); s_initialized = false;
expected_invalidated_id = a.view.id;
unsigned notified = admin_revocations;
assert(web_serial_transport_revoke_user((const uint8_t *)"alice", 5) == ESP_ERR_INVALID_STATE);
assert(!expected_invalidated_id && admin_revocations == notified + 1);
absent(&a); present(&b);
serial_reset(); a = mint(&alice);
for (unsigned field = 0; field < 6; ++field) {