Implement HTTPS lifecycle and reboot controls
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
/* Actual typed handlers, auth/parser/store; controlled HTTPD/dispatcher/owner boundaries. */
|
||||
#include "../../src/web_lifecycle_settings.c"
|
||||
static bool on_dispatcher, on_callback, on_handler, work_fail, queue_fail, invalidate_during_owner;
|
||||
static uint32_t queued_id;
|
||||
static unsigned mutations, work_calls, submit_calls, snapshots;
|
||||
static void (*work)(void *);
|
||||
static void *work_arg;
|
||||
static esp_err_t owner_error;
|
||||
static web_server_management_snapshot_t owner_snapshot = {7, true, false, true};
|
||||
esp_err_t web_server_get_management_snapshot(web_server_management_snapshot_t *out) {
|
||||
assert(!host_lock_depth && on_handler); ++snapshots; *out = owner_snapshot; return owner_error;
|
||||
}
|
||||
static esp_err_t owner_action(unsigned action, uint32_t generation) {
|
||||
assert(on_dispatcher && !on_handler && !on_callback && !host_lock_depth);
|
||||
assert(generation == 7 && s_operation.action == action && s_operation.state == EXECUTING);
|
||||
++mutations;
|
||||
if (invalidate_during_owner) {
|
||||
web_session_store_invalidate(s_operation.session);
|
||||
web_lifecycle_settings_stopped(&server);
|
||||
web_lifecycle_settings_execute(s_operation.id); /* duplicate during admission */
|
||||
assert(s_operation.state == EXECUTING);
|
||||
}
|
||||
return owner_error;
|
||||
}
|
||||
esp_err_t web_server_stop_current(uint32_t generation) { return owner_action(0, generation); }
|
||||
esp_err_t web_server_restart_current(uint32_t generation) { return owner_action(1, generation); }
|
||||
esp_err_t web_server_reboot_current(uint32_t generation) { return owner_action(2, generation); }
|
||||
esp_err_t httpd_queue_work(httpd_handle_t handle, void (*callback)(void *), void *argument) {
|
||||
assert(on_handler && !on_dispatcher && !on_callback && !host_lock_depth && handle == &server);
|
||||
assert(!strcmp(response_status, "202 Accepted") && !send_fail && !aux.remaining_len && sends);
|
||||
assert(strstr(output, "pending") && s_ack_id == (uint32_t)(uintptr_t)argument);
|
||||
++work_calls;
|
||||
if (work_fail) return ESP_FAIL;
|
||||
assert(!work); work = callback; work_arg = argument; return ESP_OK;
|
||||
}
|
||||
esp_err_t admin_ssh_console_submit_lifecycle_settings(uint32_t id) {
|
||||
assert(id && on_callback && !on_dispatcher && !on_handler && !host_lock_depth); ++submit_calls;
|
||||
if (queue_fail) return ESP_FAIL;
|
||||
queued_id = id; return ESP_OK;
|
||||
}
|
||||
static void lifecycle_begin(const issued_t *identity, const char *body, bool snapshot_read) {
|
||||
begin(snapshot_read ? "/api/settings/lifecycle" : "/api/settings/lifecycle-operation", body ? HTTP_POST : HTTP_GET, body);
|
||||
same_origin(); if (body) add("Content-Type", "application/json");
|
||||
if (identity) {
|
||||
char cookie[100]; snprintf(cookie, sizeof(cookie), "__Host-sak-session=%s", identity->token);
|
||||
add("Cookie", cookie); if (body) add("X-CSRF-Token", identity->view.csrf);
|
||||
}
|
||||
}
|
||||
static void expect_lifecycle(const char *status, bool snapshot_read) {
|
||||
unsigned before = mutations, submitted = submit_calls;
|
||||
on_handler = true;
|
||||
esp_err_t error = snapshot_read ? web_lifecycle_settings_handler(&req) : web_lifecycle_operation_handler(&req);
|
||||
on_handler = false;
|
||||
assert(error == (send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
|
||||
assert(!strcmp(response_status, status) && mutations == before && submit_calls == submitted);
|
||||
assert(strlen(output) < (snapshot_read ? 128 : 96)); zero(scratch, sizeof(scratch));
|
||||
assert(!strstr(output, "principal") && !strstr(output, "csrf") && !strstr(output, "password"));
|
||||
}
|
||||
static const char *stop_body = "{\"action\":\"stop\",\"generation\":7}";
|
||||
static void lifecycle_submit(const issued_t *identity, const char *body) {
|
||||
lifecycle_begin(identity, body, false); expect_lifecycle("202 Accepted", false);
|
||||
}
|
||||
static void owner_callback(void) {
|
||||
assert(work); void (*callback)(void *) = work; void *argument = work_arg; work = NULL;
|
||||
on_callback = true; callback(argument); on_callback = false;
|
||||
}
|
||||
static void dispatch(void) { on_dispatcher = true; web_lifecycle_settings_execute(queued_id); on_dispatcher = false; }
|
||||
static void late_validation(void) { now += 30000000; }
|
||||
static void slow_send(void) {
|
||||
assert(on_handler && !work && !s_operation.queued && s_ack_id == s_operation.id);
|
||||
unsigned before = mutations;
|
||||
web_lifecycle_settings_execute(s_operation.id);
|
||||
assert(mutations == before && s_operation.state == PENDING);
|
||||
now += 2000000; /* Synchronous send returns only at the ACK deadline. */
|
||||
}
|
||||
static void lifecycle_tests(void) {
|
||||
auth_reset(); issued_t admin = mint(&alice), user = mint(&bob), other = mint(&alice);
|
||||
receive_fragment = 64;
|
||||
for (unsigned snapshot_read = 0; snapshot_read < 2; ++snapshot_read) {
|
||||
lifecycle_begin(NULL, NULL, snapshot_read); expect_lifecycle("401 Unauthorized", snapshot_read);
|
||||
lifecycle_begin(&user, NULL, snapshot_read); expect_lifecycle("403 Forbidden", snapshot_read);
|
||||
}
|
||||
lifecycle_begin(&user, stop_body, false); expect_lifecycle("403 Forbidden", false);
|
||||
for (unsigned mode = 0; mode < 10; ++mode) {
|
||||
lifecycle_begin(&admin, stop_body, false);
|
||||
if (mode == 0) req.content_len = aux.remaining_len = 257;
|
||||
if (mode == 1) req.uri = "/api/settings/lifecycle-operation?x=1";
|
||||
if (mode == 2) req.method = HTTP_GET;
|
||||
if (mode == 3) add("X-CSRF-Token", "duplicate");
|
||||
if (mode == 4) add("Origin", "https://evil.example");
|
||||
if (mode == 5) add("Transfer-Encoding", "chunked");
|
||||
if (mode == 6) add("Content-Type", "text/plain");
|
||||
if (mode == 7) add("Sec-Fetch-Site", "cross-site");
|
||||
if (mode == 8) { begin("/api/settings/lifecycle-operation", HTTP_POST, stop_body); add("Host", "device.example"); }
|
||||
if (mode == 9) { stale_user = alice.user_id; }
|
||||
on_handler = true; (void)web_lifecycle_operation_handler(&req); on_handler = false; stale_user = 0;
|
||||
assert(response_status[0] == '4' && !s_next_id && !mutations && !work_calls);
|
||||
}
|
||||
/* Stale validation may have invalidated the initial login. */
|
||||
auth_reset(); admin = mint(&alice); other = mint(&alice);
|
||||
puts("PASS lifecycle backend admin/original-cookie/Origin/CSRF and body/query/framing policy");
|
||||
const char *invalid[] = {"{}", "[]", "{\"action\":\"stop\"}",
|
||||
"{\"action\":\"stop\",\"generation\":0}", "{\"action\":\"stop\",\"generation\":4294967295}",
|
||||
"{\"action\":\"stop\",\"generation\":4294967296}", "{\"action\":\"stop\",\"generation\":07}",
|
||||
"{\"action\":\"stop\",\"generation\":7.0}", "{\"action\":\"stop\",\"generation\":7e0}",
|
||||
"{\"action\":\"stop\",\"generation\":-7}", "{\"action\":\"stop\",\"generation\":\"7\"}",
|
||||
"{\"action\":\"stop\",\"action\":\"stop\"}", "{\"action\":\"stop\",\"generation\":7,\"target\":0}",
|
||||
"{\"action\":\"reset\",\"generation\":7}", "{\"action\":\"certificate-rotate\",\"generation\":7}"};
|
||||
for (unsigned i = 0; i < sizeof(invalid)/sizeof(*invalid); ++i) {
|
||||
lifecycle_begin(&admin, invalid[i], false); expect_lifecycle("400 Bad Request", false);
|
||||
}
|
||||
lifecycle_operation_t parsed = {0};
|
||||
for (size_t n = 0; n < strlen(stop_body); ++n) assert(!parse(stop_body, n, &parsed));
|
||||
assert(parse(stop_body, strlen(stop_body), &parsed)); assert(!parse(stop_body, strlen(stop_body) + 1, &parsed));
|
||||
const char *reordered = " { \"generation\":4294967294, \"action\":\"restart\" } ";
|
||||
assert(parse(reordered, strlen(reordered), &parsed));
|
||||
receive_fragment = 1; lifecycle_begin(&admin, stop_body, false); expect_lifecycle("400 Bad Request", false);
|
||||
assert(body_offset == 4); receive_fragment = 64;
|
||||
char full[257]; memset(full, ' ', 256); memcpy(full, stop_body, strlen(stop_body)); full[256] = 0;
|
||||
lifecycle_submit(&admin, full); assert(body_offset == 256 && !s_operation.queued);
|
||||
lifecycle_begin(&other, stop_body, false); expect_lifecycle("503 Service Unavailable", false);
|
||||
lifecycle_begin(&other, NULL, false); expect_lifecycle("200 OK", false); assert(strstr(output, "idle"));
|
||||
uint32_t first = s_operation.id;
|
||||
web_lifecycle_settings_execute(first); assert(!mutations); owner_callback(); dispatch();
|
||||
assert(mutations == 1 && s_operation.state == OK); zero(&s_operation.principal, sizeof(s_operation.principal));
|
||||
dispatch(); on_callback = true; ack_handoff((void *)(uintptr_t)first); on_callback = false;
|
||||
assert(mutations == 1 && submit_calls == 1);
|
||||
puts("PASS lifecycle strict parser/256-byte/four-receive bounds; ACK before ID dispatch and duplicate fencing");
|
||||
for (unsigned mode = 0; mode < 2; ++mode) {
|
||||
owner_error = mode ? ESP_FAIL : ESP_OK;
|
||||
lifecycle_begin(&admin, NULL, true); expect_lifecycle(mode ? "503 Service Unavailable" : "200 OK", true);
|
||||
if (!mode) assert(!strcmp(output, "{\"generation\":7,\"running\":true,\"transitioning\":false,\"controllable\":true}"));
|
||||
}
|
||||
owner_error = ESP_OK;
|
||||
puts("PASS lifecycle bounded scalar snapshot and optional owner failure isolation");
|
||||
unsigned before = mutations, submitted = submit_calls;
|
||||
work_fail = true; lifecycle_submit(&admin, stop_body); work_fail = false;
|
||||
assert(s_operation.state == CANCELLED && !s_ack_id && !work); zero(&s_operation.principal, sizeof(s_operation.principal));
|
||||
send_fail = true; unsigned calls = work_calls; lifecycle_submit(&admin, stop_body); send_fail = false;
|
||||
assert(work_calls == calls && !s_ack_id && s_operation.state == CANCELLED);
|
||||
queue_fail = true; lifecycle_submit(&admin, stop_body); owner_callback(); queue_fail = false; dispatch();
|
||||
assert(s_operation.state == CANCELLED && mutations == before && submit_calls == submitted + 1);
|
||||
puts("PASS lifecycle response/HTTPD queue/dispatcher queue failures cancel only before admission without retry");
|
||||
lifecycle_submit(&admin, stop_body); uint32_t late = s_ack_id; now += 2000000;
|
||||
lifecycle_begin(&admin, NULL, false); expect_lifecycle("200 OK", false); assert(strstr(output, "cancelled") && s_ack_id == late);
|
||||
for (unsigned i = 0; i < 5; ++i) { lifecycle_begin(&admin, stop_body, false); expect_lifecycle("503 Service Unavailable", false); }
|
||||
assert(s_ack_id == late); owner_callback(); assert(!s_ack_id && mutations == before);
|
||||
lifecycle_submit(&admin, stop_body); on_callback = true; ack_handoff((void *)(uintptr_t)late); on_callback = false;
|
||||
assert(s_ack_id == s_operation.id); owner_callback(); now += 30000000; dispatch(); assert(s_operation.state == CANCELLED);
|
||||
lifecycle_submit(&admin, stop_body); owner_callback(); db_hook = late_validation; dispatch(); assert(s_operation.state == CANCELLED && mutations == before);
|
||||
puts("PASS lifecycle ACK expiry/lost-callback single reservation, late callback ABA and dequeue/post-validation deadlines");
|
||||
for (unsigned mode = 0; mode < 5; ++mode) {
|
||||
auth_reset(); admin = mint(&alice); lifecycle_submit(&admin, stop_body); owner_callback();
|
||||
if (mode == 0) web_session_store_invalidate(admin.view.id);
|
||||
if (mode == 1) db_fail = true;
|
||||
if (mode == 2) stale_user = alice.user_id;
|
||||
if (mode == 3) now = admin.view.expires_at_us;
|
||||
if (mode == 4) { hook_id = admin.view.id; db_hook = invalidate_hook; }
|
||||
dispatch(); db_fail = false; stale_user = 0;
|
||||
assert(s_operation.state == CANCELLED && mutations == before);
|
||||
}
|
||||
auth_reset(); admin = mint(&alice); lifecycle_submit(&admin, stop_body); late = s_ack_id;
|
||||
web_cookie_auth_stop(); web_lifecycle_settings_stopped(&server); assert(web_cookie_auth_start() == ESP_OK);
|
||||
admin = mint(&alice); owner_callback(); assert(!s_ack_id && s_operation.state == CANCELLED && mutations == before);
|
||||
lifecycle_submit(&admin, stop_body); on_callback = true; ack_handoff((void *)(uintptr_t)late); on_callback = false;
|
||||
assert(s_ack_id == s_operation.id); owner_callback(); dispatch(); assert(s_operation.state == OK);
|
||||
puts("PASS lifecycle original-login expiry/revocation/validation races and shutdown/restart same-owner ABA");
|
||||
for (unsigned action = 0; action < 3; ++action) {
|
||||
auth_reset(); admin = mint(&alice); char body[80];
|
||||
snprintf(body, sizeof(body), "{\"action\":\"%s\",\"generation\":7}", s_actions[action]);
|
||||
invalidate_during_owner = true; lifecycle_submit(&admin, body); owner_callback(); before = mutations; dispatch();
|
||||
invalidate_during_owner = false;
|
||||
assert(s_operation.state == OK && mutations == before + 1);
|
||||
}
|
||||
auth_reset(); admin = mint(&alice);
|
||||
owner_error = ESP_ERR_INVALID_STATE; lifecycle_submit(&admin, stop_body); owner_callback(); dispatch();
|
||||
assert(s_operation.state == FAILED); owner_error = ESP_OK;
|
||||
puts("PASS lifecycle all actions dispatcher-only; admitted revocation is not cancellation; failure uncertainty");
|
||||
before = mutations;
|
||||
send_hook = slow_send; lifecycle_submit(&admin, stop_body);
|
||||
assert(work && !s_operation.queued); owner_callback(); dispatch();
|
||||
assert(s_operation.state == CANCELLED && mutations == before);
|
||||
/* Neither callback nor dispatcher may dereference the old request, its
|
||||
* connection or its body after the handler returns. Reuse all three. */
|
||||
lifecycle_submit(&admin, stop_body);
|
||||
lifecycle_begin(&admin, NULL, true); expect_lifecycle("200 OK", true);
|
||||
memset(&req, 0xa5, sizeof(req)); memset(&aux, 0xa5, sizeof(aux));
|
||||
memset(scratch, 0xa5, sizeof(scratch)); request_body = NULL;
|
||||
owner_callback(); dispatch();
|
||||
assert(s_operation.state == OK && mutations == before + 1);
|
||||
s_next_id = UINT32_MAX; lifecycle_begin(&admin, stop_body, false); expect_lifecycle("503 Service Unavailable", false);
|
||||
puts("PASS lifecycle send-return expiry, request/connection storage reuse after disconnect, and nonwrapping operation ID exhaustion");
|
||||
}
|
||||
@@ -61,6 +61,7 @@ serial_settings = "--serial-settings" in sys.argv
|
||||
accounts = "--accounts" in sys.argv
|
||||
broker = "--broker" in sys.argv
|
||||
ssh_settings = "--ssh" in sys.argv
|
||||
lifecycle = "--lifecycle" in sys.argv
|
||||
display = "--display" in sys.argv
|
||||
if display:
|
||||
HEADERS["nvs_flash.h"] = '#pragma once\n#include "esp_err.h"\nesp_err_t nvs_flash_init(void);\n'
|
||||
@@ -251,6 +252,14 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
*(["-DHOST_DISPLAY"] if display else []),
|
||||
*(["-DHOST_BROKER"] if broker else []),
|
||||
*(["-DHOST_SSH_SETTINGS"] if ssh_settings else []),
|
||||
*(["-DHOST_LIFECYCLE"] if lifecycle 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)
|
||||
if lifecycle:
|
||||
guard = subprocess.run(["cc", "-E", "-DCONFIG_HTTPD_QUEUE_WORK_BLOCKING=1",
|
||||
"-I" + str(tmp), "-I" + str(ROOT / "src"),
|
||||
str(ROOT / "src/web_lifecycle_settings.c")], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE, timeout=30)
|
||||
assert guard.returncode and b"Lifecycle ACK handoff requires nonblocking" in guard.stderr
|
||||
print("PASS lifecycle compile-time rejection of blocking HTTPD work submission")
|
||||
|
||||
@@ -31,6 +31,7 @@ static unsigned fail_header, setter_calls;
|
||||
static bool send_fail, recv_fail;
|
||||
static size_t receive_fragment = 7;
|
||||
static void (*password_hook)(void);
|
||||
static void (*send_hook)(void);
|
||||
static char response_status[48];
|
||||
static struct httpd_req_aux aux;
|
||||
static httpd_req_t req;
|
||||
@@ -54,6 +55,7 @@ esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body) {
|
||||
snprintf(cookie_values[cookie_count++], 200, "%s", response_headers[i].value);
|
||||
}
|
||||
}
|
||||
if (send_hook) { void (*hook)(void) = send_hook; send_hook = NULL; hook(); }
|
||||
return send_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
int httpd_req_recv(httpd_req_t *r, char *out, size_t size) {
|
||||
@@ -152,6 +154,9 @@ static void auth_reset(void) {
|
||||
#ifdef HOST_SSH_SETTINGS
|
||||
#include "ssh_settings_test.c"
|
||||
#endif
|
||||
#ifdef HOST_LIFECYCLE
|
||||
#include "lifecycle_test.c"
|
||||
#endif
|
||||
|
||||
int main(void) {
|
||||
assert(store_tests() == 0); auth_reset();
|
||||
@@ -320,6 +325,9 @@ int main(void) {
|
||||
#endif
|
||||
#ifdef HOST_SSH_SETTINGS
|
||||
ssh_settings_tests();
|
||||
#endif
|
||||
#ifdef HOST_LIFECYCLE
|
||||
lifecycle_tests();
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user