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
+139
View File
@@ -0,0 +1,139 @@
/* Real cookie policy/store/tickets/transport; only console and runtime IO doubled. */
#include <stdlib.h>
#include <sys/socket.h>
#include "admin_ssh_console.h"
#include "web_admin_tickets.h"
#include "esp_timer.h"
#include "esp_heap_caps.h"
#include "freertos/task.h"
static bool console_active;
static const admin_console_owner_t *admin_owner;
static admin_ssh_console_token_t admin_token;
static void (*timer_poll)(void *), (*pending_poll)(void *);
static void *pending_argument;
static httpd_req_t connected;
static unsigned admin_closes;
void *heap_caps_calloc(size_t n, size_t size, unsigned caps) {
assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); return calloc(n, size);
}
void heap_caps_free(void *p) { free(p); }
esp_err_t esp_timer_create(const esp_timer_create_args_t *args, esp_timer_handle_t *timer) {
timer_poll = args->callback; *timer = &server; return ESP_OK;
}
esp_err_t esp_timer_start_periodic(esp_timer_handle_t timer, uint64_t us) {
assert(timer && us == 20000); return ESP_OK;
}
esp_err_t esp_timer_delete(esp_timer_handle_t timer) { (void)timer; return ESP_OK; }
void vTaskDelay(TickType_t ticks) { now += ticks * 1000; }
esp_err_t admin_ssh_console_open_available(admin_ssh_console_token_t *token,
const user_principal_t *principal, const admin_console_owner_t *owner) {
assert(principal->role == USER_ROLE_ADMIN && !console_active);
token->slot_index = 1; admin_token = *token; admin_owner = owner;
console_active = true; return ESP_OK;
}
void admin_ssh_console_close(const admin_ssh_console_token_t *token) {
if (!memcmp(token, &admin_token, sizeof(*token))) console_active = false;
}
bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
const uint8_t *data, size_t length, size_t *consumed) {
(void)token; (void)data; *consumed = length; return true;
}
esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *token,
uint8_t *data, size_t capacity, size_t *received) {
(void)token; (void)data; (void)capacity; *received = 0; return ESP_OK;
}
esp_err_t admin_ssh_console_get_session_snapshot(const admin_ssh_console_token_t *token,
admin_ssh_console_session_snapshot_t *snapshot) {
(void)token; *snapshot = (admin_ssh_console_session_snapshot_t){.active = console_active}; return ESP_OK;
}
int httpd_req_to_sockfd(httpd_req_t *request) { (void)request; return 12; }
void *httpd_sess_get_ctx(httpd_handle_t handle, int fd) {
assert(handle == &server && fd == 12); return connected.sess_ctx;
}
httpd_ws_client_info_t httpd_ws_get_fd_info(httpd_handle_t handle, int fd) {
(void)handle; (void)fd; return HTTPD_WS_CLIENT_WEBSOCKET;
}
int shutdown(int fd, int how) {
assert(fd == 12 && how == SHUT_RDWR); ++admin_closes; return 0;
}
esp_err_t httpd_queue_work(httpd_handle_t handle, void (*work)(void *), void *arg) {
assert(handle == &server && !pending_poll); pending_poll = work; pending_argument = arg; return ESP_OK;
}
esp_err_t httpd_ws_recv_frame(httpd_req_t *request, httpd_ws_frame_t *frame, size_t size) {
(void)request; (void)frame; (void)size; return ESP_FAIL;
}
esp_err_t httpd_ws_send_frame_async(httpd_handle_t handle, int fd, httpd_ws_frame_t *frame) {
(void)handle; (void)fd; (void)frame; return ESP_OK;
}
static void admin_poll(void) {
timer_poll(NULL); assert(pending_poll);
void (*work)(void *) = pending_poll; pending_poll = NULL; work(pending_argument);
}
static void admin_request(const issued_t *session, const char *uri, bool mutation,
bool with_origin, bool with_csrf) {
begin(uri, mutation ? HTTP_POST : HTTP_GET, NULL);
add("Host", "device.example");
if (with_origin) add("Origin", origin);
if (session) {
char cookies[100]; snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", session->token);
add("Cookie", cookies);
if (with_csrf) add("X-CSRF-Token", session->view.csrf);
}
if (!mutation) {
aux.ws_handshake_detect = true;
add("Sec-WebSocket-Version", "13");
add("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==");
}
}
static void admin_tests(void) {
auth_reset(); assert(web_admin_transport_init() == ESP_OK);
assert(web_admin_transport_attach(&server) == ESP_OK);
user_principal_t administrator = alice; administrator.role = USER_ROLE_ADMIN;
issued_t user = mint(&bob), admin = mint(&administrator), other = mint(&administrator);
unsigned before = upgrades;
for (unsigned mode = 0; mode < 5; ++mode) {
admin_request(mode == 0 ? NULL : mode == 1 ? &user : &admin,
WEB_ADMIN_TICKET_URI, true, mode != 2, mode != 3);
if (mode == 4) add("Origin", origin);
(void)web_admin_transport_ticket_handler(&req);
assert(strcmp(response_status, "200 OK") && upgrades == before);
web_admin_tickets_snapshot_t tickets; web_admin_tickets_get_snapshot(&tickets); assert(!tickets.active);
}
admin_request(&admin, WEB_ADMIN_TICKET_URI, true, true, true);
assert(web_admin_transport_ticket_handler(&req) == ESP_OK && !strcmp(response_status, "200 OK"));
char ticket[65], uri[128]; const char *at = strstr(output, "\"ticket\":\""); assert(at);
memcpy(ticket, at + 10, 64); ticket[64] = 0;
snprintf(uri, sizeof(uri), "%s?ticket=%s", WEB_ADMIN_WS_URI, ticket);
for (unsigned mode = 0; mode < 4; ++mode) {
admin_request(mode == 0 ? NULL : mode == 1 ? &user : &admin, uri, false, mode != 2, false);
if (mode == 3) add("Cookie", "ambiguous");
(void)web_admin_transport_upgrade_handler(&req);
assert(upgrades == before && !console_active);
}
admin_request(&other, uri, false, true, false);
assert(web_admin_transport_upgrade_handler(&req) != ESP_OK && upgrades == before);
admin_request(&admin, uri, false, true, false);
assert(web_admin_transport_upgrade_handler(&req) != ESP_OK && upgrades == before); /* burned */
puts("PASS: combined admin endpoints reject missing cookie/Origin/CSRF, duplicates, user role and cross-session ticket replay before 101");
for (unsigned mode = 0; mode < 3; ++mode) {
assert(web_admin_tickets_issue(admin.view.id, &administrator, ticket) == ESP_OK);
snprintf(uri, sizeof(uri), "%s?ticket=%s", WEB_ADMIN_WS_URI, ticket);
admin_request(&admin, uri, false, true, false);
assert(web_admin_transport_upgrade_handler(&req) == ESP_OK && upgrades == ++before);
connected = req; assert(console_active && admin_owner->is_current(&admin_token, &administrator));
if (mode == 0) {
admin_request(&admin, "/api/logout", true, true, true); expect("204 No Content");
assert(!console_active); present(&other);
} else if (mode == 1) stale_user = administrator.user_id;
else now = admin.view.expires_at_us;
admin_poll(); assert(!console_active && admin_closes == mode + 1);
connected.free_ctx(connected.sess_ctx); memset(&connected, 0, sizeof(connected));
stale_user = 0;
if (mode < 2) { web_session_store_invalidate(admin.view.id); admin = mint(&administrator); }
}
assert(web_admin_transport_detach(&server) == ESP_OK);
web_admin_transport_stopped(&server);
assert(web_admin_transport_attach(&server) == ESP_OK);
puts("PASS: real ticket-to-101 admission, isolated logout notification, missed account revocation, absolute expiry, cleanup and restart");
}
+21
View File
@@ -41,6 +41,24 @@ struct httpd_data { struct { unsigned max_resp_headers; } config; };
esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *, const char *);
"""
admin = "--admin" in sys.argv
if admin:
HEADERS["esp_heap_caps.h"] = """#pragma once
#include <stddef.h>
#define MALLOC_CAP_SPIRAM 1
#define MALLOC_CAP_8BIT 2
void *heap_caps_calloc(size_t, size_t, unsigned);
void heap_caps_free(void *);
"""
HEADERS["esp_timer.h"] += """
#include <stdbool.h>
typedef void *esp_timer_handle_t;
typedef struct { void (*callback)(void *); const char *name; bool skip_unhandled_events; } esp_timer_create_args_t;
int esp_timer_create(const esp_timer_create_args_t *, esp_timer_handle_t *);
int esp_timer_start_periodic(esp_timer_handle_t, uint64_t);
int esp_timer_delete(esp_timer_handle_t);
"""
def function(source, name):
start = source.index(name + "(")
start = source.rfind("\n", 0, start) + 1
@@ -79,7 +97,10 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
(tmp / "installed_httpd.c").write_text(extracted)
sources = [HERE / "test.c", tmp / "installed_httpd.c"]
sources += [ROOT / "src" / name for name in ["web_session_store.c", "web_auth_parse.c", "web_cookie_auth.c", "web_httpd_adapter.c"]]
if admin:
sources += [ROOT / "src" / name for name in ["web_admin_tickets.c", "web_admin_transport.c"]]
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g", "-DHOST_OPENSSL",
*(["-DHOST_ADMIN"] if admin 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)
+15 -1
View File
@@ -5,6 +5,9 @@
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
#include "esp_httpd_priv.h"
#ifdef HOST_ADMIN
#include "web_admin_transport.h"
#endif
static struct httpd_data server = {.config.max_resp_headers = 8};
static struct sock_db socket_state;
@@ -49,7 +52,11 @@ int httpd_req_recv(httpd_req_t *r, char *out, size_t size) {
}
esp_err_t web_login_ui_send_response(httpd_req_t *r) { return httpd_resp_sendstr(r, "login document"); }
esp_err_t web_serial_transport_revoke_web_session(web_session_id_t id) {
web_session_store_invalidate(id); return ESP_OK;
web_session_store_invalidate(id);
#ifdef HOST_ADMIN
web_admin_transport_revoke(id, NULL, 0);
#endif
return ESP_OK;
}
esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *r, const char *protocol) {
(void)r; (void)protocol; ++upgrades; return ESP_OK;
@@ -109,6 +116,10 @@ static void auth_reset(void) {
password_calls = 0; password_hook = NULL;
}
#ifdef HOST_ADMIN
#include "admin_test.c"
#endif
int main(void) {
assert(store_tests() == 0); auth_reset();
char token[65], csrf[65], session[65], cookies[200];
@@ -253,5 +264,8 @@ int main(void) {
server.config.max_resp_headers = 6; expect("200 OK"); assert(cookie_count == 2);
server.config.max_resp_headers = 8;
puts("PASS: exact six-header successful login budget; all smaller header capacities invalidate unpublished login");
#ifdef HOST_ADMIN
admin_tests();
#endif
return 0;
}