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
+84 -4
View File
@@ -1,29 +1,109 @@
#define SSH_TRANSPORT_MAX_SESSIONS 2U
typedef struct { bool active, tx_pending; uint32_t session_id, generation; } ssh_transport_session_snapshot_t;
enum { SSH_TRANSPORT_SESSION_FREE=0, SSH_TRANSPORT_SESSION_ACTIVE=2,
SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE=2 };
enum { USER_AUTH_METHOD_PASSWORD=0 };
typedef struct {
uint32_t session_id, generation, broker_client_id;
int state, route, socket_fd;
uint8_t console_slot_index;
bool authenticated, principal_valid, writer, close_requested;
size_t rx_length, rx_offset, tx_length, tx_offset;
user_principal_t principal;
char peer[48];
} ssh_slot_t;
typedef struct {
bool active, tx_pending, rx_pending, authenticated, principal_valid, close_requested;
bool writer, admin_command_pending;
uint32_t session_id, generation, broker_client_id, admin_output_pending;
int state, route, socket_fd, user_role, auth_method;
char username[USER_DATABASE_USERNAME_CAPACITY+1U], peer[48];
} ssh_transport_session_snapshot_t;
static ssh_transport_session_snapshot_t s_session_snapshots[2];
static user_principal_t s_console_principals[2];
static uint8_t s_console_slot_indices[2];
static uint32_t s_external_close_id[2];
static unsigned stopped, disconnected, rotated, reset, restarted;
static esp_err_t ssh_transport_stop(void) { ++stopped; return ESP_OK; }
static esp_err_t ssh_transport_disconnect(uint32_t id) { disconnected=id; return ESP_OK; }
static esp_err_t ssh_transport_replace_host_key(bool r) { if(r) ++reset; else ++rotated; return ESP_OK; }
static void esp_restart(void) { ++restarted; }
static void publish_slot(const ssh_slot_t *, size_t);
static bool admin_console_drained(const admin_ssh_console_token_t *);
static bool admin_console_is_current(const admin_ssh_console_token_t *, const user_principal_t *);
static bool consume_external_close(const ssh_slot_t *, size_t);
static esp_err_t admin_console_perform(const admin_ssh_console_token_t *, admin_ssh_deferred_action_type_t, uint32_t);
static void test_adapter(void)
{
admin_ssh_console_token_t token={ .slot_index=0, .session_id=7, .slot_generation=3 };
user_principal_t admin={ .role=USER_ROLE_ADMIN };
user_principal_t admin={ .role=USER_ROLE_ADMIN, .user_id=11, .auth_generation=2,
.username_length=5, .username="admin" };
assert(admin_ssh_console_init()==ESP_OK);
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
assert(admin_ssh_console_open(&token,&admin)==ESP_OK);
assert(!admin_console_drained(&token));
s_session_snapshots[0]=(ssh_transport_session_snapshot_t){ .active=true, .session_id=7, .generation=3 };
assert(!admin_console_drained(&token)); /* No published console binding. */
assert(!admin_console_is_current(&token,&admin));
ssh_slot_t active={ .session_id=7, .generation=3, .authenticated=true,
.principal_valid=true, .state=SSH_TRANSPORT_SESSION_ACTIVE,
.route=SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE, .principal=admin };
publish_slot(&active,0);
assert(admin_console_is_current(&token,&admin));
/* Production publication carries both console output and principal binding. */
assert(s_session_snapshots[0].tx_pending && s_session_snapshots[0].admin_output_pending);
assert(!strcmp(s_session_snapshots[0].username,"admin"));
uint8_t output[4096]; size_t n;
assert(admin_ssh_console_read_output(&token,output,sizeof(output),&n)==ESP_OK && n);
publish_slot(&active,0);
assert(admin_console_drained(&token));
active.state=SSH_TRANSPORT_SESSION_FREE; active.principal_valid=false;
publish_slot(&active,0);
user_principal_t empty={0};
assert(!memcmp(&s_console_principals[0],&empty,sizeof(empty)));
assert(!admin_console_is_current(&token,&admin));
active.state=SSH_TRANSPORT_SESSION_ACTIVE; active.principal_valid=true;
publish_slot(&active,0);
assert(admin_console_is_current(&token,&admin));
admin.username[0]='A'; assert(!admin_console_is_current(&token,&admin)); admin.username[0]='a';
active.route=0; publish_slot(&active,0); assert(!admin_console_is_current(&token,&admin));
active.route=SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE;
active.authenticated=false; publish_slot(&active,0); assert(!admin_console_is_current(&token,&admin));
active.authenticated=true; publish_slot(&active,0);
s_external_close_id[0]=7; assert(!admin_console_is_current(&token,&admin));
ssh_slot_t closing={.session_id=7, .state=SSH_TRANSPORT_SESSION_ACTIVE};
assert(consume_external_close(&closing,0) && !s_external_close_id[0]);
assert(!admin_console_is_current(&token,&admin));
s_session_snapshots[0].close_requested=true;
assert(!admin_console_is_current(&token,&admin)); s_session_snapshots[0].close_requested=false;
++admin.auth_generation; assert(!admin_console_is_current(&token,&admin)); --admin.auth_generation;
++admin.user_id; assert(!admin_console_is_current(&token,&admin)); --admin.user_id;
++admin.method; assert(!admin_console_is_current(&token,&admin)); --admin.method;
admin.username_length=1; assert(!admin_console_is_current(&token,&admin)); admin.username_length=5;
token.transport=1; assert(!admin_console_drained(&token));
assert(!admin_console_is_current(&token,&admin));
assert(admin_ssh_console_open(&token,&admin)==ESP_ERR_INVALID_ARG);
token.transport=0; token.slot_generation=4; assert(!admin_console_drained(&token));
assert(!admin_console_is_current(&token,&admin));
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_FOUND && stopped==0);
token.slot_generation=3; s_session_snapshots[0].tx_pending=true;
token.slot_generation=3;
/* Same physical session can be assigned the other console slot. */
admin_ssh_console_close(&token);
token.slot_index=1;
assert(admin_ssh_console_open(&token,&admin)==ESP_OK);
active.console_slot_index=1; publish_slot(&active,0);
assert(admin_console_is_current(&token,&admin));
assert(s_console_slot_indices[0]==1);
admin_ssh_console_token_t wrong=token; wrong.slot_index=0;
assert(!admin_console_is_current(&wrong,&admin) && !admin_console_drained(&wrong));
assert(admin_console_perform(&wrong,ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_FOUND);
/* A colliding published ID with a stale generation cannot steal the lookup. */
s_session_snapshots[1]=s_session_snapshots[0];
++s_session_snapshots[1].generation; s_console_slot_indices[1]=0;
assert(admin_console_is_current(&token,&admin));
assert(admin_ssh_console_read_output(&token,output,sizeof(output),&n)==ESP_OK && n);
publish_slot(&active,0);
s_session_snapshots[0].tx_pending=true;
assert(!admin_console_drained(&token)); s_session_snapshots[0].tx_pending=false;
assert(admin_console_perform(&token,ADMIN_CONSOLE_DEFER_SELF_CLOSE,99)==ESP_OK && disconnected==7);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_DISCONNECT,99)==ESP_OK && disconnected==99);
@@ -31,5 +111,5 @@ static void test_adapter(void)
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_HOST_KEY_ROTATE,0)==ESP_OK && rotated==1);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_HOST_KEY_RESET,0)==ESP_OK && reset==1);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_REBOOT,0)==ESP_OK && restarted==1);
puts("PASS: actual SSH adapter identity/drain checks, legacy admission and lifecycle action routing");
puts("PASS: actual SSH snapshot/principal publication and wiping, adapter identity/drain checks, legacy admission and lifecycle action routing");
}
+11 -5
View File
@@ -10,7 +10,13 @@ typedef int esp_err_t;
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE,
ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT, ESP_ERR_NOT_SUPPORTED, ESP_ERR_NOT_FOUND };
enum { USER_ROLE_USER, USER_ROLE_ADMIN };
typedef struct { int role; } user_principal_t;
#define USER_DATABASE_USERNAME_CAPACITY 16U
typedef struct {
uint32_t user_id, auth_generation;
int role, method;
size_t username_length;
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
} user_principal_t;
typedef unsigned TickType_t;
typedef void *TaskHandle_t;
typedef int portMUX_TYPE;
@@ -38,7 +44,7 @@ static size_t strlcpy(char *d, const char *s, size_t n) {
memcpy(d, s, k); d[k] = 0; } return len;
}
static esp_err_t user_database_principal_is_current(const user_principal_t *p, bool *c)
{ (void)p; *c = principal_current; return ESP_OK; }
{ (void)p; assert(!lock_depth); *c = principal_current; return ESP_OK; }
static const char *esp_err_to_name(int e) { (void)e; return "fake"; }
static TaskHandle_t xTaskGetCurrentTaskHandle(void) { return current_task; }
static unsigned xTaskGetTickCount(void) { return ticks; }
@@ -57,7 +63,8 @@ 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; }
static SemaphoreHandle_t xSemaphoreCreateBinaryStatic(StaticSemaphore_t *s) { return s; }
static int xSemaphoreTake(SemaphoreHandle_t s, unsigned t)
{ if (t && prompt_hook) prompt_hook(); int r=*s; *s=0; return r; }
{ assert(!lock_depth); if (t && !*s) { ticks+=t; if (prompt_hook) prompt_hook(); }
int r=*s; *s=0; return r; }
static int xSemaphoreGive(SemaphoreHandle_t s) { *s=1; return 1; }
static void linenoiseSetMaxLineLen(unsigned n) { (void)n; }
static char *linenoise(const char *p) { (void)p; return NULL; }
@@ -67,8 +74,7 @@ static bool console_completion_expand(const char *s, char *d, size_t n)
{ (void)s; (void)d; (void)n; if (completion_hook) completion_hook(); return false; }
static bool console_completion_format_matches(const char *s, char *d, size_t n, size_t *len)
{ (void)s; *len=strlcpy(d,"help\r\n",n); return true; }
static size_t esp_console_split_argv(char *s, char **v, size_t n)
{ (void)s; (void)v; (void)n; return 0; } /* Real parser tested by admin_ssh_policy. */
size_t esp_console_split_argv(char *s, char **v, size_t n);
static esp_err_t esp_console_run(const char *s, int *r)
{ (void)s; ++runs; if (command_hook) command_hook(); *r=0; return ESP_OK; }
typedef struct { const char *command, *help, *hint; int (*func)(int,char **); void *argtable; } esp_console_cmd_t;
+7 -4
View File
@@ -4,10 +4,13 @@
No target scheduler, socket library, or hardware execution is claimed.
"""
from pathlib import Path
import os
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
parser = str(IDF / "components/console/split_argv.c")
source = (ROOT / "src/admin_ssh_console.c").read_text()
header = (ROOT / "src/admin_ssh_console.h").read_text()
def strip_includes(text):
@@ -21,18 +24,18 @@ with tempfile.TemporaryDirectory(prefix="admin-console-boundary-") as directory:
+ (ROOT / "tests/admin_console_boundary/test.c").read_text())
(path / "test.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-g", str(path / "test.c"),
"-g", str(path / "test.c"), parser,
"-o", str(path / "test")], check=True, timeout=30)
subprocess.run([str(path / "test")], check=True, timeout=10)
ssh = (ROOT / "src/ssh_transport.c").read_text()
adapter = ssh[ssh.index("static bool admin_console_drained("):
ssh.index("static bool consume_external_close(")]
adapter = ssh[ssh.index("static admin_ssh_console_token_t admin_console_token("):
ssh.index("static void *ssh_malloc(")]
unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text()
+ strip_includes(header) + "\n" + strip_includes(source)
+ (ROOT / "tests/admin_console_boundary/adapter.c").read_text()
+ adapter + "\nint main(void) { test_adapter(); }\n")
(path / "adapter.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-variable", str(path / "adapter.c"),
"-Wno-unused-variable", str(path / "adapter.c"), parser,
"-o", str(path / "adapter")], check=True, timeout=30)
subprocess.run([str(path / "adapter")], check=True, timeout=10)
+141 -1
View File
@@ -2,6 +2,14 @@
static admin_ssh_console_token_t a = { .slot_index=0, .session_id=7, .slot_generation=1 };
static admin_ssh_console_token_t b = { .slot_index=1, .session_id=7, .slot_generation=1, .transport=1 };
static user_principal_t admin = { .role=USER_ROLE_ADMIN };
static bool live[2] = {true, true};
static void (*current_hook)(void);
static bool is_current(const admin_ssh_console_token_t *t, const user_principal_t *p)
{
assert(!lock_depth && p->role==USER_ROLE_ADMIN);
if (current_hook) current_hook();
return live[t->slot_index];
}
static bool drained(const admin_ssh_console_token_t *t)
{ assert(!lock_depth); assert(t->session_id==7); return owner_drained; }
static esp_err_t perform(const admin_ssh_console_token_t *t,
@@ -9,6 +17,7 @@ static esp_err_t perform(const admin_ssh_console_token_t *t,
{ (void)t; (void)arg; assert(!lock_depth); assert(action==ADMIN_CONSOLE_DEFER_SELF_CLOSE); ++actions; return ESP_OK; }
static const admin_console_owner_t owner = {
.supported_actions=1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE, .drained=drained, .perform=perform,
.is_current=is_current,
};
static void pump(void (*task)(void *)) { if (!setjmp(loop_done)) task(NULL); }
static void feed(const admin_ssh_console_token_t *t, const char *s)
@@ -40,12 +49,141 @@ static void close_during_command(void)
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
}
static void setup_dispatch(void)
{ s_dispatch_remote=true; s_dispatch_token=a; s_sessions[0].executing=true; s_sessions[0].command_pending=true; }
{ s_dispatch_remote=true; s_dispatch_token=a; s_dispatch_principal=admin;
s_sessions[0].executing=true; s_sessions[0].command_pending=true; }
static void reopen_during_current(void)
{
current_hook=NULL;
admin_ssh_console_close(&a);
++a.slot_generation;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
live[0]=false; /* Failed old validation must not close the replacement. */
}
static void revoked_reply(void) { hidden_reply(); live[0]=false; }
static unsigned checks;
static void stale_at_execution(void) { if (++checks==2) live[0]=false; }
static void account_revoked_reply(void) { hidden_reply(); principal_current=false; }
static void closed_reply(void) { hidden_reply(); close_prompt(); }
static unsigned waits;
static void unanswered(void)
{
++waits;
if (waits==1) xSemaphoreGive(s_prompt_done); /* Stale wake while still waiting. */
if (waits==3) live[0]=false; /* No close notification. */
}
static void prompt_command(void)
{
uint8_t answer[32]; size_t n=99;
assert(admin_ssh_console_dispatch_read_input("Password: ",answer,sizeof(answer),true,&n)==ESP_ERR_NOT_FOUND);
assert(n==0);
for (size_t i=0;i<sizeof(answer);++i) assert(!answer[i]);
assert(!s_sessions[0].active && !s_sessions[0].prompt_length);
for (size_t i=0;i<sizeof(s_sessions[0].prompt_input);++i) assert(!s_sessions[0].prompt_input[i]);
}
static void test_currentness(void)
{
++a.slot_generation;
admin_console_owner_t missing=owner; missing.is_current=NULL;
assert(admin_ssh_console_open_owned(&a,&admin,&missing)==ESP_ERR_INVALID_ARG);
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
unsigned before=runs;
feed(&a,"owner stale\r"); live[0]=false; pump(worker_task);
assert(runs==before && !s_sessions[0].active && principal_current);
feed(&b,"isolated\r"); pump(worker_task); assert(runs==++before);
live[0]=true; ++a.slot_generation;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
feed(&a,"reuse\r"); current_hook=reopen_during_current; pump(worker_task);
assert(runs==before && token_matches(&s_sessions[0],&a));
live[0]=true;
feed(&a,"last check\r"); checks=0; current_hook=stale_at_execution;
pump(worker_task); current_hook=NULL;
assert(checks==2 && runs==before && !s_sessions[0].active);
live[0]=true; ++a.slot_generation;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
void (*hooks[])(void)={revoked_reply,account_revoked_reply,closed_reply,unanswered};
for (size_t i=0;i<sizeof(hooks)/sizeof(hooks[0]);++i) {
clear_output(&a); ticks=0; waits=0;
feed(&a,"prompt\r"); prompt_hook=hooks[i]; command_hook=prompt_command;
pump(worker_task); prompt_hook=NULL; command_hook=NULL;
assert(runs==++before);
admin_session_t empty={0}; assert(!memcmp(&empty,&s_sessions[0],sizeof(empty)));
if (i==3) assert(waits==3 && ticks==750);
/* Dispatcher recovered, so trusted UART0 work still runs. */
admin_request_t uart={.origin=ADMIN_REQUEST_UART0};
assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==++before);
live[0]=true; principal_current=true; ++a.slot_generation;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
}
setup_dispatch(); clear_output(&a); live[0]=false;
uint8_t answer[32]; size_t n=99;
assert(admin_ssh_console_dispatch_read_input("Not published",answer,sizeof(answer),true,&n)==ESP_ERR_NOT_FOUND);
assert(!n && !s_sessions[0].output_length);
secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); live[0]=true;
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
clear_output(&a);
s_sessions[0].output_start=4094;
assert(worker_write(&a,"abcdef"));
uint8_t out[8];
assert(admin_ssh_console_read_output(&a,out,3,&n)==ESP_OK && n==3 && !memcmp(out,"abc",3));
assert(!s_sessions[0].output[4094] && !s_sessions[0].output[4095] && !s_sessions[0].output[0]);
assert(!memcmp(s_sessions[0].output+1,"def",3));
assert(admin_ssh_console_read_output(&a,out,sizeof(out),&n)==ESP_OK && n==3 && !memcmp(out,"def",3));
for (size_t i=0;i<sizeof(s_sessions[0].output);++i) assert(!s_sessions[0].output[i]);
admin_ssh_console_close(&a);
puts("PASS: owner stale/account current isolation, callback close/reuse, revoked submitted prompts, periodic unanswered invalidation/stale wake, UART recovery, consumed output wiping");
}
static void test_shared_admission(void)
{
admin_ssh_console_token_t web={.slot_index=255, .session_id=7,
.slot_generation=42, .transport=ADMIN_CONSOLE_TRANSPORT_WEB};
admin_ssh_console_token_t ssh=web; ssh.transport=ADMIN_CONSOLE_TRANSPORT_SSH;
static const admin_console_owner_t second_owner={
.supported_actions=1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE,
.drained=drained, .perform=perform, .is_current=is_current,
};
assert(admin_ssh_console_open_available(&web,&admin,&owner)==ESP_OK);
assert(web.slot_index==0 && web.session_id==7 && web.slot_generation==42 &&
web.transport==ADMIN_CONSOLE_TRANSPORT_WEB);
assert(admin_ssh_console_open_available(&ssh,&admin,&second_owner)==ESP_OK);
assert(ssh.slot_index==1 && ssh.session_id==7 && ssh.slot_generation==42 && !ssh.transport);
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);
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);
assert(strstr((char *)diagnostic,"unavailable from the web console"));
feed(&web,"\"user\" \"password\" admin --generate\r"); pump(worker_task);
assert(runs==before && !s_control_queue->count);
feed(&web,"\"web\" \"status\"\r"); pump(worker_task); assert(runs==before+1);
/* UART0 bypasses remote policy and remains the recovery path. */
admin_request_t uart={.origin=ADMIN_REQUEST_UART0, .line="user recover --force"};
assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==before+2);
runs=before;
admin_ssh_console_token_t full=web; full.slot_index=99;
assert(admin_ssh_console_open_available(&full,&admin,&owner)==ESP_ERR_INVALID_STATE);
assert(full.slot_index==99);
admin_ssh_console_token_t stale=web;
s_sessions[0].executing=true;
admin_ssh_console_close(&web);
assert(admin_ssh_console_open_available(&full,&admin,&owner)==ESP_ERR_INVALID_STATE);
assert(full.slot_index==99); /* Inactive executing slots still consume capacity. */
s_sessions[0].executing=false;
++web.slot_generation;
assert(admin_ssh_console_open_available(&web,&admin,&owner)==ESP_OK);
admin_ssh_console_close(&stale);
assert(!admin_ssh_console_accepts_input(&stale) && admin_ssh_console_accepts_input(&web));
assert(admin_ssh_console_accepts_input(&ssh));
admin_ssh_console_close(&web); admin_ssh_console_close(&ssh);
puts("PASS: two-owner shared admission, colliding preferred indices/IDs, full capacity, executing reservation and stale tokens");
}
int main(void)
{
assert(admin_ssh_console_init()==ESP_OK);
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
test_shared_admission();
principal_current=false;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
principal_current=true;
@@ -74,6 +212,7 @@ int main(void)
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
pump(worker_task); assert(runs==1);
feed(&a,"revoked\r"); principal_current=false; pump(worker_task); assert(runs==1); principal_current=true;
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
admin_request_t uart={ .origin=ADMIN_REQUEST_UART0 };
assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==2);
feed(&a,"close\r"); command_hook=close_during_command; pump(worker_task); command_hook=NULL;
@@ -128,5 +267,6 @@ int main(void)
admin_ssh_console_close(&a);
assert(ssh_output_write(&a,"x",1)==-1 && errno==EPIPE);
assert(!lock_depth);
test_currentness();
puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
}
+47 -2
View File
@@ -23,7 +23,11 @@ prelude = r'''
#include <stdio.h>
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
#define ADMIN_SSH_CONSOLE_MAX_ARGUMENTS 10U
typedef struct { char line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U]; } admin_request_t;
#define ADMIN_CONSOLE_TRANSPORT_WEB 1U
typedef struct {
struct { uint8_t transport; } token;
char line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
} admin_request_t;
size_t esp_console_split_argv(char *, char **, size_t);
static void secure_wipe(void *p, size_t n) {
volatile unsigned char *bytes = p;
@@ -48,7 +52,48 @@ int main(void) {
assert(remote_command_allowed(&request) == cases[i].allowed);
assert(!strcmp(request.line, cases[i].line));
}
puts("PASS: empty input/ordinary commands allowed; physical-only commands (including quoted forms) remain denied");
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\"",
"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 credentials show", "web credentials rotate --force", "web certificate info",
"web certificate rotate --force", "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",
"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",
"user key delete admin 0 --force", "user list extra", "user show admin extra",
"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\"",
"\"ssh\" \"host-key\" \"rotate\" --force", "\"user\" \"recover\" --force",
};
for (size_t i=0; i<sizeof(web_allowed)/sizeof(web_allowed[0]); ++i) {
admin_request_t request={.token.transport=ADMIN_CONSOLE_TRANSPORT_WEB};
strcpy(request.line,web_allowed[i]);
assert(remote_command_allowed(&request));
assert(!strcmp(request.line,web_allowed[i]));
}
for (size_t i=0; i<sizeof(web_denied)/sizeof(web_denied[0]); ++i) {
admin_request_t request={.token.transport=ADMIN_CONSOLE_TRANSPORT_WEB};
strcpy(request.line,web_denied[i]);
if (remote_command_allowed(&request)) fprintf(stderr,"Unexpected allow: %s\n",request.line);
assert(!remote_command_allowed(&request));
assert(!strcmp(request.line,web_denied[i]));
request.token.transport=0;
/* SSH retains only the global bootstrap/recover dispatcher restriction. */
assert(remote_command_allowed(&request) ==
(strstr(request.line,"bootstrap")==NULL && strstr(request.line,"recover")==NULL));
}
puts("PASS: SSH policy unchanged; web read-only exceptions, mutations/lifecycle and quoted forms checked with actual IDF parser");
}
'''
with tempfile.TemporaryDirectory(prefix="admin-ssh-policy-") as directory:
+87
View File
@@ -0,0 +1,87 @@
# Admin ticket store host checks
Run from the repository root:
```sh
python3 tests/web_admin_tickets/run.py
python3 tests/web_admin_tickets/run.py --sanitize
```
Requires a C11 `cc`, Python 3, OpenSSL development headers/libcrypto, and (for
`--sanitize`) ASan/UBSan runtimes. No firmware build, network, generated assets,
or persistent build output. The runner reuses the session-store harness's tiny
platform header fakes. `test.c` includes the **unmodified production C**, using
real project principal/session declarations and OpenSSL SHA-256. Inclusion gives
white-box access for wipe, saturation and exhaustion assertions without adding
production test hooks. RNG, time and session validation are deterministic fakes;
all external calls assert that the ticket critical section is not held.
## Exact test groups
1. Stopped/start/idempotent-start lifecycle; 64 hex output; SHA-256 digest-only
storage; success, replay denial and full record wipe.
2. Two-ticket capacity and no live eviction; exact counters; nested competing
issuance takes the last slot and the losing output is wiped.
3. Issue rejects user role, public-key method, mismatched generation, zero ID,
NULL principal/output, stale sessions and session-check errors.
4. Consume burns matches before denying wrong session, user role, public-key
method, generation, stale/check-error, zero ID or NULL principal; also rejects
a different session with the *same* account principal.
5. Empty/NULL/short/long/nonhex input; uppercase hex consumes the same secret.
6. Success one microsecond before expiry; rejection at expiry; stale reclaim on
issue/snapshot; snapshot expiry cleanup; signed deadline overflow rejection.
7. Revocation ID precedence, exact username length/name and global scope;
revocation never invalidates the fake sessions.
8. RNG/SHA failures, failed output wipe, consume SHA failure leaves the
unidentifiable ticket intact, duplicate live RNG/digest rejection.
9. Issuance RNG/SHA hooks exercise stop/restart, global and nonmatching revoke,
and session invalidation; currentness hook exercises stop/restart.
10. Consume SHA/postcheck hooks exercise stop/restart, global/nonmatching revoke,
stale sessions and expiry; nested competing consumes admit exactly once.
11. Prune check races replacement with the same ID, digest and deadline; the
non-reused record generation protects the replacement from stale cleanup.
12. Nonwrapping epoch and record generation exhaustion, permanent lifecycle
failure at exhaustion, saturated counters, NULL/count-only snapshots and
host structure sizes.
## Contract and limits
The public API is in `src/web_admin_tickets.h`. This module is inert until wired
by a later integration increment. It adds no routes, session invalidation,
transport, task, socket, queue, timer or heap allocation. Callers must authorize
HTTP cookie/Origin/CSRF, invalidate the authoritative session store **before**
calling revoke, wipe successful token outputs and recheck currentness at later
sensitive boundaries. A successful consume is not an authorization lease.
Two tickets, 32 RNG bytes each, 64 hex characters plus NUL, absolute 30-second
lifetime. Only SHA-256 of decoded secret bytes is retained with copied principal,
session ID, deadline and unique generation. Both hex cases are accepted. Live
digest collisions fail rather than creating ambiguous tickets. No retry loop
or live eviction. Pruning checks at most two copied records per invocation.
Every revoke advances the epoch even if no record matches, conservatively
cancelling unrelated in-flight issue/consume work. Start is idempotent while
ready. Stop/start never resets counters, epoch or record generation.
`issued` counts published tickets, `consumed` counts burned matches (including
subsequently denied admissions), `rejected` counts failed issue/consume calls;
`capacity_rejections` is a subset of rejected. All counters saturate at UINT32_MAX.
Snapshot prunes expired/stale records and exports counts, readiness and storage
size only. A capacity failure is ESP_ERR_NO_MEM; malformed input INVALID_ARG;
unauthorized/stale/lifecycle-raced work INVALID_STATE; no live consume match
NOT_FOUND; SHA failure ESP_FAIL; RNG errors propagate. Failed issue wipes all 65
output bytes when output is non-NULL. Output must not alias inputs.
Host measured sizes: ticket 104 B, two-ticket state 248 B, fake lock 4 B, snapshot
40 B; snapshot `storage_bytes` = 252 B. Estimated 32-bit target sizes: ticket
96 B, state 232 B, plus the target portMUX (typically 8 B), roughly **240 B static
RAM**. These are estimates, not target linker measurements. Issue plus nested
prune has 240 B of explicit ticket/random local payload on this host (about
224 B on a 32-bit target), excluding scalar/compiler frames and session/RNG/SHA
call stacks; caller also owns a 65 B token. No measured target stack/flash delta.
Hooks test deterministic interleavings, not true multicore scheduling or IDF
portMUX semantics. They do not validate the real DRBG, mbedTLS, session database,
HTTP admission, hardware, or full Phase 8D.5 integration. Post-check account
changes without notification are subject to the same no-lease boundary as the
session API. Combined hardware validation remains pending; no firmware build
or device operation is part of this increment.
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Compile production ticket C with deterministic boundary fakes; no firmware build."""
import os
import pathlib
import runpy
import subprocess
import sys
import tempfile
sys.dont_write_bytecode = True
os.environ["CCACHE_DISABLE"] = "1"
HERE = pathlib.Path(__file__).resolve().parent
ROOT = HERE.parents[1]
HEADERS = runpy.run_path(str(HERE.parent / "web_session_store/run.py"))["HEADERS"]
with tempfile.TemporaryDirectory(prefix="web-admin-tickets-") as directory:
tmp = pathlib.Path(directory)
for name, text in HEADERS.items():
path = tmp / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
sanitize = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"] if "--sanitize" in sys.argv else []
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g",
*sanitize, "-I" + str(tmp), "-I" + str(ROOT / "src"),
str(HERE / "test.c"), "-lcrypto", "-o", str(tmp / "test")],
check=True, timeout=30)
subprocess.run([str(tmp / "test")], check=True, timeout=20)
+285
View File
@@ -0,0 +1,285 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include <assert.h>
#include <ctype.h>
#include <stdio.h>
#include <string.h>
#include <openssl/sha.h>
/* Include unmodified production C to inspect wipes, ABA and exhaustion without
* adding firmware-only test hooks. Platform/user/session headers remain real. */
#include "web_admin_tickets.c"
int host_lock_depth;
static int64_t clock_us;
static unsigned random_sequence;
static bool rng_fail, sha_fail, session_fail, live[4];
static user_principal_t principals[4];
static void (*rng_hook)(void), (*sha_hook)(void), (*check_hook)(void);
static unsigned check_calls, hook_at;
static unsigned tests;
int64_t esp_timer_get_time(void) { assert(!host_lock_depth); return clock_us; }
void secure_wipe(void *p, size_t n)
{
volatile unsigned char *v = p;
while (n--) *v++ = 0;
}
static void fire(void (**hook)(void))
{
void (*call)(void) = *hook;
*hook = NULL;
if (call) call();
}
esp_err_t secure_random_fill(void *p, size_t n)
{
assert(!host_lock_depth && n == 32);
memset(p, ++random_sequence, n);
fire(&rng_hook);
return rng_fail ? ESP_FAIL : ESP_OK;
}
int mbedtls_sha256(const unsigned char *p, size_t n, unsigned char *out, int mode)
{
assert(!host_lock_depth && n == 32 && mode == 0);
assert(SHA256(p, n, out));
fire(&sha_hook);
return sha_fail ? -1 : 0;
}
esp_err_t web_session_store_check_principal(web_session_id_t id,
const user_principal_t *p, bool *valid)
{
assert(!host_lock_depth);
++check_calls;
*valid = id < 4 && live[id] && same_principal(&principals[id], p);
if (check_calls == hook_at) fire(&check_hook);
/* Like the real session resolver, recheck liveness before returning;
* never upgrade an already-failed check after a slot replacement. */
*valid = *valid && id < 4 && live[id] && same_principal(&principals[id], p);
return session_fail ? ESP_FAIL : ESP_OK;
}
static void zero(const void *p, size_t n)
{
const unsigned char *v = p;
while (n--) assert(*v++ == 0);
}
static void reset(void)
{
/* Test isolation only; production never resets these generations. */
memset(&s_state, 0, sizeof(s_state));
clock_us = 100;
random_sequence = 0;
rng_fail = sha_fail = session_fail = false;
rng_hook = sha_hook = check_hook = NULL;
check_calls = hook_at = 0;
for (unsigned i = 1; i < 4; ++i) {
live[i] = true;
principals[i] = (user_principal_t) {
.user_id = i, .auth_generation = 1, .role = USER_ROLE_ADMIN,
.method = USER_AUTH_METHOD_PASSWORD, .username_length = 1,
.username = {(char)('a' + i - 1), 0},
};
}
web_admin_tickets_start();
}
static void passed(const char *name) { ++tests; printf("PASS %s\n", name); }
static void issue(unsigned id, char *token)
{
assert(web_admin_tickets_issue(id, &principals[id], token) == ESP_OK);
}
static void restart(void) { web_admin_tickets_stop(); web_admin_tickets_start(); }
static void revoke_all(void) { web_admin_tickets_revoke(0, NULL, 0); }
static void revoke_other(void) { web_admin_tickets_revoke(99, NULL, 0); }
static void stale(void) { live[1] = false; }
static void expire(void) { clock_us += WEB_ADMIN_TICKET_LIFETIME_US; }
static void fail_issue(void)
{
char token[65];
memset(token, 'x', sizeof(token));
assert(web_admin_tickets_issue(1, &principals[1], token) != ESP_OK);
zero(token, sizeof(token));
zero(s_state.tickets, sizeof(s_state.tickets));
}
static char replacement[65];
static void replace_stale(void)
{
revoke_all();
live[1] = true;
random_sequence = 0; /* Same digest, ID and deadline: only generation differs. */
issue(1, replacement);
}
static char nested_token[65];
static esp_err_t nested_result;
static void nested_issue(void)
{
issue(2, nested_token);
}
static void nested_consume(void)
{
nested_result = web_admin_tickets_consume(nested_token, 1, &principals[1]);
}
int main(void)
{
char a[65], b[65], c[65];
web_admin_tickets_snapshot_t snap;
reset();
web_admin_tickets_stop(); fail_issue();
web_admin_tickets_start(); issue(1, a);
uint64_t epoch = s_state.epoch;
web_admin_tickets_start(); assert(s_state.epoch == epoch);
assert(strlen(a) == 64);
for (unsigned i = 0; i < 64; ++i) assert(isxdigit((unsigned char)a[i]));
uint8_t raw[32]; memset(raw, 1, sizeof(raw));
uint8_t expected[32]; assert(SHA256(raw, sizeof(raw), expected));
assert(equal_digest(s_state.tickets[1].digest, expected));
assert(memcmp(s_state.tickets[1].digest, raw, 32));
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_NOT_FOUND);
zero(s_state.tickets, sizeof(s_state.tickets));
passed("lifecycle, hex/digest storage, single use and wipe");
reset(); issue(1, a); issue(2, b);
assert(web_admin_tickets_issue(3, &principals[3], c) == ESP_ERR_NO_MEM);
zero(c, sizeof(c)); web_admin_tickets_get_snapshot(&snap);
assert(snap.active == 2 && snap.issued == 2 && snap.rejected == 1 &&
snap.capacity_rejections == 1 && snap.ready);
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
assert(web_admin_tickets_consume(b, 2, &principals[2]) == ESP_OK);
reset(); issue(3, c); rng_hook = nested_issue;
assert(web_admin_tickets_issue(1, &principals[1], a) == ESP_ERR_NO_MEM);
zero(a, sizeof(a));
assert(web_admin_tickets_consume(nested_token, 2, &principals[2]) == ESP_OK);
assert(web_admin_tickets_consume(c, 3, &principals[3]) == ESP_OK);
passed("capacity rejects without live eviction, competing issue and exact counters");
reset(); principals[1].role = USER_ROLE_USER; fail_issue();
principals[1].role = USER_ROLE_ADMIN;
principals[1].method = USER_AUTH_METHOD_SSH_PUBLIC_KEY; fail_issue();
principals[1].method = USER_AUTH_METHOD_PASSWORD;
user_principal_t bad = principals[1]; bad.auth_generation++;
assert(web_admin_tickets_issue(1, &bad, a) == ESP_ERR_INVALID_STATE);
assert(web_admin_tickets_issue(0, &principals[1], a) == ESP_ERR_INVALID_ARG);
assert(web_admin_tickets_issue(1, NULL, a) == ESP_ERR_INVALID_ARG);
assert(web_admin_tickets_issue(1, &principals[1], NULL) == ESP_ERR_INVALID_ARG);
live[1] = false; fail_issue(); live[1] = true;
session_fail = true; fail_issue();
passed("issue role, password, session/principal binding and errors");
for (unsigned mode = 0; mode < 8; ++mode) {
reset(); issue(1, a); bad = principals[1];
unsigned id = 1;
if (mode == 0) id = 2;
if (mode == 1) bad.role = USER_ROLE_USER;
if (mode == 2) bad.method = USER_AUTH_METHOD_SSH_PUBLIC_KEY;
if (mode == 3) bad.auth_generation++;
if (mode == 4) live[1] = false;
if (mode == 5) session_fail = true;
if (mode == 6) id = 0;
assert(web_admin_tickets_consume(a, id, mode == 7 ? NULL : &bad) == ESP_ERR_INVALID_STATE);
zero(s_state.tickets, sizeof(s_state.tickets));
assert(s_state.consumed == 1);
}
reset(); principals[2] = principals[1]; issue(1, a);
assert(web_admin_tickets_consume(a, 2, &principals[2]) == ESP_ERR_INVALID_STATE);
zero(s_state.tickets, sizeof(s_state.tickets));
passed("consume burns before wrong identity/role/currentness results, same-account session binding");
reset(); random_sequence = 170; issue(1, a);
strcpy(b, a); b[63] = 0;
assert(web_admin_tickets_consume(b, 1, &principals[1]) == ESP_ERR_INVALID_ARG);
char long_token[66]; memcpy(long_token, a, 64); long_token[64] = 'a'; long_token[65] = 0;
assert(web_admin_tickets_consume(long_token, 1, &principals[1]) == ESP_ERR_INVALID_ARG);
assert(web_admin_tickets_consume("", 1, &principals[1]) == ESP_ERR_INVALID_ARG);
assert(web_admin_tickets_consume(NULL, 1, &principals[1]) == ESP_ERR_INVALID_ARG);
strcpy(b, a); b[30] = 'g';
assert(web_admin_tickets_consume(b, 1, &principals[1]) == ESP_ERR_INVALID_ARG);
for (unsigned i = 0; i < 64; ++i) a[i] = (char)toupper((unsigned char)a[i]);
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
passed("exact bounded hex validation and uppercase equivalence");
reset(); issue(1, a); clock_us += WEB_ADMIN_TICKET_LIFETIME_US - 1;
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
issue(1, a); expire();
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_NOT_FOUND);
issue(1, a); issue(2, b); live[1] = false; issue(3, c);
web_admin_tickets_get_snapshot(&snap); assert(snap.active == 2);
live[2] = false; web_admin_tickets_get_snapshot(&snap); assert(snap.active == 1);
expire(); web_admin_tickets_get_snapshot(&snap); assert(snap.active == 0);
clock_us = INT64_MAX - WEB_ADMIN_TICKET_LIFETIME_US + 1;
fail_issue();
passed("absolute expiry boundary, stale cleanup and time overflow");
reset(); issue(1, a); issue(2, b);
web_admin_tickets_revoke(1, (const uint8_t *)"b", 1);
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_NOT_FOUND);
assert(web_admin_tickets_consume(b, 2, &principals[2]) == ESP_OK);
issue(1, a); issue(2, b);
web_admin_tickets_revoke(0, (const uint8_t *)"a", 0);
web_admin_tickets_get_snapshot(&snap); assert(snap.active == 2);
web_admin_tickets_revoke(0, (const uint8_t *)"a", 1);
web_admin_tickets_get_snapshot(&snap); assert(snap.active == 1);
revoke_all(); zero(s_state.tickets, sizeof(s_state.tickets));
assert(live[1] && live[2]);
passed("revoke ID precedence, exact username, all; no session invalidation");
reset(); rng_fail = true; fail_issue();
reset(); sha_fail = true; fail_issue();
reset(); issue(1, a); sha_fail = true;
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_FAIL);
sha_fail = false;
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
reset(); issue(1, a); random_sequence = 0;
assert(web_admin_tickets_issue(2, &principals[2], b) == ESP_FAIL);
zero(b, sizeof(b));
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
passed("RNG/SHA failure, output wipe and live digest collision rejection");
void (*actions[])(void) = {restart, revoke_all, revoke_other, stale};
for (unsigned i = 0; i < sizeof(actions) / sizeof(actions[0]); ++i) {
reset(); rng_hook = actions[i]; fail_issue();
reset(); sha_hook = actions[i]; fail_issue();
}
reset(); hook_at = 1; check_hook = restart; fail_issue();
passed("issue stop/restart, revoke and stale races across RNG/SHA/currentness");
for (unsigned i = 0; i < sizeof(actions) / sizeof(actions[0]); ++i) {
reset(); issue(1, a); sha_hook = actions[i];
assert(web_admin_tickets_consume(a, 1, &principals[1]) != ESP_OK);
reset(); issue(1, a); hook_at = check_calls + 2; check_hook = actions[i];
assert(web_admin_tickets_consume(a, 1, &principals[1]) != ESP_OK);
assert(s_state.consumed == 1);
}
reset(); issue(1, a); sha_hook = expire;
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_NOT_FOUND);
reset(); issue(1, a); hook_at = check_calls + 2; check_hook = expire;
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_INVALID_STATE);
reset(); issue(1, nested_token); sha_hook = nested_consume;
assert(web_admin_tickets_consume(nested_token, 1, &principals[1]) == ESP_ERR_NOT_FOUND);
assert(nested_result == ESP_OK && s_state.consumed == 1);
passed("consume crypto/postcheck lifecycle/expiry races and competing consume");
reset(); issue(1, a); live[1] = false;
hook_at = check_calls + 1; check_hook = replace_stale;
web_admin_tickets_get_snapshot(&snap); assert(snap.active == 1);
assert(web_admin_tickets_consume(replacement, 1, &principals[1]) == ESP_OK);
passed("stale-prune slot replacement ABA");
reset(); issue(1, a); s_state.epoch = UINT64_MAX - 1;
revoke_all(); web_admin_tickets_start();
assert(s_state.epoch == UINT64_MAX && !s_state.ready); fail_issue();
restart(); assert(s_state.epoch == UINT64_MAX && !s_state.ready);
reset(); s_state.generation = UINT64_MAX - 1; issue(1, a);
assert(web_admin_tickets_issue(1, &principals[1], b) == ESP_ERR_INVALID_STATE);
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
restart(); assert(!s_state.ready);
reset(); s_state.issued = s_state.consumed = s_state.rejected = UINT32_MAX;
s_state.capacity_rejections = UINT32_MAX;
issue(1, a); issue(2, b);
assert(web_admin_tickets_issue(3, &principals[3], c) == ESP_ERR_NO_MEM);
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
web_admin_tickets_get_snapshot(&snap);
assert(snap.issued == UINT32_MAX && snap.consumed == UINT32_MAX &&
snap.rejected == UINT32_MAX && snap.capacity_rejections == UINT32_MAX);
web_admin_tickets_get_snapshot(NULL);
printf("Host sizes: ticket=%zu state=%zu lock=%zu snapshot=%zu bytes\n",
sizeof(ticket_t), sizeof(s_state), sizeof(s_lock), sizeof(snap));
passed("nonwrapping epoch/generation, saturating counters, count-only snapshot");
printf("%u test groups passed\n", tests);
}
+193
View File
@@ -0,0 +1,193 @@
# Admin WebSocket transport host harness
Run from the repository root:
```sh
python3 tests/web_admin_transport/run.py
python3 tests/web_admin_transport/run.py --strict
python3 tests/web_admin_transport/run.py --tickets
python3 tests/web_admin_transport/run.py --sanitize
```
`CC` selects the compiler. The runner compiles the current production
`src/web_admin_transport.c` and production public headers into a temporary C11
translation unit with `-Wall -Wextra -Werror`. Only include directives are removed;
transport functions are not copied or reimplemented. Temporary output is removed.
`platform.h` supplies host types; `fakes.h` doubles dependencies; `test.c` exercises
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
Final continuation: `run.py --tickets` passes **19 transport / 12 ticket groups**,
including the HTTPD-owned shutdown retry/reuse regression. `server_lifecycle.py`
passes **11 groups** against extracted production server lifecycle/URI tables.
`python3 tests/web_cookie_auth/run.py --admin` now links the real cookie policy,
session store, tickets, private adapter and admin transport for endpoint admission,
pre-101 rejection and logout/expiry/currentness cleanup checks; console and runtime
IO remain doubled. These supersede the older counts/integration-pending notes
below. Final admin closure uses direct HTTPD-owned `shutdown`, not queued IDF
session-close work. Parent reports the sequential final firmware build after this
fix passed in **23.55 s**, at **95,580 B RAM / 1,637,273 B flash**, and the final
independent security integration review found no actionable findings. See
`docs/phase8d5_implementation.md` for build history and the pending target procedure.
After the production empty-frame, input-deadline and timer-generation fixes:
- `python3 tests/web_admin_transport/run.py`: **18 groups passed**.
- `python3 tests/web_admin_transport/run.py --strict`: **18 groups passed**.
- All assertions are mandatory by default. `--strict` is retained as a
compatibility flag with identical behavior; there are no expected-defect probes
or failure exemptions.
- Earlier, before these regression additions, `--tickets` also ran the separate
production ticket suite: **12 groups passed**. It was not rerun in this update.
This is a separate suite, not transport plus real-ticket integration.
- The earlier `--sanitize --tickets` attempt was blocked at linking by missing
`/usr/lib64/libasan.so.8.0.0` and `/usr/lib64/libubsan.so.1.0.0`.
Sanitizers were not rerun in this update; no sanitizer pass is claimed.
## Meaningful coverage
- PSRAM-only allocation flags, allocation/timer-create failure cleanup, retry,
idempotent initialization and duplicate attachment rejection.
- Authentication-helper delegation, role rejection, ticket response/capacity,
exact upgrade URI shape, ticket failure and ticket consumption before capacity
rejection. Shared console admission precedes 101; fake console assigns index 1
to verify that the returned shared-slot token is retained.
- One admin socket without replacement; failed upgrade and revocation during
console admission release reservations and console state.
- 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
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
paths close the console and flag the slot without socket operations.
- Send/queue failure paths, close-trigger suppression after success, deferred
action support checks and the output-send drain guard. A queue-submission hook
frees and re-admits the HTTPD slot before returning failure: the replacement
generation remains unflagged/live and its next poll delivers output.
- Detach disables acceptance and new timer submissions. A deterministic hook
enters detach during submission, exercises its timeout, then verifies retry.
Queued work after detach does no IO. Successful-stop simulation discards pending
work and frees HTTPD context before `stopped` retires the queue marker/re-attach.
- Disconnect wipes payload and retires console state; replacement generations
reject a previous owner token. Dependency fakes assert external calls occur
outside the transport critical section and socket/input/output operations occur
in the simulated HTTPD owner context.
## Empty-frame regression
IDF 5.5's `httpd_ws_recv_frame` uses `frame->len == 0` as its header-parsing
sentinel. Calling it twice on an empty frame would parse a second header. The
production transport now skips the payload receive for zero-length frames.
The mandatory regression asserts one header parse, no input/close side effect,
and successful feeding of a following nonempty frame.
The fake models the sentinel checked in the installed IDF 5.5
`components/esp_http_server/src/httpd_ws.c`; it counts parses rather than
emulating socket timeout or wire desynchronization. No production source was
edited for this regression update.
## Limits / remaining integration and target work
This is deterministic single-threaded execution, not a concurrency proof. Locks
are assertions and races are selected reentrant hooks; FreeRTOS scheduling,
esp_timer scheduling, stack bounds, allocation placement on hardware, and memory
floors are not measured. Payload byte counts use host ABI metadata sizes; 512-byte
RX plus 1024-byte TX are not the entire allocated struct size.
Cookie/Origin/CSRF parsing, real session expiry/principal storage, ticket crypto,
shared-console allocator/dispatcher/prompts/policy and SSH are doubled here.
Their implementation correctness is not established by this harness. In
particular it does not prove simultaneous use of both real shared console slots.
The independent ticket suite is optional via `--tickets`.
HTTPD request/context/upgrade/send/close and queue operations are fakes. Close
triggering is recorded, not queued as IDF's real session-close work. Queue delivery
loss, socket-slot reuse, TLS partial reads/writes, ping/pong/control-frame handling,
actual HTTPD stop completion and on-wire pre-101 responses require real-IDF or
target validation. The empty-frame sentinel is modeled from source, not linked
from IDF. Failed `httpd_ssl_stop` orchestration is the integrating server's duty;
this harness only calls `stopped` after simulated successful shutdown.
Server route registration, six-socket non-eviction policy, revocation hook order,
status aggregation, full-client coexistence, serial writer/USB isolation and
whole-8D.5 target acceptance remain main integration/target work. No production
source is changed by this harness; passing normal mode does not close Phase 8D.5.
## Temporary authenticated device smoke client
`client.py` is a local Python-standard-library-only tool, not shipped firmware,
UI, or a new endpoint. **Running it contacts the specified device and consumes a
login attempt/session and, for an administrator, an admin console slot.** Only run
against a device you are authorized to test. It never uploads, builds or erases.
```sh
# System TLS trust; certificate hostname must match the explicit HTTPS origin.
python3 tests/web_admin_transport/client.py --url https://device.local
# Trust a locally obtained PEM CA/device certificate; hostname is still verified.
python3 tests/web_admin_transport/client.py --url https://device.local --cafile device-cert.pem --smoke
# Explicit isolated/local-test opt-in ONLY: warns and disables TLS verification.
python3 tests/web_admin_transport/client.py --url https://device.local --insecure --max-runtime 60 --timeout 10
```
Replace the example hostname with your device's certificate-matching hostname.
Only HTTPS origins on port 443 are accepted: no URL credentials, application
paths, queries or fragments. Host and Origin are derived from that validated
origin; redirects and environment proxies are not followed. `--insecure` does
**not** enforce private-address routing: it is an explicit operator opt-in, not
proof that the destination is local. Prefer `--cafile`, with its certificate
obtained through a trusted channel; insecure mode exposes credentials to active
network interception.
Username is requested with `input`, password with non-echoing `getpass`. Password
entry fails rather than falling back to echoed input. No credential arguments,
cookie files or HTTP debug logs are used. Cookies (including HttpOnly) are kept
in an in-memory CookieJar and copied into the WebSocket request header. Routine
results never print cookies, CSRF, passwords, tickets, ticket URLs or exception
representations. Python immutable strings cannot be reliably erased from memory;
this is not protection against process inspection, swap or core dumps.
The default and only mode is bounded smoke (`--smoke` is optional):
1. GET `/api/login-challenge` with `X-Login-Bootstrap: 1`, then JSON username/password
POST `/api/login` with challenge CSRF, then GET `/api/session` for session CSRF.
All requests include the matching Origin.
2. For role `user`, require HTTP 403 from the CSRF-protected admin ticket POST.
3. For role `admin`, mint a ticket, authenticate `/ws/admin` with the cookie and
ticket, validate the 101 handshake, then require 403 when replaying that ticket
with the same live cookie. Run binary `help`, an empty binary frame, empty Enter,
and `exit`, waiting for prompts/closure rather than sending commands in a burst.
4. Close the client socket and attempt CSRF-protected logout in `finally`; require
a subsequent session request to return 401. Cleanup failure is reported and
makes the command fail. If connectivity or authentication-response delivery
fails, server-side cleanup cannot be guaranteed; a session may remain until
its absolute expiry. There are no automatic login retries.
Console bytes are **deliberately printed directly to stdout**, including terminal
control sequences. Use a trusted device and do not capture output into routine
logs if console commands may disclose sensitive information. Authentication
metadata and rejected-response bodies are not printed. Smoke uses no mutating
administration command other than closing its own console/login session.
This version intentionally has no interactive/raw-terminal mode, so it does not
change terminal settings or exercise completion/hidden prompts. It requires
POSIX interval timers for a hard runtime guard: default 60 seconds, configurable
up to 300, starting after credential entry, plus up to 10 seconds for cleanup.
Individual transport timeout defaults to 10 seconds (maximum 30). HTTP/upgrade
headers or response bodies, frames, and per-command output have bounded sizes.
The WS parser accepts only final, unmasked, bounded binary/control frames and
masks all client frames; it is not a general-purpose WebSocket implementation.
**Local validation (2026-09-06):** syntax compiled in memory, and offline in-memory
checks passed for valid/rejected URLs, masked client frame encoding, bounded
server frame rejection, both-role smoke/replay/command sequencing, HttpOnly
CookieJar header forwarding and logout cleanup sequencing. These checks were run
without adding test files or opening sockets. No device/network command, TLS
handshake, browser test, interactive test or hardware validation was performed.
The client is temporary test tooling; its implementation and these local checks
do not establish whole-8D.5 acceptance.
+308
View File
@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""Temporary stdlib-only Phase 8D.5 smoke client; no device validation implied."""
import argparse
import base64
import getpass
import hashlib
import http.client
import http.cookiejar
import ipaddress
import json
import os
import re
import signal
import socket
import ssl
import struct
import sys
import time
import urllib.request
import warnings
from urllib.parse import urlsplit
class Failure(Exception):
"""Only fixed, secret-free diagnostics may be supplied here."""
def require(condition, message):
if not condition:
raise Failure(message)
def origin_url(value):
try:
u = urlsplit(value)
require(u.scheme == 'https' and u.hostname and not u.username and
not u.password and u.port in (None, 443) and
u.path in ('', '/') and not u.query and not u.fragment,
'URL must be an HTTPS origin on port 443 without credentials/query.')
host = u.hostname.encode('idna').decode('ascii').lower()
try:
address = ipaddress.ip_address(host)
authority = '[' + host + ']' if address.version == 6 else host
except ValueError:
require(len(host) <= 253 and all(re.fullmatch(
r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?', label)
for label in host.split('.')), 'Invalid hostname.')
authority = host
require(len(authority) + 8 <= 128, 'Origin exceeds firmware limit.')
return host, authority, 'https://' + authority
except (ValueError, UnicodeError):
raise Failure('Invalid HTTPS origin.') from None
def secret_field(body, field):
value = body.get(field)
require(isinstance(value, str) and re.fullmatch(r'[0-9a-fA-F]{64}', value),
'Missing or malformed authentication field.')
return value
class Client:
def __init__(self, args):
self.host, self.authority, self.origin = origin_url(args.url)
self.context = ssl.create_default_context(cafile=args.cafile)
if args.insecure:
print('WARNING: LOCAL TEST ONLY: TLS certificate/hostname verification DISABLED.',
file=sys.stderr)
self.context.check_hostname = False
self.context.verify_mode = ssl.CERT_NONE
self.jar = http.cookiejar.CookieJar()
self.timeout = args.timeout
self.deadline = time.monotonic() + args.max_runtime
self.csrf = None
self.ws = None
def budget(self):
left = self.deadline - time.monotonic()
require(left > 0, 'Maximum runtime exceeded.')
return min(self.timeout, left)
def cookie_request(self, path):
request = urllib.request.Request(self.origin + path)
self.jar.add_cookie_header(request) # Secure/HttpOnly cookies stay in memory.
return request
def api(self, path, method='GET', body=None, headers=None, expected=200):
request = self.cookie_request(path)
fields = {'Origin': self.origin, 'Connection': 'close'}
fields.update(headers or {})
cookie = request.get_header('Cookie')
if cookie:
fields['Cookie'] = cookie
data = json.dumps(body).encode() if body is not None else None
if data is not None:
fields['Content-Type'] = 'application/json'
conn = http.client.HTTPSConnection(self.host, 443, timeout=self.budget(),
context=self.context)
try:
conn.request(method, path, body=data, headers=fields)
response = conn.getresponse() # No redirects or proxy/environment routing.
self.jar.extract_cookies(response, request)
require(response.status == expected, 'Unexpected HTTP status: %d.' % response.status)
payload = response.read(4097)
require(len(payload) <= 4096, 'HTTP response exceeds bound.')
result = json.loads(payload) if payload else {}
require(isinstance(result, dict), 'Expected JSON object.')
return result
finally:
conn.close()
def upgrade(self, ticket, expected=101):
path = '/ws/admin?ticket=' + ticket
cookie = self.cookie_request(path).get_header('Cookie')
require(cookie is not None, 'Session cookie unavailable for upgrade.')
key = base64.b64encode(os.urandom(16)).decode('ascii')
raw = socket.create_connection((self.host, 443), self.budget())
sock = None
try:
sock = self.context.wrap_socket(raw, server_hostname=self.host)
sock.settimeout(self.budget())
message = ('GET %s HTTP/1.1\r\nHost: %s\r\nOrigin: %s\r\n'
'Upgrade: websocket\r\nConnection: Upgrade\r\n'
'Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: %s\r\n'
'Cookie: %s\r\n\r\n') % (path, self.authority, self.origin, key, cookie)
sock.sendall(message.encode('ascii'))
header = bytearray()
while not header.endswith(b'\r\n\r\n'):
require(len(header) < 4096, 'Upgrade headers exceed bound.')
sock.settimeout(self.budget())
byte = sock.recv(1) # Do not consume an immediately following WS frame.
require(byte, 'Connection ended during upgrade.')
header.extend(byte)
lines = bytes(header).decode('ascii').split('\r\n')
parts = lines[0].split(' ', 2)
require(len(parts) >= 2 and parts[0] == 'HTTP/1.1' and parts[1].isdigit(),
'Malformed upgrade response.')
require(int(parts[1]) == expected, 'Unexpected upgrade status: %d.' % int(parts[1]))
if expected != 101:
return None
fields = {}
for line in lines[1:-2]:
name, separator, value = line.partition(':')
name = name.lower()
require(separator and name not in fields, 'Ambiguous upgrade headers.')
fields[name] = value.strip()
accept = base64.b64encode(hashlib.sha1((key +
'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest()).decode()
require(fields.get('sec-websocket-accept') == accept and
fields.get('upgrade', '').lower() == 'websocket' and
'upgrade' in [v.strip() for v in fields.get('connection', '').lower().split(',')] and
'sec-websocket-extensions' not in fields and
'sec-websocket-protocol' not in fields, 'Invalid WebSocket handshake.')
result, sock = sock, None
return result
finally:
if sock is not None:
sock.close()
elif expected != 101:
raw.close()
if sock is None and raw.fileno() != -1:
raw.close()
def send(self, payload, opcode=2):
require(len(payload) <= (125 if opcode >= 8 else 512), 'Client frame exceeds bound.')
mask = os.urandom(4)
length = len(payload)
header = bytes([0x80 | opcode, 0x80 | (length if length < 126 else 126)])
if length >= 126:
header += struct.pack('!H', length)
self.ws.settimeout(self.budget())
self.ws.sendall(header + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
def exact(self, count):
data = bytearray()
while len(data) < count:
self.ws.settimeout(self.budget())
chunk = self.ws.recv(count - len(data))
if not chunk:
raise EOFError
data.extend(chunk)
return bytes(data)
def frame(self):
first, second = self.exact(2)
opcode, length = first & 15, second & 127
require(first & 0x80 and not first & 0x70 and not second & 0x80 and
opcode in (2, 8, 9, 10), 'Unsupported server frame.')
require(length != 127 and (opcode < 8 or length <= 125), 'Server frame exceeds bound.')
if length == 126:
length = struct.unpack('!H', self.exact(2))[0]
require(length >= 126, 'Noncanonical frame length.')
require(length <= 1024 and not (opcode == 8 and length == 1), 'Invalid server frame length.')
return opcode, self.exact(length)
def drain(self, closing=False):
recent = bytearray()
total = 0
while True:
try:
opcode, payload = self.frame()
except EOFError:
require(closing, 'WebSocket closed before command prompt.')
return
if opcode == 8:
require(closing, 'WebSocket closed before command prompt.')
return
if opcode == 9:
self.send(payload, 10)
if opcode != 2:
continue
total += len(payload)
require(total <= 65536, 'Console output exceeds smoke bound.')
sys.stdout.buffer.write(payload) # Deliberate console output, never auth metadata.
sys.stdout.buffer.flush()
recent.extend(payload)
del recent[:-128]
if not closing and recent.endswith(b'admin@serial-tool> '):
return
def smoke(self, username, password):
challenge = self.api('/api/login-challenge', headers={'X-Login-Bootstrap': '1'})
self.api('/api/login', 'POST', {'username': username, 'password': password},
{'X-CSRF-Token': secret_field(challenge, 'csrf')})
session = self.api('/api/session')
self.csrf = secret_field(session, 'csrf')
require(session.get('role') in ('user', 'admin'), 'Unexpected session role.')
if session['role'] == 'user':
self.api('/api/admin/ws-ticket', 'POST', headers={'X-CSRF-Token': self.csrf}, expected=403)
print('PASS: user ticket request rejected (403).')
return
ticket = secret_field(self.api('/api/admin/ws-ticket', 'POST',
headers={'X-CSRF-Token': self.csrf}), 'ticket')
self.ws = self.upgrade(ticket)
self.upgrade(ticket, expected=403) # Live cookie + consumed ticket: reject before capacity.
self.drain()
for command in (b'help\r', b'', b'\r'):
self.send(command)
if command:
self.drain()
self.send(b'exit\r')
self.drain(closing=True)
print('\nPASS: admin upgrade/replay, help, empty frame/Enter and exit smoke.')
def cleanup(self):
if self.ws is not None:
self.ws.close()
self.deadline = time.monotonic() + 10
try:
if any(cookie.name == '__Host-sak-session' for cookie in self.jar):
if self.csrf is None:
self.csrf = secret_field(self.api('/api/session'), 'csrf')
self.api('/api/logout', 'POST', headers={'X-CSRF-Token': self.csrf}, expected=204)
self.api('/api/session', expected=401)
print('PASS: logout and unauthenticated session check.')
finally:
self.jar.clear()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--url', required=True, help='HTTPS origin, port 443 only')
trust = parser.add_mutually_exclusive_group()
trust.add_argument('--cafile', help='trusted PEM CA/device certificate; hostname must match')
trust.add_argument('--insecure', action='store_true', help='LOCAL TEST ONLY: disable TLS verification')
parser.add_argument('--smoke', action='store_true', help='bounded smoke (default; only mode)')
parser.add_argument('--timeout', type=float, default=10)
parser.add_argument('--max-runtime', type=float, default=60)
args = parser.parse_args()
client = None
result = 0
try:
require(hasattr(signal, 'setitimer'), 'This bounded client requires POSIX interval timers.')
require(0 < args.timeout <= 30 and 0 < args.max_runtime <= 300, 'Invalid timeout/runtime bounds.')
def expired(signum, frame):
raise Failure('Maximum runtime exceeded.')
signal.signal(signal.SIGALRM, expired)
client = Client(args)
username = input('Username: ')
with warnings.catch_warnings():
warnings.simplefilter('error', getpass.GetPassWarning)
password = getpass.getpass('Password: ')
client.deadline = time.monotonic() + args.max_runtime
signal.setitimer(signal.ITIMER_REAL, args.max_runtime)
try:
client.smoke(username, password)
finally:
password = None # Python cannot guarantee erasure of immutable strings.
except (Exception, KeyboardInterrupt) as error:
print('FAIL: ' + (str(error) if isinstance(error, Failure) else
'Operation failed; details suppressed to protect credentials/tickets.'), file=sys.stderr)
result = 1
finally:
if client is not None and hasattr(signal, 'setitimer'):
signal.setitimer(signal.ITIMER_REAL, 0)
try:
signal.setitimer(signal.ITIMER_REAL, 10)
client.cleanup()
except (Exception, KeyboardInterrupt):
print('WARNING: logout cleanup unconfirmed; session may remain until expiry.', file=sys.stderr)
result = 1
finally:
signal.setitimer(signal.ITIMER_REAL, 0)
return result
if __name__ == '__main__':
sys.exit(main())
+136
View File
@@ -0,0 +1,136 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Deterministic dependencies; production transport is included after this file. */
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 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;
static uint8_t fed[2048], output[1024], sent[1024];
static size_t sent_length;
static char status[64], response_body[256];
static web_session_view_t auth_view;
static admin_ssh_console_token_t console_token;
static const admin_console_owner_t *console_owner;
static httpd_req_t *live_request;
static httpd_ws_frame_t incoming;
static void (*queued_work)(void *);
static void *queued_argument;
static void (*check_hook)(void);
static void (*queue_hook)(void);
static void (*timer_callback)(void *);
static void io(void) { OUTSIDE(); assert(httpd_owner); }
static void *heap_caps_calloc(size_t n, size_t size, unsigned caps) {
OUTSIDE(); assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
return alloc_fail ? NULL : calloc(n, size);
}
static void heap_caps_free(void *p) { OUTSIDE(); free(p); }
static esp_err_t esp_timer_create(const esp_timer_create_args_t *a, esp_timer_handle_t *t) {
OUTSIDE(); assert(a->skip_unhandled_events); timer_callback = a->callback;
if (timer_fail) return ESP_FAIL;
*t = &server_storage; return ESP_OK;
}
static esp_err_t esp_timer_start_periodic(esp_timer_handle_t t, uint64_t period) {
OUTSIDE(); assert(t && period == 20000); return ESP_OK;
}
static esp_err_t esp_timer_delete(esp_timer_handle_t t) { OUTSIDE(); assert(t); return ESP_OK; }
esp_err_t web_session_store_check_principal(web_session_id_t id, const user_principal_t *p, bool *valid) {
OUTSIDE(); ++checks;
if (check_hook) { void (*hook)(void) = check_hook; check_hook = NULL; hook(); }
*valid = session_current && id == auth_view.id && p &&
p->user_id == auth_view.principal.user_id && p->auth_generation == auth_view.principal.auth_generation &&
p->role == auth_view.principal.role && p->method == auth_view.principal.method &&
p->username_length == auth_view.principal.username_length &&
!memcmp(p->username, auth_view.principal.username, p->username_length);
return ESP_OK;
}
static esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade,
web_session_view_t *v, bool *allowed) {
io(); assert(r); assert(mutation != upgrade);
*v = auth_view; *allowed = auth_allowed; return ESP_OK;
}
void web_admin_tickets_start(void) { OUTSIDE(); }
void web_admin_tickets_stop(void) { OUTSIDE(); ticket_live = false; }
void web_admin_tickets_revoke(web_session_id_t id, const uint8_t *u, size_t n) {
OUTSIDE(); (void)id; (void)u; (void)n;
}
esp_err_t web_admin_tickets_issue(web_session_id_t id, const user_principal_t *p, char token[WEB_ADMIN_TICKET_LENGTH + 1U]) {
OUTSIDE(); assert(id == auth_view.id && p->role == USER_ROLE_ADMIN);
if (ticket_live) return ESP_ERR_NO_MEM;
memset(token, 'a', 64); token[64] = 0; ticket_live = true; return ESP_OK;
}
esp_err_t web_admin_tickets_consume(const char *t, web_session_id_t id, const user_principal_t *p) {
OUTSIDE(); assert(id && p); bool valid = ticket_live && strlen(t) == 64;
ticket_live = false; return valid ? ESP_OK : ESP_ERR_NOT_FOUND;
}
esp_err_t admin_ssh_console_open_available(admin_ssh_console_token_t *t, const user_principal_t *p,
const admin_console_owner_t *owner) {
OUTSIDE(); assert(p->role == USER_ROLE_ADMIN);
if (console_full) return ESP_ERR_INVALID_STATE;
assert(!console_live); t->slot_index = 1; console_token = *t; console_owner = owner; console_live = true;
if (revoke_on_open) { session_current = false; web_admin_transport_revoke(auth_view.id, NULL, 0); }
return ESP_OK;
}
void admin_ssh_console_close(const admin_ssh_console_token_t *t) {
OUTSIDE();
if (console_live && !memcmp(t, &console_token, sizeof(*t))) console_live = false;
}
bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *t, const uint8_t *data,
size_t length, size_t *consumed) {
io(); assert(console_live && t->session_id == console_token.session_id);
*consumed = length < feed_limit ? length : feed_limit;
assert(fed_length + *consumed <= sizeof(fed));
memcpy(fed + fed_length, data, *consumed); fed_length += *consumed; return *consumed != 0;
}
esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *t, uint8_t *data,
size_t capacity, size_t *received) {
io(); assert(t->session_id == console_token.session_id);
*received = output_length < capacity ? output_length : capacity;
memcpy(data, output, *received); output_length -= *received;
if (revoke_on_send) { session_current = false; web_admin_transport_revoke(auth_view.id, NULL, 0); }
return ESP_OK;
}
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;
}
static esp_err_t httpd_queue_work(httpd_handle_t h, void (*fn)(void *), void *arg) {
OUTSIDE(); assert(h == SERVER); ++queues;
if (queue_hook) { void (*hook)(void) = queue_hook; queue_hook = NULL; hook(); }
if (queue_fail) return ESP_FAIL;
assert(!queued_work); queued_work = fn; queued_argument = arg; return ESP_OK;
}
static void *httpd_sess_get_ctx(httpd_handle_t h, int fd) {
io(); assert(h == SERVER); return live_request && live_request->fd == fd ? live_request->sess_ctx : NULL;
}
static int httpd_ws_get_fd_info(httpd_handle_t h, int fd) { io(); assert(h == SERVER && fd >= 0); return HTTPD_WS_CLIENT_WEBSOCKET; }
#define SHUT_RDWR 2
static bool shutdown_fail;
static int shutdown(int fd, int how) {
io(); assert(how == SHUT_RDWR && live_request && live_request->fd == fd);
++closes; return shutdown_fail ? -1 : 0;
}
static int httpd_req_to_sockfd(httpd_req_t *r) { io(); return r->fd; }
static esp_err_t httpd_ws_send_frame_async(httpd_handle_t h, int fd, httpd_ws_frame_t *f) {
io(); assert(h == SERVER && fd >= 0 && f->final && f->type == HTTPD_WS_TYPE_BINARY);
++sends; sent_length = f->len; memcpy(sent, f->payload, f->len); return send_fail ? ESP_FAIL : ESP_OK;
}
/* Match IDF 5.5's frame->len == 0 sentinel, including its empty-frame reparse. */
static esp_err_t httpd_ws_recv_frame(httpd_req_t *r, httpd_ws_frame_t *f, size_t capacity) {
io(); (void)r;
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;
}
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; }
static esp_err_t httpd_resp_set_hdr(httpd_req_t *r, const char *k, const char *v) { io(); (void)r; assert(k && v); return ESP_OK; }
static esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *s) { io(); (void)r; snprintf(response_body, sizeof(response_body), "%s", s); return ESP_OK; }
static bool web_httpd_upgrade_requested(httpd_req_t *r) { io(); (void)r; return upgrade_requested; }
static esp_err_t web_httpd_upgrade(httpd_req_t *r, esp_err_t (*handler)(httpd_req_t *)) {
io(); assert(r && handler && console_live); ++upgrades; return upgrade_fail ? ESP_FAIL : ESP_OK;
}
static bool web_httpd_unread_body(httpd_req_t *r) { io(); (void)r; return false; }
static void web_httpd_wipe_request(httpd_req_t *r, bool closing) { io(); (void)r; (void)closing; ++wipes; }
+52
View File
@@ -0,0 +1,52 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>
typedef int esp_err_t;
enum { ESP_OK, ESP_FAIL, ESP_ERR_NO_MEM, ESP_ERR_INVALID_ARG,
ESP_ERR_INVALID_STATE, ESP_ERR_NOT_SUPPORTED, ESP_ERR_NOT_FOUND,
ESP_ERR_TIMEOUT, ESP_ERR_NOT_ALLOWED };
typedef int portMUX_TYPE;
#define portMUX_INITIALIZER_UNLOCKED 0
static int lock_depth;
#define taskENTER_CRITICAL(lock) do { (void)(lock); assert(lock_depth++ == 0); } while (0)
#define taskEXIT_CRITICAL(lock) do { (void)(lock); assert(--lock_depth == 0); } while (0)
#define OUTSIDE() assert(lock_depth == 0)
#define MALLOC_CAP_SPIRAM 1
#define MALLOC_CAP_8BIT 2
typedef void *httpd_handle_t;
typedef struct httpd_req {
httpd_handle_t handle;
const char *uri;
void *sess_ctx;
void (*free_ctx)(void *);
int fd;
} httpd_req_t;
typedef struct {
bool final;
int type;
uint8_t *payload;
size_t len;
} httpd_ws_frame_t;
enum { HTTPD_WS_TYPE_BINARY = 2, HTTPD_WS_TYPE_TEXT = 1,
HTTPD_WS_TYPE_CLOSE = 8, HTTPD_WS_CLIENT_WEBSOCKET = 3 };
typedef void *esp_timer_handle_t;
typedef struct {
void (*callback)(void *);
const char *name;
bool skip_unhandled_events;
} esp_timer_create_args_t;
static int64_t now;
static int64_t esp_timer_get_time(void) { OUTSIDE(); return now; }
static void vTaskDelay(unsigned ticks) { OUTSIDE(); now += ticks * 1000; }
static void secure_wipe(void *p, size_t n) {
volatile unsigned char *b = p;
while (n--) *b++ = 0;
}
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Deterministic production-C transport harness; no target build or device IO."""
from pathlib import Path
import argparse
import os
import subprocess
import tempfile
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
def stripped(path):
return '\n'.join(line for line in path.read_text().splitlines()
if not line.startswith(('#include', '#pragma once'))) + '\n'
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--sanitize', action='store_true')
parser.add_argument('--tickets', action='store_true', help='also run separate real ticket suite')
parser.add_argument('--strict', action='store_true',
help='compatibility flag: all regression assertions are mandatory by default')
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix='web-admin-transport-') as directory:
path = Path(directory)
unit = (HERE / 'platform.h').read_text() + '\n'
for header in ('user_database.h', 'web_session_store.h', 'admin_ssh_console.h',
'web_admin_tickets.h', 'web_admin_transport.h'):
unit += stripped(ROOT / 'src' / header)
unit += (HERE / 'fakes.h').read_text() + '\n'
unit += stripped(ROOT / 'src/web_admin_transport.c')
unit += (HERE / 'test.c').read_text()
(path / 'test.c').write_text(unit)
flags = ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] if args.sanitize else []
subprocess.run([os.environ.get('CC', 'cc'), '-std=c11', '-Wall', '-Wextra', '-Werror',
'-g', *flags, str(path / 'test.c'), '-o', str(path / 'test')],
check=True, timeout=30)
subprocess.run([str(path / 'test')], check=True, timeout=15)
if args.tickets:
subprocess.run(['python3', str(ROOT / 'tests/web_admin_tickets/run.py'),
*(['--sanitize'] if args.sanitize else [])], check=True, timeout=60)
@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""Compile production server lifecycle and URI tables against fixed host fakes.
No HTTP handlers, TLS/HTTPD runtime, transport implementation or scheduler is
executed. Assertions cover server orchestration and values passed to registration
and SSL-start fakes, not actual requests/101, socket eviction or concurrent stop.
No firmware build, network access or device operation. CC selects the compiler.
"""
import os
from pathlib import Path
import re
import subprocess
import tempfile
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
SOURCE = ROOT / 'src/web_server.c'
source = SOURCE.read_text()
def function(name):
match = re.search(r'^(?:static )?esp_err_t ' + name + r'\(void\)\n\{.*?^\}',
source, re.M | re.S)
if not match:
raise RuntimeError('Production function shape changed: ' + name)
return match.group() + '\n'
def define(path, name):
match = re.search(r'^#define ' + name + r' .+$', path.read_text(), re.M)
if not match:
raise RuntimeError('Missing production constant: ' + name)
return match.group() + '\n'
# Extract complete initializers, retaining real handler pointers and flags.
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
source, re.M | re.S)
# Non-array declarations have no brackets; explicit shape avoids silent omission.
if len(uri_tables) != 13:
raise RuntimeError('Review URI extraction: expected 11 descriptors and two tables')
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
source.index('static esp_err_t ensure_mutex(void)')]
header = (ROOT / 'src/web_server.h').read_text()
header = '\n'.join(line for line in header.splitlines()
if not line.startswith(('#include', '#pragma once')))
constants = define(SOURCE, 'WEB_SERVER_PORT')
for filename, names in {
'web_admin_transport.h': ('WEB_ADMIN_TICKET_URI', 'WEB_ADMIN_WS_URI'),
'web_serial_transport.h': ('WEB_SERIAL_TRANSPORT_TICKET_URI', 'WEB_SERIAL_TRANSPORT_WS_URI'),
'web_security.h': ('WEB_SECURITY_CERTIFICATE_DER_CAPACITY', 'WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY'),
}.items():
for name in names:
constants += define(ROOT / 'src' / filename, name)
FAKES = 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, ESP_FAIL, ESP_ERR_INVALID_STATE, ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT };
typedef void *SemaphoreHandle_t;
typedef void *httpd_handle_t;
typedef struct { int unused; } httpd_req_t;
typedef int httpd_err_code_t;
enum { HTTP_GET, HTTP_POST, HTTPD_404_NOT_FOUND = 404, HTTPD_405_METHOD_NOT_ALLOWED = 405 };
enum { WEB_UI_RESOURCE_XTERM_JS, WEB_UI_RESOURCE_XTERM_CSS, WEB_UI_RESOURCE_ADDON_FIT_JS,
WEB_UI_RESOURCE_APP_JS, WEB_UI_RESOURCE_LOGO_PNG };
typedef struct {
const char *uri; int method; esp_err_t (*handler)(httpd_req_t *);
void *user_ctx; bool is_websocket, handle_ws_control_frames;
} httpd_uri_t;
typedef struct {
struct { unsigned max_open_sockets, max_uri_handlers; bool lru_purge_enable;
unsigned recv_wait_timeout, send_wait_timeout; } httpd;
const uint8_t *servercert, *prvtkey_pem;
size_t servercert_len, prvtkey_len;
unsigned port_secure, tls_handshake_timeout_ms;
} httpd_ssl_config_t;
/* Nonproduction defaults deliberately make explicit overrides observable. */
#define HTTPD_SSL_CONFIG_DEFAULT() ((httpd_ssl_config_t){.httpd = {.max_open_sockets = 1, .lru_purge_enable = true}})
#define portMAX_DELAY 0
static int mutex_storage, server_storage, locked;
#define SERVER ((void *)&server_storage)
static bool mutex_fail, auth_live, ssl_live, admin_owned, serial_live;
static esp_err_t serial_init_error, admin_init_error, admin_attach_error;
static esp_err_t auth_error, ssl_start_error, ssl_stop_error, admin_detach_error;
static unsigned serial_inits, admin_inits, auth_starts, auth_stops;
static unsigned ssl_starts, ssl_stops, serial_attaches, serial_detaches;
static unsigned admin_attaches, admin_detaches, admin_stoppeds;
static unsigned registration_calls, registration_fail_at, registered_count, unregister_calls;
static bool unregister_fail;
static const httpd_uri_t *registered[32];
static char events[128]; static size_t event_length;
static void event(char value) { assert(!locked && event_length + 1 < sizeof(events)); events[event_length++] = value; events[event_length] = 0; }
static SemaphoreHandle_t xSemaphoreCreateMutex(void) { assert(!locked); return mutex_fail ? NULL : &mutex_storage; }
static void xSemaphoreTake(SemaphoreHandle_t m, int wait) { (void)wait; assert(m && !locked); locked = 1; }
static void xSemaphoreGive(SemaphoreHandle_t m) { assert(m && locked); locked = 0; }
static void secure_wipe(void *p, size_t n) { assert(!locked); memset(p, 0, n); }
#define HANDLER(name) static esp_err_t name(httpd_req_t *r) { (void)r; assert(!"HTTP handler must not run in lifecycle harness"); return ESP_FAIL; }
HANDLER(root_handler) HANDLER(status_handler) HANDLER(ticket_handler)
HANDLER(websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
HANDLER(web_admin_transport_ticket_handler) HANDLER(web_admin_transport_upgrade_handler)
static esp_err_t route_error_handler(httpd_req_t *r, httpd_err_code_t c) { (void)r; (void)c; assert(0); return ESP_FAIL; }
static esp_err_t web_serial_transport_init(void) { assert(!locked); ++serial_inits; return serial_init_error; }
static esp_err_t web_cookie_auth_start(void) { assert(!locked); ++auth_starts; auth_live = auth_error == ESP_OK; return auth_error; }
static void web_cookie_auth_stop(void) { event('A'); ++auth_stops; auth_live = false; }
static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t *lc,
uint8_t *key, size_t nk, size_t *lk) {
assert(!locked && auth_live && nc && nk); cert[0] = 1; key[0] = 2; *lc = *lk = 1; return ESP_OK;
}
static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_t *config) {
assert(!locked && auth_live && !ssl_live); ++ssl_starts;
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable);
assert(config->httpd.max_uri_handlers == 16 && config->port_secure == 443);
assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
assert(config->tls_handshake_timeout_ms == 5000);
assert(config->servercert_len == 1 && config->servercert[0] == 1);
assert(config->prvtkey_len == 1 && config->prvtkey_pem[0] == 2);
if (ssl_start_error != ESP_OK) return ssl_start_error;
*server = SERVER; ssl_live = true; return ESP_OK;
}
static esp_err_t register_one(httpd_handle_t server) {
assert(!locked && server == SERVER && ssl_live); ++registration_calls;
return registration_calls == registration_fail_at ? ESP_FAIL : ESP_OK;
}
static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t *uri) {
if (!strcmp(uri->uri, "/api/admin/ws-ticket") || !strcmp(uri->uri, "/ws/admin")) {
assert(registration_calls >= 16);
assert(serial_init_error != ESP_OK || serial_live);
} else assert(registration_calls < 14);
esp_err_t error = register_one(s);
if (error == ESP_OK) { assert(registered_count < 32); registered[registered_count++] = uri; }
return error;
}
static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri, int method) {
assert(!locked && s == SERVER && ssl_live && auth_live && serial_live);
assert(registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST);
++unregister_calls;
for (unsigned i = 0; i < registered_count; ++i) {
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
if (unregister_fail) return ESP_FAIL;
memmove(&registered[i], &registered[i + 1],
(registered_count - i - 1) * sizeof(registered[0]));
--registered_count;
return ESP_OK;
}
}
assert(!"unregister must target the previously registered ticket");
return ESP_FAIL;
}
static esp_err_t httpd_register_err_handler(httpd_handle_t s, httpd_err_code_t code,
esp_err_t (*handler)(httpd_req_t *, httpd_err_code_t)) {
assert(registration_calls == 14 || registration_calls == 15);
assert((code == 404 || code == 405) && handler == route_error_handler);
return register_one(s);
}
static esp_err_t web_serial_transport_attach_server(httpd_handle_t s) {
assert(!locked && s == SERVER && ssl_live && auth_live && registration_calls == 16);
++serial_attaches; serial_live = true; return ESP_OK;
}
static esp_err_t web_admin_transport_init(void) { assert(!locked && auth_live && registration_calls == 18); ++admin_inits; return admin_init_error; }
static esp_err_t web_admin_transport_attach(httpd_handle_t s) {
assert(!locked && s == SERVER && ssl_live && !admin_owned); ++admin_attaches;
admin_owned = admin_attach_error == ESP_OK; return admin_attach_error;
}
static esp_err_t web_admin_transport_detach(httpd_handle_t s) {
assert(s == SERVER && ssl_live && admin_owned && !auth_live);
event('D'); ++admin_detaches; return admin_detach_error;
}
static esp_err_t web_serial_transport_detach_server(httpd_handle_t s) {
assert(s == SERVER && ssl_live && serial_live && !auth_live);
event('S'); ++serial_detaches; serial_live = false; return ESP_OK;
}
static esp_err_t httpd_ssl_stop(httpd_handle_t s) {
assert(s == SERVER && ssl_live && !auth_live); event('H'); ++ssl_stops;
if (ssl_stop_error == ESP_OK) ssl_live = false;
return ssl_stop_error;
}
static void web_admin_transport_stopped(httpd_handle_t s) {
assert(s == SERVER && !ssl_live && admin_owned && admin_detaches);
event('R'); ++admin_stoppeds; admin_owned = false;
}
'''
TESTS = r'''
static void clear_events(void) { event_length = 0; events[0] = 0; }
static void reset(void) {
assert(!locked);
s_server_mutex = NULL; s_server = NULL; s_initialized = s_transitioning = false;
s_serial_transport_init_attempted = s_serial_transport_initialized = false;
s_serial_transport_attached = s_admin_transport_owned = false;
s_last_error = s_serial_transport_error = ESP_ERR_INVALID_STATE;
memset(&s_counters, 0, sizeof(s_counters));
mutex_fail = auth_live = ssl_live = admin_owned = serial_live = false;
serial_init_error = admin_init_error = admin_attach_error = ESP_OK;
auth_error = ssl_start_error = ssl_stop_error = admin_detach_error = ESP_OK;
serial_inits = admin_inits = auth_starts = auth_stops = ssl_starts = ssl_stops = 0;
serial_attaches = serial_detaches = admin_attaches = admin_detaches = admin_stoppeds = 0;
registration_calls = registration_fail_at = registered_count = unregister_calls = 0;
unregister_fail = false; clear_events();
}
static void fresh_registration(void) { registration_calls = registered_count = 0; }
static void start(void) {
assert(web_server_start() == ESP_OK);
assert(s_server == SERVER && s_admin_transport_owned && s_serial_transport_attached);
assert(auth_live && ssl_live && admin_owned && serial_live && !s_transitioning);
}
static const httpd_uri_t *route(const char *uri) {
const httpd_uri_t *found = NULL;
for (unsigned i = 0; i < registered_count; ++i) if (!strcmp(registered[i]->uri, uri)) {
assert(!found); found = registered[i];
}
assert(found); return found;
}
int main(void) {
reset(); mutex_fail = true;
assert(web_server_init() == ESP_ERR_NO_MEM && !s_initialized && !serial_inits);
mutex_fail = false; serial_init_error = ESP_FAIL;
assert(web_server_init() == ESP_OK && s_initialized && !s_serial_transport_initialized);
assert(web_server_start() == ESP_OK && auth_live && ssl_live && admin_owned);
assert(serial_inits == 1 && !serial_attaches && !auth_stops);
assert(web_server_stop() == ESP_OK);
puts("PASS mutex failure and serial-init failure isolation from authenticated HTTPS");
for (unsigned mode = 0; mode < 2; ++mode) {
reset(); if (mode == 0) admin_init_error = ESP_ERR_NO_MEM; else admin_attach_error = ESP_FAIL;
assert(web_server_start() == ESP_OK && auth_live && ssl_live && serial_live);
assert(s_serial_transport_attached && !s_admin_transport_owned && !auth_stops);
assert(admin_inits == 1 && admin_attaches == mode);
assert(web_server_stop() == ESP_OK && !admin_detaches && !admin_stoppeds);
}
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
reset(); start(); assert(registered_count == 16 && registration_calls == 18);
const httpd_uri_t *ticket = route("/api/admin/ws-ticket"), *ws = route("/ws/admin");
assert(ticket->method == HTTP_POST && ticket->handler == web_admin_transport_ticket_handler && !ticket->is_websocket);
assert(ws->method == HTTP_GET && ws->handler == web_admin_transport_upgrade_handler && !ws->is_websocket);
assert(route("/ws/serial")->method == HTTP_GET && !route("/ws/serial")->is_websocket);
assert(route("/api/login")->method == HTTP_POST && route("/api/session")->method == HTTP_GET);
assert(web_server_start() == ESP_ERR_INVALID_STATE && auth_starts == 1 && ssl_starts == 1);
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ADSHR"));
assert(!s_server && !s_admin_transport_owned && !s_transitioning && s_counters.stops == 1);
puts("PASS production URI tables/registration, six sockets/no LRU, admission and ordered normal stop");
fresh_registration(); start(); assert(ssl_starts == 2 && serial_inits == 1 && admin_inits == 2);
assert(s_counters.starts == 2 && web_server_stop() == ESP_OK && admin_stoppeds == 2);
puts("PASS restart after successful stop reattaches without repeated serial initialization");
reset(); start(); admin_detach_error = ESP_ERR_TIMEOUT; clear_events();
assert(web_server_stop() == ESP_ERR_TIMEOUT && !strcmp(events, "AD"));
assert(!ssl_stops && !serial_detaches && !admin_stoppeds);
assert(s_server == SERVER && s_admin_transport_owned && admin_owned && ssl_live);
assert(s_serial_transport_attached && !s_transitioning && s_last_error == ESP_ERR_TIMEOUT);
assert(web_server_start() == ESP_ERR_INVALID_STATE && auth_starts == 1);
admin_detach_error = ESP_OK; clear_events();
assert(web_server_stop() == ESP_OK && !strcmp(events, "ADSHR") && admin_detaches == 2);
puts("PASS admin detach timeout fences SSL stop, retains ownership and permits stop retry");
reset(); start(); ssl_stop_error = ESP_FAIL; clear_events();
assert(web_server_stop() == ESP_FAIL && !strcmp(events, "ADSH"));
assert(s_server == SERVER && s_admin_transport_owned && admin_owned && ssl_live);
assert(!s_serial_transport_attached && !s_transitioning && !admin_stoppeds);
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
ssl_stop_error = ESP_OK; clear_events();
assert(web_server_stop() == ESP_OK && !strcmp(events, "ADHR"));
assert(admin_detaches == 2 && serial_detaches == 1 && admin_stoppeds == 1 && !s_admin_transport_owned);
puts("PASS failed SSL stop retains admin ownership; stopped runs only after successful retry");
for (unsigned failure = 1; failure <= 16; ++failure) {
reset(); registration_fail_at = failure;
assert(web_server_start() == ESP_FAIL);
assert(registration_calls == failure && !admin_inits && !admin_attaches && !serial_attaches);
assert(!auth_live && !ssl_live && ssl_stops == 1 && !s_server && !s_admin_transport_owned);
assert(!admin_detaches && !admin_stoppeds && !s_transitioning && s_counters.start_failures == 1);
}
puts("PASS required registration positions 1..16 fail fatally before transport attachment");
for (unsigned failure = 17; failure <= 18; ++failure) {
reset(); registration_fail_at = failure;
assert(web_server_start() == ESP_OK && registration_calls == failure);
assert(auth_live && ssl_live && serial_live && s_server == SERVER);
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops);
assert(!s_transitioning && s_last_error == ESP_OK && s_counters.starts == 1 && !s_counters.start_failures);
assert(registered_count == 14 && unregister_calls == failure - 17);
for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
assert(route("/ws/serial")->handler == websocket_handler);
assert(route("/api/session")->handler == web_cookie_auth_handler);
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds);
registration_fail_at = 0; fresh_registration(); start();
assert(registered_count == 16 && admin_attaches == 1 && s_counters.starts == 2);
assert(web_server_stop() == ESP_OK && admin_stoppeds == 1);
}
puts("PASS optional positions 17..18 preserve M1, roll back ticket when needed and recover after stop/restart");
reset(); registration_fail_at = 18; unregister_fail = true;
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 15);
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
ticket = route("/api/admin/ws-ticket");
assert(ticket->method == HTTP_POST && !ticket->is_websocket &&
ticket->handler == web_admin_transport_ticket_handler);
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/ws/admin"));
/* Handler identity is checked, not its authentication implementation (doubled). */
assert(!auth_stops && !ssl_stops && !s_transitioning && s_last_error == ESP_OK);
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds);
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start();
assert(registered_count == 16 && admin_attaches == 1 && web_server_stop() == ESP_OK);
puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart");
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
assert(web_server_start() == ESP_FAIL && s_server == SERVER && ssl_live);
assert(!s_admin_transport_owned && !admin_attaches && !auth_live);
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
ssl_stop_error = ESP_OK; clear_events();
assert(web_server_stop() == ESP_OK && !strcmp(events, "AH") && !admin_stoppeds);
registration_fail_at = 0; fresh_registration(); start(); assert(web_server_stop() == ESP_OK);
puts("PASS registration cleanup SSL failure retains partial server for stop/restart without admin ownership");
reset(); auth_error = ESP_FAIL;
assert(web_server_start() == ESP_FAIL && !ssl_starts && !admin_inits && !s_server);
reset(); ssl_start_error = ESP_FAIL;
assert(web_server_start() == ESP_FAIL && !auth_live && !registration_calls && !ssl_stops);
reset(); assert(web_server_stop() == ESP_ERR_INVALID_STATE);
assert(web_server_init() == ESP_OK); s_transitioning = true;
assert(web_server_start() == ESP_ERR_INVALID_STATE && !auth_starts);
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
puts("11 lifecycle groups passed (16 required fatal positions, 2 optional positions, plus failed unregister)");
return 0;
}
'''
unit = FAKES + header + '\n' + constants + state + '\n'.join(uri_tables)
unit += function('ensure_mutex')
unit += ''.join(function(name) for name in ('web_server_init', 'web_server_start', 'web_server_stop'))
unit += TESTS
with tempfile.TemporaryDirectory(prefix='web-admin-server-lifecycle-') as directory:
temporary = Path(directory)
c_file = temporary / 'test.c'
c_file.write_text(unit)
executable = temporary / 'test'
subprocess.run([os.environ.get('CC', 'cc'), '-std=c11', '-Wall', '-Wextra', '-Werror',
'-g', str(c_file), '-o', str(executable)], check=True, timeout=30)
subprocess.run([str(executable)], check=True, timeout=15)
print('Compiled production init/start/stop, URI initializers and configuration; dependency behavior is faked.')
+234
View File
@@ -0,0 +1,234 @@
/* SPDX-License-Identifier: GPL-3.0-only */
static char uri[128];
static httpd_req_t request;
static unsigned cases;
static bool zeroed(const void *p, size_t n) {
const uint8_t *b = p; for (size_t i = 0; i < n; ++i) if (b[i]) return false; return true;
}
static void reset(void) {
assert(lock_depth == 0);
free(s_payload); s_payload = NULL;
memset(&s_slot, 0, sizeof(s_slot)); memset(&s_counts, 0, sizeof(s_counts));
s_timer = NULL; s_server = NULL; s_initialized = s_accepting = s_queued = false;
s_submitting = s_generation = 0;
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;
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));
memset(status, 0, sizeof(status)); memset(response_body, 0, sizeof(response_body));
queued_work = NULL; queued_argument = NULL; check_hook = queue_hook = NULL;
console_owner = NULL; live_request = NULL; now = 1000000;
auth_view = (web_session_view_t){.id = 7, .principal = {
.user_id = 3, .auth_generation = 9, .role = USER_ROLE_ADMIN,
.method = USER_AUTH_METHOD_PASSWORD, .username_length = 5, .username = "admin"}};
snprintf(uri, sizeof(uri), "%s?ticket=%064d", WEB_ADMIN_WS_URI, 0);
request = (httpd_req_t){.handle = SERVER, .uri = uri, .fd = 12};
incoming = (httpd_ws_frame_t){.final = true, .type = HTTPD_WS_TYPE_BINARY};
}
static void start(void) {
assert(web_admin_transport_init() == ESP_OK);
assert(web_admin_transport_attach(SERVER) == ESP_OK);
}
static void admit(void) {
ticket_live = true;
assert(web_admin_transport_upgrade_handler(&request) == ESP_OK);
assert(upgrades == 1 && s_slot.active && console_live && request.free_ctx);
assert(s_slot.token.slot_index == 1); live_request = &request;
}
static void tick(void) { httpd_owner = false; timer_callback(NULL); httpd_owner = true; }
static void work(void) {
assert(queued_work); void (*fn)(void *) = queued_work; void *arg = queued_argument;
queued_work = NULL; queued_argument = NULL; fn(arg);
}
static void disconnected(void) {
assert(request.free_ctx); request.free_ctx(request.sess_ctx);
request.free_ctx = NULL; request.sess_ctx = NULL; live_request = NULL;
}
static void ok(const char *name) { ++cases; printf("PASS %s\n", name); }
static void revoke_check(void) { session_current = false; web_admin_transport_revoke(auth_view.id, NULL, 0); }
static void detach_in_submit(void) {
assert(web_admin_transport_detach(SERVER) == ESP_ERR_TIMEOUT);
assert(!s_accepting && s_server == SERVER && s_submitting == 1);
}
static void replace_in_submit(void) {
assert(!httpd_owner && s_submitting == 1 && s_queued);
uint32_t generation = s_slot.token.slot_generation;
httpd_owner = true;
disconnected();
assert(!s_slot.occupied && !console_live);
upgrades = 0;
admit();
assert(s_slot.token.slot_generation != generation && !s_slot.close_requested);
httpd_owner = false;
}
int main(void) {
reset(); alloc_fail = true; assert(web_admin_transport_init() == ESP_ERR_NO_MEM);
assert(!s_initialized && !s_payload); alloc_fail = false; timer_fail = true;
assert(web_admin_transport_init() == ESP_FAIL && !s_payload);
timer_fail = false; start(); assert(web_admin_transport_init() == ESP_OK);
assert(web_admin_transport_attach(SERVER) == ESP_ERR_INVALID_STATE);
ok("PSRAM-only allocation failure, timer failure, retry and duplicate attach");
reset(); start(); auth_allowed = false;
assert(web_admin_transport_ticket_handler(&request) == ESP_OK && !ticket_live);
assert(web_admin_transport_upgrade_handler(&request) == ESP_OK && !upgrades);
auth_allowed = true; auth_view.principal.role = USER_ROLE_USER;
assert(web_admin_transport_ticket_handler(&request) != ESP_OK && !strcmp(status, "403 Forbidden"));
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && !upgrades);
auth_view.principal.role = USER_ROLE_ADMIN;
assert(web_admin_transport_ticket_handler(&request) == ESP_OK && ticket_live);
assert(strstr(response_body, "\"expires_in\":30"));
assert(web_admin_transport_ticket_handler(&request) != ESP_OK && !strcmp(status, "503 Service Unavailable"));
ok("authorization delegation, admin role and ticket capacity responses");
reset(); start(); ticket_live = true; upgrade_requested = false;
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && ticket_live && !upgrades);
upgrade_requested = true; request.uri = "/ws/admin?ticket=short";
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && !upgrades);
request.uri = uri; ticket_live = false;
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && !s_slot.occupied);
ticket_live = true; console_full = true;
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && !ticket_live && !s_slot.occupied && !upgrades);
console_full = false; revoke_on_open = true; ticket_live = true;
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && !console_live && !s_slot.occupied && !upgrades);
ok("pre-101 malformed/ticket/shared-console rejection and revocation during admission");
reset(); start(); upgrade_fail = true; ticket_live = true;
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && !console_live && !s_slot.occupied);
assert(zeroed(s_payload, sizeof(*s_payload)));
ok("failed upgrade unwinds console, slot and payload");
reset(); start(); admit(); admin_ssh_console_token_t old = s_slot.token;
ticket_live = true; httpd_req_t second = request; second.fd = 13; second.sess_ctx = NULL;
assert(web_admin_transport_upgrade_handler(&second) != ESP_OK && upgrades == 1 && console_live);
tick(); tick(); tick(); assert(queues == 1 && s_queued);
memcpy(output, "hello", 5); output_length = 5; work();
assert(sends == 1 && sent_length == 5 && !memcmp(sent, "hello", 5));
assert(zeroed(s_payload->tx, sizeof(s_payload->tx)) && !s_slot.sending);
disconnected(); assert(!console_live && !s_slot.occupied && zeroed(s_payload, sizeof(*s_payload)));
upgrades = 0; admit(); assert(s_slot.token.session_id != old.session_id);
assert(!owner_current(&old, &auth_view.principal));
ok("single admin slot, shared slot token, one outstanding poll, TX wiping, disconnect/reuse");
reset(); start(); admit(); uint8_t bytes[] = {0, 1, 2, 255};
incoming.payload = bytes; incoming.len = sizeof(bytes); feed_limit = 2;
assert(frame_handler(&request) == ESP_OK && fed_length == 2 && s_payload->rx_offset == 2);
assert(zeroed(s_payload->rx, 2)); tick(); work();
assert(fed_length == 4 && !memcmp(fed, bytes, 4) && s_payload->rx_length == 0 && zeroed(s_payload->rx, 4));
ok("binary-transparent bounded input with partial consume/retry and wiping");
reset(); start(); admit(); incoming.payload = bytes; incoming.len = 4; feed_limit = 0;
assert(frame_handler(&request) == ESP_OK); now += ADMIN_INPUT_TIMEOUT_US;
tick(); work(); assert(closes == 1 && !console_live && s_counts.input_backpressure == 1);
tick(); work(); assert(closes == 1);
ok("input timeout closes once without new task or notifier IO");
for (unsigned late = 0; late < 2; ++late) {
reset(); start(); admit(); incoming.payload = bytes; incoming.len = 4; feed_limit = 2;
assert(frame_handler(&request) == ESP_OK && fed_length == 2);
assert(s_payload->rx_offset == 2 && s_payload->rx_length == 4);
feed_limit = SIZE_MAX; /* Dispatcher is now ready, but the bytes are expired. */
now = s_payload->input_deadline + late;
tick(); work();
assert(fed_length == 2 && s_payload->rx_offset == 2);
assert(closes == 1 && !console_live && s_counts.input_backpressure == 1);
disconnected(); assert(zeroed(s_payload, sizeof(*s_payload)));
}
ok("ready console must not consume pending bytes at or after input deadline");
for (unsigned mode = 0; mode < 3; ++mode) {
reset(); start(); admit(); incoming.payload = bytes; incoming.len = 4;
if (mode == 0) incoming.final = false;
if (mode == 1) incoming.type = HTTPD_WS_TYPE_TEXT;
if (mode == 2) incoming.len = WEB_ADMIN_RX_CAPACITY + 1;
assert(frame_handler(&request) != ESP_OK && !fed_length && !console_live);
}
ok("fragmented, text and oversized input rejected before payload feed");
reset(); start(); admit(); incoming.payload = bytes; incoming.len = 4; feed_limit = 0;
assert(frame_handler(&request) == ESP_OK);
assert(frame_handler(&request) != ESP_OK && !fed_length && !console_live);
ok("second frame rejected while input buffer occupied");
reset(); start(); admit(); httpd_owner = false;
web_admin_transport_revoke(99, NULL, 0); assert(console_live);
web_admin_transport_revoke(0, (const uint8_t *)"other", 5); assert(console_live);
web_admin_transport_revoke(0, (const uint8_t *)"admin", 5);
assert(!console_live && s_slot.close_requested && !closes); httpd_owner = true;
tick(); work(); assert(closes == 1);
ok("session/account revocation isolation and HTTPD-only close request");
reset(); start(); admit(); session_current = false; tick(); work();
assert(!console_live && closes == 1 && !sends);
reset(); start(); admit(); memcpy(output, "secret", 6); output_length = 6;
revoke_on_send = true; tick(); work();
assert(!sends && !console_live && zeroed(s_payload->tx, sizeof(s_payload->tx)));
reset(); start(); admit(); check_hook = revoke_check;
assert(!owner_current(&s_slot.token, &auth_view.principal));
ok("idle expiry, revocation between ring read and send, currentness recheck");
reset(); start(); admit(); send_fail = true; output[0] = 1; output_length = 1;
tick(); work(); assert(s_counts.send_failures == 1 && closes == 1 && !console_live);
reset(); start(); admit(); queue_fail = true; tick();
assert(!s_queued && !s_submitting && s_slot.close_requested && s_counts.queue_failures == 1);
queue_fail = false; tick(); work(); assert(!console_live && closes == 1);
ok("send failure and queue failure close/retry paths");
reset(); start(); admit(); request_close(); shutdown_fail = true;
tick(); work(); assert(closes == 1 && !s_slot.close_triggered && !queued_work);
shutdown_fail = false; tick(); work(); assert(closes == 2 && s_slot.close_triggered && !queued_work);
disconnected(); upgrades = 0; admit();
tick(); work(); assert(closes == 2 && console_live && !s_slot.close_requested);
ok("HTTPD-owned shutdown retries without queuing a reusable HTTPD slot pointer or closing replacement");
reset(); start(); admit(); queue_fail = true; queue_hook = replace_in_submit;
tick();
assert(s_counts.queue_failures == 1 && !s_queued && !s_submitting && !queued_work);
assert(s_slot.active && console_live && !s_slot.close_requested && !closes);
queue_fail = false; memcpy(output, "replacement", 11); output_length = 11;
tick(); work();
assert(sends == 1 && sent_length == 11 && !memcmp(sent, "replacement", 11));
assert(console_live && !s_slot.close_requested && !closes);
ok("failed timer submission cannot close HTTPD-replaced generation; replacement poll recovers");
reset(); start(); admit(); tick(); httpd_owner = false;
assert(web_admin_transport_detach(SERVER) == ESP_OK && !console_live);
tick(); assert(queues == 1); httpd_owner = true; work(); assert(!sends && !s_queued);
disconnected(); httpd_owner = false; web_admin_transport_stopped(SERVER);
assert(!s_server && !s_queued); assert(web_admin_transport_attach(SERVER) == ESP_OK);
httpd_owner = true;
ok("detach stops acceptance/submission; stale queued work no-ops before successful stop");
reset(); start(); admit(); queue_hook = detach_in_submit; tick();
assert(s_queued && !s_submitting && !s_accepting);
assert(web_admin_transport_detach(SERVER) == ESP_OK);
/* Simulate successful HTTPD stop: queued callbacks are discarded, context freed. */
queued_work = NULL; queued_argument = NULL; disconnected();
httpd_owner = false; web_admin_transport_stopped(SERVER);
assert(!s_server && !s_queued && web_admin_transport_attach(SERVER) == ESP_OK);
httpd_owner = true;
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(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;
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_SELF_CLOSE, 0) == ESP_OK);
assert(!console_live && !closes); httpd_owner = true;
ok("unsupported deferred action has no side effects; self-close notifier and drain guard");
reset(); start(); admit(); incoming.len = 0;
assert(frame_handler(&request) == ESP_OK);
assert(receive_headers == 1 && !fed_length && console_live && !s_slot.close_requested);
incoming.payload = bytes; incoming.len = sizeof(bytes);
assert(frame_handler(&request) == ESP_OK && receive_headers == 2);
assert(fed_length == sizeof(bytes) && !memcmp(fed, bytes, sizeof(bytes)));
ok("empty binary frame parsed once; following nonempty frame feeds normally");
free(s_payload); s_payload = NULL;
printf("%u groups passed\n", cases);
return EXIT_SUCCESS;
}
+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;
}
+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) {