Move console payloads to lazy PSRAM storage

This commit is contained in:
2026-09-13 23:06:42 +02:00
parent 91267b371e
commit 4a53a21f31
12 changed files with 322 additions and 38 deletions
+37 -7
View File
@@ -138,8 +138,9 @@ static bool admin_ssh_console_dispatch_is_current(void) {
if (checks==revoke_check) owner_current=false;
return owner_current && user_database_principal_is_current(&actor, &current)==ESP_OK && current;
}
static int admin_command_gate_take(void) { return ESP_OK; }
static void admin_command_gate_give(void) {}
static bool gate_held;
static int admin_command_gate_take(void) { assert(!gate_held); gate_held=true; return ESP_OK; }
static void admin_command_gate_give(void) { assert(gate_held); gate_held=false; }
static int console_input_read_hidden(const char *prompt, uint8_t *out, size_t cap,
size_t min, size_t max, size_t *n) {
(void)prompt; (void)min; (void)max; assert(cap>=13); ++prompts;
@@ -158,7 +159,28 @@ static int ssh_transport_revoke_user(const uint8_t *u, size_t n) {
++ssh_revokes; assert(strlen(revoked_name)==n && !memcmp(u,revoked_name,n)); return notify_error;
}
/* Forbidden paths are traps rather than alternative implementations. */
static int show_users(const char *n) { (void)n; return 0; }
#define MALLOC_CAP_SPIRAM 1U
#define MALLOC_CAP_8BIT 2U
static bool fail_snapshot_alloc;
static unsigned snapshot_allocations;
static struct { uint64_t before; user_database_snapshot_t value; uint64_t after; } snapshot_memory;
static void *heap_caps_malloc(size_t n, unsigned caps) {
assert(gate_held && n==sizeof(snapshot_memory.value));
assert(caps==(MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
++snapshot_allocations;
if (fail_snapshot_alloc) return NULL;
snapshot_memory.before=snapshot_memory.after=UINT64_C(0xaabbccdd11223344);
memset(&snapshot_memory.value,0xa5,n);
return &snapshot_memory.value;
}
static unsigned user_registrations;
typedef struct { const char *command,*help,*hint; int (*func)(int,char **); void *argtable; } esp_console_cmd_t;
static int esp_console_cmd_register(const esp_console_cmd_t *c) {
assert(!strcmp(c->command,"user") && c->func); ++user_registrations; return ESP_OK;
}
static int mbedtls_base64_encode(uint8_t *out,size_t cap,size_t *n,const uint8_t *in,size_t len) {
(void)in; assert(cap>=4 && len==32); memcpy(out,"AAAA",4); *n=4; return 0;
}
static int add_key(const char *n) { (void)n; assert(!"key mutation"); return 1; }
static int add_key_parts(const char *n,const uint8_t *t,size_t tl,const uint8_t *b,size_t bl) {
@@ -190,17 +212,21 @@ db_names = ["constant_time_equal", "all_zero", "user_database_username_valid",
"user_database_clear_ssh_keys_current", "fill_principal", "user_database_authorize_ssh_public_key",
"initialize_dummy_verifier", "user_database_init", "user_database_recover_empty",
"user_database_get_snapshot"]
console_names = ["print_usage", "revoke_user_network_sessions", "read_password",
console_names = ["print_fingerprint", "print_user", "show_users", "print_usage", "revoke_user_network_sessions", "read_password",
"show_generated_password", "mutation_currentness", "add_user", "change_password",
"parse_key_index", "recover_database", "command_user_inner", "command_user"]
"parse_key_index", "recover_database", "command_user_inner", "command_user",
"user_console_register_commands"]
unit = prelude + header + "\n" + state + fakes
unit += "\n".join(function(db, n) for n in db_names)
unit += function(admin, "admin_ssh_console_web_user_command_allowed")
unit += console[console.index("static user_database_snapshot_t"):console.index("static void print_usage")]
unit += "\n".join(function(console, n) for n in console_names)
account_tests = (ROOT / "tests/admin_console_boundary/accounts.c").read_text()
key_tests = (ROOT / "tests/admin_console_boundary/account_keys.c").read_text()
account_tests = account_tests.replace('int main(void)', key_tests + '\nint main(void)')
account_tests = account_tests.replace(' typed_account_tests();', ' typed_key_tests();\n typed_account_tests();')
snapshot_tests = (ROOT / "tests/admin_console_boundary/psram_snapshot.c").read_text()
account_tests = account_tests.replace('int main(void)', snapshot_tests + '\nint main(void)')
account_tests = account_tests.replace(' typed_account_tests();', ' psram_snapshot_tests();\n typed_key_tests();\n typed_account_tests();')
assert ' typed_key_tests();' in account_tests
unit += account_tests
with tempfile.TemporaryDirectory(prefix="admin-accounts-") as directory:
@@ -209,9 +235,13 @@ with tempfile.TemporaryDirectory(prefix="admin-accounts-") as directory:
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-Wno-unused-variable",
str(path / "test.c"), str(IDF / "components/console/split_argv.c"),
"-lcrypto", "-o", str(path / "test")], check=True, timeout=30)
result = subprocess.run([str(path / "test")], check=True, timeout=10, capture_output=True, text=True)
result = subprocess.run([str(path / "test")], timeout=10, capture_output=True, text=True)
if result.returncode:
print(result.stderr)
result.check_returncode()
assert "test-password" not in result.stdout
assert "Generated password for" not in result.stdout
print("PASS: PSRAM snapshot allocation failure/retry/retention, status/list/show/unknown/unavailable, full wipe and bounds, gate ownership, mutation/recovery isolation")
print("PASS: empty initialization/recovery, unchanged v1 records, corrupt/unsupported fail-closed loads, first UART0 administrator and removed bootstrap commands")
print("PASS: canonical SSH keys: Ed25519/P256 parser and authorization, malformed/off-curve/truncated inputs, zero-wait fingerprints, stale ID/generation/recreation, duplicates/capacity, sparse indices, failed persistence and CLI parity (OpenSSL-backed curve/SHA adapters)")
print("PASS: operation-admission semantics: browser invalidated in derivation double before NVS; admitted add/password transactions still commit, only target is revoked, next command rejects; persistence failure still preserves live state (not precommit cancellation or real concurrency)")
+10 -6
View File
@@ -33,6 +33,7 @@ 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 const admin_console_owner_t s_admin_console_owner;
static void test_adapter(void)
{
admin_ssh_console_token_t token={ .slot_index=0, .session_id=7, .slot_generation=3 };
@@ -40,7 +41,7 @@ static void test_adapter(void)
.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_ssh_console_open_available(&token,&admin,&s_admin_console_owner)==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. */
@@ -82,16 +83,19 @@ static void test_adapter(void)
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;
/* 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);
admin_ssh_console_token_t other={ .session_id=8, .slot_generation=1 };
assert(admin_ssh_console_open_available(&other,&admin,&s_admin_console_owner)==ESP_OK);
assert(other.slot_index==0);
assert(admin_ssh_console_open_available(&token,&admin,&s_admin_console_owner)==ESP_OK);
assert(token.slot_index==1);
active.console_slot_index=token.slot_index; 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;
@@ -111,5 +115,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 snapshot/principal publication and wiping, adapter identity/drain checks, legacy admission and lifecycle action routing");
puts("PASS: actual SSH snapshot/principal publication and wiping, adapter identity/drain checks, shared-slot admission and lifecycle action routing");
}
+44
View File
@@ -0,0 +1,44 @@
static int run_ping(const char *count)
{
char *argv[]={"ping","localhost",(char *)count};
int result=network_console_execute(count?3:2,argv);
if(worker_started) { assert(!pthread_join(worker,NULL)); worker_started=false; }
return result;
}
int main(void)
{
_Static_assert(sizeof(ping_event_t)==200,"audited event payload changed");
assert(network_console_register_root_commands()==ESP_OK && registrations==3 && !allocations);
assert(run_ping("0")==1 && run_ping("21")==1 && run_ping("-1")==1);
assert(run_ping("999999999999999999999")==1 && !allocations);
resolve_fail=true; assert(run_ping(NULL)==1 && !allocations); resolve_fail=false;
fail_alloc=true; assert(run_ping(NULL)==1 && allocations==1 && !creations && !starts);
char *lookup[]={"nslookup","localhost"};
assert(network_console_execute(2,lookup)==0);
assert(network_console_register_root_commands()==ESP_OK && allocations==1);
fail_alloc=false; fail_queue=true;
assert(run_ping(NULL)==1 && allocations==2 && creations==1 && !starts);
void *retained=s_ping_queue_bytes;
fail_alloc=true; fail_queue=false; fail_new=true;
assert(run_ping(NULL)==1 && allocations==2 && creations==2 && !starts);
fail_new=false; fail_start=true;
assert(run_ping(NULL)==1 && deletes==1);
fail_start=false;
for(unsigned i=0;i<4;++i) {
burst=i%2==0;
assert(run_ping("20")==0);
assert(s_ping_queue_bytes==retained && allocations==2 && creations==2);
assert(queue.count==0);
assert(memory.before==UINT64_C(0x1122334455667788));
assert(memory.after==UINT64_C(0x1122334455667788));
}
/* Reset stale payload before a new session, preserving the end-event slot. */
ping_event_t stale={.kind=PING_EVENT_END,.profile_error=ESP_FAIL};
xQueueSend(s_ping_queue,&stale,0);
assert(run_ping("1")==0);
all_timeouts=true; assert(run_ping(NULL)==1); all_timeouts=false;
fail_profile=true; assert(run_ping(NULL)==1); fail_profile=false;
fail_delete=true; assert(run_ping(NULL)==1); fail_delete=false;
assert(run_ping(NULL)==0 && resets>=10 && allocations==2);
assert(s_ping_queue==&queue && s_ping_queue_bytes==retained);
}
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Production ping command/callbacks with bounded pthread queue and SDK doubles."""
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
s = (ROOT / 'src/network_console.c').read_text()
def function(name):
start = s.rfind('\n', 0, s.index(name + '(')) + 1
return s[start:s.index('\n}', start) + 2] + '\n'
prelude = (ROOT / 'tests/admin_console_boundary/psram_ping_fakes.c').read_text()
unit = prelude + '\n'
unit += s[s.index('#define PING_DEFAULT_COUNT'):s.index('/* This covers')]
unit += function('print_command_usage') + function('parse_bounded_u32')
unit += s[s.index('typedef enum {\n PING_EVENT_LINE'):s.index('static bool socket_addresses_equal')]
unit += function('network_console_is_command') + function('network_console_execute')
unit += function('network_console_register_root_commands')
unit += (ROOT / 'tests/admin_console_boundary/psram_ping.c').read_text()
with tempfile.TemporaryDirectory(prefix='psram-ping-') as d:
p = Path(d)
(p / 'test.c').write_text(unit)
subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror', '-pthread',
'-g', str(p / 'test.c'), '-o', str(p / 'test')],
check=True, timeout=30)
subprocess.run([str(p / 'test')], check=True, timeout=20)
print('PASS: production ping allocation/create-queue/session failures, retry/retention/reset, bounds, asynchronous success/timeout/end callbacks and registration isolation (host doubles)')
@@ -0,0 +1,119 @@
/* Deterministic SDK boundaries; queue data movement uses the supplied payload. */
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <pthread.h>
typedef int esp_err_t;
enum { ESP_OK, ESP_FAIL };
#define MALLOC_CAP_SPIRAM 1U
#define MALLOC_CAP_8BIT 2U
#define portMAX_DELAY UINT32_MAX
#define pdTRUE 1
static bool fail_alloc, fail_queue, fail_new, fail_start, fail_delete, fail_profile, resolve_fail;
static unsigned allocations, creations, resets, registrations, starts, deletes;
static struct { uint64_t before; uint8_t bytes[4200]; uint64_t after; } memory;
static void *heap_caps_malloc(size_t n, unsigned caps) {
++allocations; assert(n==sizeof(memory.bytes));
assert(caps==(MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
if (fail_alloc) return NULL;
memory.before=memory.after=UINT64_C(0x1122334455667788);
return memory.bytes;
}
typedef struct { unsigned marker; } StaticQueue_t;
typedef struct { uint8_t *bytes; size_t length, size, read, count; } queue_t;
typedef queue_t *QueueHandle_t;
static queue_t queue;
static pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t ready=PTHREAD_COND_INITIALIZER;
static QueueHandle_t xQueueCreateStatic(size_t length,size_t size,uint8_t *bytes,StaticQueue_t *control) {
++creations; assert(length==21 && size==200 && bytes==memory.bytes && control);
if (fail_queue) return NULL;
queue=(queue_t){.bytes=bytes,.length=length,.size=size}; return &queue;
}
static int xQueueReset(QueueHandle_t q) {
pthread_mutex_lock(&mutex); ++resets; q->read=q->count=0;
pthread_mutex_unlock(&mutex); return pdTRUE;
}
static int xQueueSend(QueueHandle_t q,const void *event,unsigned wait) {
assert(wait==0); pthread_mutex_lock(&mutex); assert(q->count<q->length);
memcpy(q->bytes+((q->read+q->count)%q->length)*q->size,event,q->size);
++q->count; pthread_cond_signal(&ready); pthread_mutex_unlock(&mutex); return pdTRUE;
}
static int xQueueReceive(QueueHandle_t q,void *event,unsigned wait) {
assert(wait==portMAX_DELAY); pthread_mutex_lock(&mutex);
while (!q->count) pthread_cond_wait(&ready,&mutex);
memcpy(event,q->bytes+q->read*q->size,q->size);
q->read=(q->read+1)%q->length; --q->count;
pthread_mutex_unlock(&mutex); return pdTRUE;
}
typedef uint32_t ip_addr_t;
#define IP_IS_V4(p) ((void)(p), 1)
static char *ipaddr_ntoa_r(const ip_addr_t *ip,char *out,int n) {
(void)ip; assert(n>=10); strcpy(out,"127.0.0.1"); return out;
}
static size_t strlcpy(char *out,const char *in,size_t n) {
size_t len=strlen(in); if(n) { size_t copy=len<n-1?len:n-1; memcpy(out,in,copy); out[copy]=0; } return len;
}
static int resolve_ping_target(const char *host,ip_addr_t *ip,char *out,size_t n) {
(void)host; *ip=0; ipaddr_ntoa_r(ip,out,(int)n); return resolve_fail;
}
static const char *esp_err_to_name(int e) { (void)e; return "injected"; }
typedef void *esp_ping_handle_t;
typedef struct { uint32_t count; ip_addr_t target_addr; } esp_ping_config_t;
#define ESP_PING_DEFAULT_CONFIG() ((esp_ping_config_t){0})
typedef struct {
void *cb_args;
void (*on_ping_success)(esp_ping_handle_t,void *);
void (*on_ping_timeout)(esp_ping_handle_t,void *);
void (*on_ping_end)(esp_ping_handle_t,void *);
} esp_ping_callbacks_t;
enum { ESP_PING_PROF_SEQNO, ESP_PING_PROF_SIZE, ESP_PING_PROF_TIMEGAP,
ESP_PING_PROF_IPADDR, ESP_PING_PROF_TTL, ESP_PING_PROF_REQUEST,
ESP_PING_PROF_REPLY, ESP_PING_PROF_DURATION };
static esp_ping_callbacks_t callbacks;
static uint32_t probes, replies;
static uint16_t sequence;
static pthread_t worker;
static bool worker_started, all_timeouts, burst;
static int esp_ping_get_profile(esp_ping_handle_t h,int profile,void *out,size_t n) {
assert(h==(void *)1);
if (fail_profile) return ESP_FAIL;
if (profile==ESP_PING_PROF_SEQNO) { assert(n==2); memcpy(out,&sequence,n); }
else if(profile==ESP_PING_PROF_TTL) { assert(n==1); *(uint8_t *)out=64; }
else { assert(n==4); uint32_t value=profile==ESP_PING_PROF_REQUEST?probes:
profile==ESP_PING_PROF_REPLY?replies:1; memcpy(out,&value,n); }
return ESP_OK;
}
static int esp_ping_delete_session(esp_ping_handle_t h) {
assert(h==(void *)1); ++deletes; return fail_delete?ESP_FAIL:ESP_OK;
}
static void *produce(void *unused) {
(void)unused;
for(sequence=1;sequence<=probes;++sequence) {
if(all_timeouts || sequence%2==0) callbacks.on_ping_timeout((void *)1,callbacks.cb_args);
else callbacks.on_ping_success((void *)1,callbacks.cb_args);
}
callbacks.on_ping_end((void *)1,callbacks.cb_args); return NULL;
}
static int esp_ping_new_session(const esp_ping_config_t *c,const esp_ping_callbacks_t *cb,esp_ping_handle_t *h) {
assert(queue.bytes==memory.bytes && c->count>=1 && c->count<=20);
if(fail_new) return ESP_FAIL;
callbacks=*cb; probes=c->count; replies=all_timeouts?0:(probes+1)/2; *h=(void *)1; return ESP_OK;
}
static int esp_ping_start(esp_ping_handle_t h) {
assert(h==(void *)1); ++starts; if(fail_start) return ESP_FAIL;
assert(!pthread_create(&worker,NULL,produce,NULL)); worker_started=true;
if(burst) { assert(!pthread_join(worker,NULL)); worker_started=false; assert(queue.count==probes+1); }
return ESP_OK;
}
static int execute_nslookup(int argc,char **argv) { (void)argc; (void)argv; return 0; }
static int execute_traceroute(int argc,char **argv) { (void)argc; (void)argv; return 0; }
typedef struct { const char *command,*help,*hint; int (*func)(int,char **); void *argtable; } esp_console_cmd_t;
static int esp_console_cmd_register(const esp_console_cmd_t *c) {
assert(c->func); ++registrations; return ESP_OK;
}
@@ -0,0 +1,42 @@
/* Actual canonical handlers and database projection; included by accounts.py. */
static void psram_snapshot_tests(void)
{
reset(); remote=web=false;
fail_snapshot_alloc=true;
assert(user_console_register_commands()==ESP_OK && user_registrations==1);
assert(!snapshot_allocations);
assert(run("user status")==1);
assert(run("user list")==1);
assert(run("user show other")==1);
assert(snapshot_allocations==3 && s_user_snapshot==NULL && !gate_held);
assert(run("user add newcomer user")==0);
assert(snapshot_allocations==3);
s_initialized=false; s_mutex=NULL; storage_test=true;
assert(run("user recover --force")==0);
storage_test=false;
assert(snapshot_allocations==3);
reset(); remote=web=false;
fail_snapshot_alloc=false;
assert(run("user status")==0 && snapshot_allocations==4);
assert(all_zero(s_user_snapshot,sizeof(*s_user_snapshot)));
void *retained=s_user_snapshot;
fail_snapshot_alloc=true;
for (unsigned i=0;i<3;++i) {
assert(run("user")==0);
assert(all_zero(s_user_snapshot,sizeof(*s_user_snapshot)));
assert(run("user list")==0);
assert(all_zero(s_user_snapshot,sizeof(*s_user_snapshot)));
assert(run("user show other")==0);
assert(all_zero(s_user_snapshot,sizeof(*s_user_snapshot)));
assert(run("user show missing")==1);
assert(all_zero(s_user_snapshot,sizeof(*s_user_snapshot)));
s_initialized=false;
memset(s_user_snapshot,0xa5,sizeof(*s_user_snapshot));
assert(run("user status")==1);
assert(all_zero(s_user_snapshot,sizeof(*s_user_snapshot)));
s_initialized=true;
assert(s_user_snapshot==retained && snapshot_allocations==4 && !gate_held);
assert(snapshot_memory.before==UINT64_C(0xaabbccdd11223344));
assert(snapshot_memory.after==UINT64_C(0xaabbccdd11223344));
}
}