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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(®istered[i], ®istered[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.')
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, ¤t) == 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) {
|
||||
|
||||
Reference in New Issue
Block a user