Add typed account and password settings

- Add admin account list, create, role, delete, and password workflows
- Execute identity-checked mutations through the existing dispatcher
- Bound queued credential lifetime and wipe transient secrets
- Add explicit password generation with saved-value acknowledgement
- Handle self-revocation and uncertain outcomes without automatic
  retries
- Register optional account routes without disrupting terminal
  transports
- Expand host regressions and document contracts and pending target
  checks

Validated host suites and pio run; hardware validation remains pending.
This commit is contained in:
2026-09-08 09:27:02 +02:00
parent 42548f6334
commit 94433ef975
30 changed files with 1864 additions and 48 deletions
+1
View File
@@ -32,6 +32,7 @@ idf_component_register(
"web_security.c"
"web_serial_transport.c"
"web_serial_settings.c"
"web_account_settings.c"
"web_admin_tickets.c"
"web_admin_transport.c"
"web_assets_data.c"
+16 -2
View File
@@ -17,6 +17,7 @@
#include "secure_random.h"
#include "user_database.h"
#include "web_serial_settings.h"
#include "web_account_settings.h"
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
@@ -83,6 +84,7 @@ typedef enum {
ADMIN_REQUEST_UART0,
ADMIN_REQUEST_DEFERRED,
ADMIN_REQUEST_SERIAL_SETTINGS,
ADMIN_REQUEST_ACCOUNT_SETTINGS,
} admin_request_origin_t;
typedef struct {
@@ -94,6 +96,7 @@ typedef struct {
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
admin_control_request_t deferred;
uint32_t serial_settings_id;
uint32_t account_settings_id;
};
} admin_request_t;
@@ -659,6 +662,16 @@ esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id)
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id)
{
taskENTER_CRITICAL(&s_lock);
bool ready = s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready || !id) return ESP_ERR_INVALID_STATE;
admin_request_t request = {.origin = ADMIN_REQUEST_ACCOUNT_SETTINGS, .account_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
static void worker_task(void *context)
{
(void)context;
@@ -667,8 +680,9 @@ static void worker_task(void *context)
if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) {
continue;
}
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) {
web_serial_settings_execute(request.serial_settings_id);
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) {
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) web_serial_settings_execute(request.serial_settings_id);
else web_account_settings_execute(request.account_settings_id);
secure_wipe(&request, sizeof(request));
continue;
}
+2 -1
View File
@@ -14,8 +14,9 @@
extern "C" {
#endif
/* Nonblocking typed Serial-settings admission to the canonical dispatcher. */
/* Nonblocking typed settings admission to the canonical dispatcher. */
esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id);
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
+90 -12
View File
@@ -295,7 +295,7 @@ static esp_err_t set_record_password(stored_user_t *user,
return error;
}
static esp_err_t generate_password(user_database_generated_password_t *generated)
esp_err_t user_database_generate_password_value(user_database_generated_password_t *generated)
{
if (generated == NULL) {
return ESP_ERR_INVALID_ARG;
@@ -788,6 +788,25 @@ esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot)
return ESP_OK;
}
esp_err_t user_database_get_accounts(user_database_accounts_t *accounts)
{
if (accounts == NULL) return ESP_ERR_INVALID_ARG;
memset(accounts, 0, sizeof(*accounts));
if (!s_initialized || s_mutex == NULL) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
for (size_t i = 0; i < USER_DATABASE_MAX_USERS; ++i) {
const stored_user_t *user = &s_database.users[i];
if (!user->active) continue;
user_database_account_t *out = &accounts->users[accounts->count++];
out->user_id = user->user_id;
out->auth_generation = user->auth_generation;
out->role = (user_role_t)user->role;
memcpy(out->username, user->username, user->username_length);
}
xSemaphoreGive(s_mutex);
return ESP_OK;
}
static void fill_principal(const stored_user_t *user, user_auth_method_t method,
user_principal_t *principal)
{
@@ -986,7 +1005,7 @@ esp_err_t user_database_create_generated(
const uint8_t *username, size_t username_length, user_role_t role,
user_database_generated_password_t *generated_password)
{
esp_err_t error = generate_password(generated_password);
esp_err_t error = user_database_generate_password_value(generated_password);
if (error == ESP_OK) {
error = user_database_create(username, username_length, role,
generated_password->password,
@@ -1037,7 +1056,7 @@ esp_err_t user_database_bootstrap_admin(const uint8_t *password,
esp_err_t user_database_bootstrap_admin_generated(
user_database_generated_password_t *generated_password)
{
esp_err_t error = generate_password(generated_password);
esp_err_t error = user_database_generate_password_value(generated_password);
if (error == ESP_OK) {
error = user_database_bootstrap_admin(generated_password->password,
generated_password->password_length);
@@ -1062,14 +1081,26 @@ static esp_err_t mutate_user_begin(const uint8_t *username, size_t username_leng
return ESP_OK;
}
esp_err_t user_database_delete(const uint8_t *username, size_t username_length)
static bool target_matches_locked(const uint8_t *username, size_t length,
const user_database_account_t *expected)
{
if (!expected) return true;
int index = find_user(&s_database, username, length);
return index >= 0 && expected->user_id != 0 && expected->auth_generation != 0 &&
s_database.users[index].user_id == expected->user_id &&
s_database.users[index].auth_generation == expected->auth_generation;
}
static esp_err_t delete_user(const uint8_t *username, size_t username_length,
const user_database_account_t *expected)
{
if (!s_initialized || s_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
int index;
esp_err_t error = mutate_user_begin(username, username_length, &index);
esp_err_t error = target_matches_locked(username, username_length, expected)
? mutate_user_begin(username, username_length, &index) : ESP_ERR_NOT_FOUND;
if (error == ESP_OK) {
const stored_user_t *user = &s_database.users[index];
bool protected_migrated_admin =
@@ -1090,8 +1121,8 @@ esp_err_t user_database_delete(const uint8_t *username, size_t username_length)
return error;
}
esp_err_t user_database_set_role(const uint8_t *username, size_t username_length,
user_role_t role)
static esp_err_t set_role(const uint8_t *username, size_t username_length,
user_role_t role, const user_database_account_t *expected)
{
if (!s_initialized || s_mutex == NULL ||
(role != USER_ROLE_USER && role != USER_ROLE_ADMIN)) {
@@ -1099,7 +1130,8 @@ esp_err_t user_database_set_role(const uint8_t *username, size_t username_length
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
int index;
esp_err_t error = mutate_user_begin(username, username_length, &index);
esp_err_t error = target_matches_locked(username, username_length, expected)
? mutate_user_begin(username, username_length, &index) : ESP_ERR_NOT_FOUND;
if (error == ESP_OK) {
stored_user_t *user = &s_candidate->users[index];
if (user->role == role) {
@@ -1125,8 +1157,37 @@ esp_err_t user_database_set_role(const uint8_t *username, size_t username_length
return error;
}
esp_err_t user_database_set_password(const uint8_t *username, size_t username_length,
const uint8_t *password, size_t password_length)
esp_err_t user_database_delete(const uint8_t *username, size_t length)
{
return delete_user(username, length, NULL);
}
esp_err_t user_database_set_role(const uint8_t *username, size_t length, user_role_t role)
{
return set_role(username, length, role, NULL);
}
esp_err_t user_database_delete_current(const user_database_account_t *expected)
{
if (!expected) return ESP_ERR_INVALID_ARG;
size_t length = strnlen(expected->username, sizeof(expected->username));
if (!user_database_username_valid((const uint8_t *)expected->username, length))
return ESP_ERR_INVALID_ARG;
return delete_user((const uint8_t *)expected->username, length, expected);
}
esp_err_t user_database_set_role_current(const user_database_account_t *expected, user_role_t role)
{
if (!expected) return ESP_ERR_INVALID_ARG;
size_t length = strnlen(expected->username, sizeof(expected->username));
if (!user_database_username_valid((const uint8_t *)expected->username, length))
return ESP_ERR_INVALID_ARG;
return set_role((const uint8_t *)expected->username, length, role, expected);
}
static esp_err_t set_password(const uint8_t *username, size_t username_length,
const uint8_t *password, size_t password_length,
const user_database_account_t *expected)
{
if (!s_initialized || s_mutex == NULL ||
!user_database_password_valid(password, password_length)) {
@@ -1134,7 +1195,8 @@ esp_err_t user_database_set_password(const uint8_t *username, size_t username_le
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
int index;
esp_err_t error = mutate_user_begin(username, username_length, &index);
esp_err_t error = target_matches_locked(username, username_length, expected)
? mutate_user_begin(username, username_length, &index) : ESP_ERR_NOT_FOUND;
if (error == ESP_OK) {
stored_user_t *user = &s_candidate->users[index];
error = set_record_password(user, password, password_length);
@@ -1151,11 +1213,27 @@ esp_err_t user_database_set_password(const uint8_t *username, size_t username_le
return error;
}
esp_err_t user_database_set_password(const uint8_t *username, size_t username_length,
const uint8_t *password, size_t password_length)
{
return set_password(username, username_length, password, password_length, NULL);
}
esp_err_t user_database_set_password_current(const user_database_account_t *expected,
const uint8_t *password, size_t password_length)
{
if (!expected) return ESP_ERR_INVALID_ARG;
size_t length = strnlen(expected->username, sizeof(expected->username));
if (!user_database_username_valid((const uint8_t *)expected->username, length))
return ESP_ERR_INVALID_ARG;
return set_password((const uint8_t *)expected->username, length, password, password_length, expected);
}
esp_err_t user_database_generate_password(
const uint8_t *username, size_t username_length,
user_database_generated_password_t *generated_password)
{
esp_err_t error = generate_password(generated_password);
esp_err_t error = user_database_generate_password_value(generated_password);
if (error == ESP_OK) {
error = user_database_set_password(username, username_length,
generated_password->password,
+22
View File
@@ -105,6 +105,28 @@ esp_err_t user_database_recover_from_legacy(
const user_database_legacy_credentials_t *legacy);
esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot);
/* Compact secret-free list, zero-wait mutex acquisition; no key material. */
typedef struct {
uint32_t user_id, auth_generation;
user_role_t role;
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
} user_database_account_t;
typedef struct {
size_t count;
user_database_account_t users[USER_DATABASE_MAX_USERS];
} user_database_accounts_t;
esp_err_t user_database_get_accounts(user_database_accounts_t *accounts);
/* Compare target identity under the mutation lock, before candidate/commit.
* ESP_ERR_NOT_FOUND means absent or stale; existing account invariants apply. */
esp_err_t user_database_delete_current(const user_database_account_t *expected);
esp_err_t user_database_set_role_current(const user_database_account_t *expected,
user_role_t role);
esp_err_t user_database_set_password_current(const user_database_account_t *expected,
const uint8_t *password, size_t password_length);
/* RNG only: no database initialization, account mutation or persistence. Caller
* owns/wipes successful output; failures clear it. Same generator as CLI. */
esp_err_t user_database_generate_password_value(user_database_generated_password_t *generated);
esp_err_t user_database_authenticate_password(
const uint8_t *username, size_t username_length,
const uint8_t *password, size_t password_length,
+353
View File
@@ -0,0 +1,353 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_account_settings.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "secure_random.h"
#include "ssh_transport.h"
#include "web_cookie_auth.h"
#include "web_auth_parse.h"
#include "web_httpd_adapter.h"
#include "web_serial_transport.h"
enum { IDLE, PENDING, OK, FAILED, CANCELLED, STALE, PROTECTED, DUPLICATE, FULL };
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "stale", "protected", "duplicate", "full"};
typedef enum { ACTION_ROLE, ACTION_DELETE, ACTION_CREATE, ACTION_PASSWORD } account_action_t;
static const char *const s_actions[] = {"role", "delete", "create", "password"};
typedef struct {
uint32_t id;
web_session_id_t session;
user_principal_t principal;
user_database_account_t target;
int64_t deadline;
user_role_t role;
unsigned state;
account_action_t action;
bool executing;
uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U];
size_t password_length;
} account_operation_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static account_operation_t s_operation;
static uint32_t s_next_id;
static esp_timer_handle_t s_secret_timer;
static bool s_secret_timer_started;
static bool credential_action(account_action_t action)
{
return action == ACTION_CREATE || action == ACTION_PASSWORD;
}
static void wipe_input(account_operation_t *operation)
{
secure_wipe(&operation->principal, sizeof(operation->principal));
secure_wipe(&operation->target, sizeof(operation->target));
secure_wipe(operation->password, sizeof(operation->password));
operation->password_length = 0;
}
static void expire_secret(void *unused)
{
(void)unused;
taskENTER_CRITICAL(&s_lock);
/* Inspect only the current ID/deadline, never a captured/rearmed job. A late
* tick cannot cancel a replacement before its own deadline or executing work. */
if (s_operation.id && s_operation.state == PENDING && !s_operation.executing &&
credential_action(s_operation.action) && esp_timer_get_time() >= s_operation.deadline) {
s_operation.state = CANCELLED;
wipe_input(&s_operation);
}
taskEXIT_CRITICAL(&s_lock);
}
static bool ensure_secret_timer(void)
{
/* HTTPD is the sole admission owner. Once started, this one firmware-lifetime
* timer is never stopped/rearmed/deleted. Expiry is best-effort scheduling,
* not hard realtime; no network/database work runs in its callback. */
if (!s_secret_timer) {
const esp_timer_create_args_t args = {.callback = expire_secret, .name = "account-secret"};
if (esp_timer_create(&args, &s_secret_timer) != ESP_OK) return false;
}
if (!s_secret_timer_started) {
if (esp_timer_start_periodic(s_secret_timer, 1000000ULL) != ESP_OK) return false;
s_secret_timer_started = true;
}
return true;
}
/* Exact flat schemas. Only password accepts JSON escapes; canonical database
* policy validates the decoded bytes. No coercion/unknown/duplicate fields. */
static bool parse(const char *body, size_t length, account_operation_t *operation)
{
const char *keys[] = {"action", "username", "user_id", "auth_generation", "role", "password"};
unsigned seen = 0;
size_t pos = 0;
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
TAKE('{');
for (unsigned field = 0; field < 6; ++field) {
if (field) { TAKE(','); }
TAKE('"');
size_t start = pos;
while (pos < length && body[pos] != '"') ++pos;
if (pos == length) return false;
unsigned key = 0;
for (; key < 6; ++key)
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
if (key == 6 || (seen & (1U << key))) return false;
++pos; TAKE(':'); SPACE();
uint32_t number = 0;
char value[USER_DATABASE_USERNAME_CAPACITY + 1] = {0};
if (key == 2 || key == 3) {
start = pos;
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
unsigned digit = (unsigned)(body[pos++] - '0');
if (number > (UINT32_MAX - digit) / 10U) return false;
number = number * 10U + digit;
}
if (!number || pos == start || (pos - start > 1 && body[start] == '0')) return false;
} else if (key == 5) {
if (!web_auth_parse_json_string(body, length, &pos, operation->password,
sizeof(operation->password), &operation->password_length) ||
!user_database_password_valid(operation->password, operation->password_length)) return false;
} else {
TAKE('"'); start = pos;
while (pos < length && body[pos] != '"') {
if (body[pos] < ' ' || body[pos] > '~' || body[pos] == '\\' || pos - start >= sizeof(value) - 1) return false;
++pos;
}
if (pos == length) return false;
memcpy(value, body + start, pos - start); ++pos;
}
switch (key) {
case 0:
{
unsigned action = 0;
for (; action < sizeof(s_actions) / sizeof(*s_actions); ++action)
if (!strcmp(value, s_actions[action])) break;
if (action == sizeof(s_actions) / sizeof(*s_actions)) return false;
operation->action = (account_action_t)action;
}
break;
case 1:
if (!user_database_username_valid((const uint8_t *)value, strlen(value))) return false;
memcpy(operation->target.username, value, sizeof(value)); break;
case 2: operation->target.user_id = number; break;
case 3: operation->target.auth_generation = number; break;
case 4: if (!user_role_parse(value, &operation->role)) return false; break;
}
seen |= 1U << key;
SPACE();
if (pos < length && body[pos] == '}') break;
}
TAKE('}'); SPACE();
#undef TAKE
#undef SPACE
const unsigned schemas[] = {31U, 15U, 51U, 47U};
return pos == length && seen == schemas[operation->action];
}
void web_account_settings_execute(uint32_t id)
{
account_operation_t operation = {0};
taskENTER_CRITICAL(&s_lock);
bool admitted = id && s_operation.id == id && s_operation.state == PENDING && !s_operation.executing;
if (admitted) {
s_operation.executing = true;
operation = s_operation;
wipe_input(&s_operation);
}
taskEXIT_CRITICAL(&s_lock);
if (!admitted) return;
bool current = false;
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, &current);
unsigned state = CANCELLED;
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
esp_timer_get_time() < operation.deadline) {
/* CLI and typed mutations share this dispatcher. Target identity is also
* compared under the database mutation lock, not just at HTTP admission. */
switch (operation.action) {
case ACTION_ROLE: error = user_database_set_role_current(&operation.target, operation.role); break;
case ACTION_DELETE: error = user_database_delete_current(&operation.target); break;
case ACTION_CREATE:
error = user_database_create((const uint8_t *)operation.target.username,
strlen(operation.target.username), operation.role, operation.password, operation.password_length);
break;
case ACTION_PASSWORD:
error = user_database_set_password_current(&operation.target, operation.password, operation.password_length);
break;
}
secure_wipe(operation.password, sizeof(operation.password));
operation.password_length = 0;
state = error == ESP_OK ? OK : error == ESP_ERR_NOT_FOUND ? STALE :
error == ESP_ERR_INVALID_STATE ? (operation.action == ACTION_CREATE ? DUPLICATE : PROTECTED) :
error == ESP_ERR_NO_MEM && operation.action == ACTION_CREATE ? FULL : FAILED;
if (error == ESP_OK && operation.action != ACTION_CREATE) {
size_t length = strlen(operation.target.username);
(void)web_serial_transport_revoke_user((const uint8_t *)operation.target.username, length);
(void)ssh_transport_revoke_user((const uint8_t *)operation.target.username, length);
}
}
secure_wipe(operation.password, sizeof(operation.password));
operation.password_length = 0;
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == PENDING && s_operation.executing) {
s_operation.state = state;
s_operation.executing = false;
wipe_input(&s_operation);
}
taskEXIT_CRITICAL(&s_lock);
secure_wipe(&operation, sizeof(operation));
}
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
return web_httpd_unread_body(request) ? ESP_FAIL : error;
}
static esp_err_t list_accounts(httpd_req_t *request)
{
user_database_accounts_t accounts;
if (user_database_get_accounts(&accounts) != ESP_OK)
return respond(request, "503 Service Unavailable", "{\"error\":\"accounts_unavailable\"}");
char body[1024];
size_t used = (size_t)snprintf(body, sizeof(body), "{\"users\":[");
for (size_t i = 0; i < accounts.count; ++i) {
const user_database_account_t *user = &accounts.users[i];
/* Database username policy makes these ASCII strings JSON-safe. */
int written = snprintf(body + used, sizeof(body) - used,
"%s{\"username\":\"%s\",\"user_id\":%" PRIu32 ",\"auth_generation\":%" PRIu32 ",\"role\":\"%s\"}",
i ? "," : "", user->username, user->user_id, user->auth_generation, user_role_to_string(user->role));
if (written < 0 || (size_t)written >= sizeof(body) - used) return ESP_FAIL;
used += (size_t)written;
}
if (used + 3 > sizeof(body)) return ESP_FAIL;
memcpy(body + used, "]}", 3);
return respond(request, "200 OK", body);
}
esp_err_t web_account_generate_password_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
user_database_generated_password_t generated = {0};
char response[96] = {0};
bool allowed = false;
esp_err_t error = web_cookie_auth_require(request, true, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
error = user_database_generate_password_value(&generated);
if (error != ESP_OK) {
secure_wipe(&generated, sizeof(generated));
error = respond(request, "503 Service Unavailable", "{\"error\":\"unavailable\"}");
goto done;
}
bool current = false;
error = web_session_store_check_principal(view.id, &view.principal, &current);
if (error != ESP_OK || !current) {
secure_wipe(&generated, sizeof(generated));
error = respond(request, "401 Unauthorized", "{\"error\":\"authentication_required\"}");
goto done;
}
int written = snprintf(response, sizeof(response), "{\"password\":\"%s\"}", (const char *)generated.password);
secure_wipe(&generated, sizeof(generated));
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : respond(request, "200 OK", response);
done:
secure_wipe(&generated, sizeof(generated));
secure_wipe(response, sizeof(response));
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
esp_err_t web_account_settings_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
account_operation_t operation = {0};
bool allowed = false, mutation = request->method == HTTP_POST;
esp_err_t error = mutation ? web_cookie_auth_require_json(request, 768, &view, &allowed) :
web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
if (!strcmp(request->uri, "/api/settings/accounts")) {
error = mutation ? respond(request, "400 Bad Request", "{\"error\":\"invalid_request\"}") : list_accounts(request);
goto done;
}
if (mutation) {
char type[40] = {0}, body[768];
size_t received = 0;
bool valid = request->content_len && request->content_len <= sizeof(body) &&
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
int count = httpd_req_recv(request, body + received, request->content_len - received);
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
else received += (size_t)count;
}
valid = valid && received == request->content_len && parse(body, received, &operation);
secure_wipe(body, sizeof(body));
if (!valid) {
wipe_input(&operation);
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_account_request\"}");
goto done;
}
if (credential_action(operation.action) && !ensure_secret_timer()) {
wipe_input(&operation);
error = respond(request, "503 Service Unavailable", "{\"error\":\"unavailable\"}");
goto done;
}
operation.session = view.id;
operation.principal = view.principal;
operation.deadline = esp_timer_get_time() + 30000000LL;
operation.state = PENDING;
taskENTER_CRITICAL(&s_lock);
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
if (!busy) { operation.id = ++s_next_id; s_operation = operation; }
taskEXIT_CRITICAL(&s_lock);
if (busy || admin_ssh_console_submit_account_settings(operation.id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (!busy && s_operation.id == operation.id && !s_operation.executing)
secure_wipe(&s_operation, sizeof(s_operation));
taskEXIT_CRITICAL(&s_lock);
wipe_input(&operation);
error = httpd_resp_set_hdr(request, "Retry-After", "1");
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
goto done;
}
} else {
taskENTER_CRITICAL(&s_lock);
if (s_operation.session == view.id) {
operation.id = s_operation.id;
operation.action = s_operation.action;
operation.state = s_operation.state;
}
taskEXIT_CRITICAL(&s_lock);
}
wipe_input(&operation);
char response[96];
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, mutation ? "202 Accepted" : "200 OK", response);
done:
secure_wipe(&operation, sizeof(operation));
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
+12
View File
@@ -0,0 +1,12 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
/* One session-bound pending/result slot. Dispatcher execution only; completed
* results are replaceable, not durable history or an idempotent retry API. */
esp_err_t web_account_settings_handler(httpd_req_t *request);
void web_account_settings_execute(uint32_t id);
/* POST /api/settings/accounts/generate-password; bodyless admin cookie +
* Origin/CSRF. RNG only, no queued/account/persistent state or retrieval. */
esp_err_t web_account_generate_password_handler(httpd_req_t *request);
+17
View File
@@ -208,6 +208,23 @@ static bool string(json_cursor_t *c, uint8_t *out, size_t capacity, size_t *leng
return true;
}
bool web_auth_parse_json_string(const char *body, size_t length, size_t *position,
uint8_t *output, size_t capacity, size_t *decoded_length)
{
if (output && capacity) wipe(output, capacity);
if (decoded_length) *decoded_length = 0;
if (!body || !position || *position > length || !output || !capacity || !decoded_length)
return false;
json_cursor_t c = { (const uint8_t *)body, length, *position };
if (!string(&c, output, capacity - 1U, decoded_length)) {
wipe(output, capacity);
*decoded_length = 0;
return false;
}
*position = c.pos;
return true;
}
bool web_auth_parse_login(const char *body, size_t length,
web_auth_credentials_t *credentials)
{
+5
View File
@@ -40,6 +40,11 @@ bool web_auth_parse_cookie(const char *header, size_t length, const char *name,
* lets HTTP policy distinguish absence from malformed/ambiguous cookies. */
bool web_auth_parse_optional_cookie(const char *header, size_t length, const char *name,
char token[WEB_AUTH_TOKEN_LENGTH + 1U], bool *present);
/* Decode one string at *position (including optional JSON whitespace). Capacity
* includes the terminator. Failure wipes output and leaves position unchanged.
* Success output is sensitive; caller must wipe it. Same strict decoder as login. */
bool web_auth_parse_json_string(const char *body, size_t length, size_t *position,
uint8_t *output, size_t capacity, size_t *decoded_length);
/* Exactly username/password string fields, either order. JSON escapes and valid
* UTF-8 accepted; unknown/duplicate fields, NUL and malformed Unicode rejected.
* Database credential policy remains authoritative. Caller must wipe BOTH the
+21 -1
View File
@@ -24,6 +24,7 @@
#include "web_security.h"
#include "web_serial_transport.h"
#include "web_serial_settings.h"
#include "web_account_settings.h"
#include "web_admin_transport.h"
#include "web_session_store.h"
#include "web_cookie_auth.h"
@@ -388,6 +389,20 @@ static const httpd_uri_t s_serial_operation_post_uri = {
.handler = web_serial_settings_handler,
};
static const httpd_uri_t s_accounts_uri = {
.uri = "/api/settings/accounts", .method = HTTP_GET, .handler = web_account_settings_handler,
};
static const httpd_uri_t s_account_operation_get_uri = {
.uri = "/api/settings/account-operation", .method = HTTP_GET, .handler = web_account_settings_handler,
};
static const httpd_uri_t s_account_operation_post_uri = {
.uri = "/api/settings/account-operation", .method = HTTP_POST, .handler = web_account_settings_handler,
};
static const httpd_uri_t s_account_generate_password_uri = {
.uri = "/api/settings/accounts/generate-password", .method = HTTP_POST,
.handler = web_account_generate_password_handler,
};
static const httpd_uri_t s_root_uri = {
.uri = "/",
.method = HTTP_GET,
@@ -576,7 +591,7 @@ esp_err_t web_server_start(void)
config.httpd.max_open_sockets = 6;
config.httpd.max_uri_handlers =
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 5U;
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 9U;
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
config.httpd.lru_purge_enable = false;
config.httpd.recv_wait_timeout = 1;
@@ -627,6 +642,11 @@ esp_err_t web_server_start(void)
if (web_httpd_register_optional(server, &s_serial_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_serial_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_serial_operation_get_uri.uri, HTTP_GET);
if (web_httpd_register_optional_get(server, &s_accounts_uri) == ESP_OK &&
web_httpd_register_optional_get(server, &s_account_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_account_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_account_operation_get_uri.uri, HTTP_GET);
(void)web_httpd_register_optional(server, &s_account_generate_password_uri);
}
if (error != ESP_OK) {
web_cookie_auth_stop();
+195 -3
View File
@@ -178,7 +178,9 @@ static const char s_index_html[] =
"<div id=\"terminal\" class=\"terminal-host\"></div>\n"
"<div id=\"admin-terminal\" class=\"terminal-host\" hidden></div>\n"
"<section id=\"serial-settings\" class=\"settings-page\" aria-label=\"Serial settings\" hidden>"
"<h2>Serial</h2><p class=\"connection-detail\">Working UART1 configuration below is not a saved NVS snapshot. "
"<div class=\"serial-actions\"><button id=\"settings-serial\" class=\"button\" type=\"button\" aria-pressed=\"true\">Serial settings</button>"
"<button id=\"settings-accounts\" class=\"button\" type=\"button\" aria-pressed=\"false\">Accounts</button></div>"
"<div id=\"serial-settings-content\"><h2>Serial</h2><p class=\"connection-detail\">Working UART1 configuration below is not a saved NVS snapshot. "
"Navigation leaves both terminals connected and preserves the serial writer lease.</p>"
"<button id=\"refresh-settings\" class=\"button\" type=\"button\">Refresh</button>"
"<p id=\"settings-detail\" class=\"connection-detail\" role=\"status\">Select Refresh to read current values.</p>"
@@ -212,7 +214,36 @@ static const char s_index_html[] =
"<button id=\"serial-start\" class=\"button\" type=\"button\">Start</button>"
"<button id=\"serial-stop\" class=\"button\" type=\"button\">Stop</button></div></div>"
"<button id=\"serial-result\" class=\"button\" type=\"button\">Check Operation Result</button>"
"<p id=\"serial-operation-detail\" class=\"connection-detail\" role=\"status\">Check Result after any uncertain submission; never assume timeout or navigation cancels an operation.</p>"
"<p id=\"serial-operation-detail\" class=\"connection-detail\" role=\"status\">Check Result after any uncertain submission; never assume timeout or navigation cancels an operation.</p></div>"
"<div id=\"account-settings\" hidden><h2>Accounts</h2>"
"<p class=\"connection-detail\">Role and deletion changes are saved immediately and revoke only the affected account's network sessions. The final administrator is protected. "
"Self changes can immediately close all this account's web/SSH sessions, including browser serial/admin. A disconnect or 401 is NOT proof of success; re-login and inspect before retrying.</p>"
"<button id=\"refresh-accounts\" class=\"button\" type=\"button\">Refresh accounts</button>"
"<p id=\"accounts-detail\" class=\"connection-detail\" role=\"status\">Select Refresh to read accounts.</p>"
"<pre id=\"accounts-list\"></pre><div class=\"serial-edit\">"
"<label>Account<select id=\"account-target\">"
"<option id=\"account-option-0\" value=\"0\"></option><option id=\"account-option-1\" value=\"1\"></option>"
"<option id=\"account-option-2\" value=\"2\"></option><option id=\"account-option-3\" value=\"3\"></option>"
"<option id=\"account-option-4\" value=\"4\"></option><option id=\"account-option-5\" value=\"5\"></option>"
"<option id=\"account-option-6\" value=\"6\"></option><option id=\"account-option-7\" value=\"7\"></option></select></label>"
"<label>New role<select id=\"account-role\"><option value=\"user\">user</option><option value=\"admin\">admin</option></select></label></div>"
"<div class=\"serial-actions\"><button id=\"account-change-role\" class=\"button\" type=\"button\">Change role</button>"
"<button id=\"account-delete\" class=\"button\" type=\"button\">Delete account</button>"
"<button id=\"account-result\" class=\"button\" type=\"button\">Check Result</button></div>"
"<div class=\"serial-edit\"><label>Purpose<select id=\"account-purpose\"><option value=\"create\">Create account</option><option value=\"password\">Change selected account password</option></select></label>"
"<label id=\"account-username-label\">New username<input id=\"account-username\" maxlength=\"16\" autocomplete=\"off\"></label>"
"<label id=\"account-create-role-label\">Initial role<select id=\"account-create-role\"><option value=\"user\">user</option><option value=\"admin\">admin</option></select></label>"
"<label>Password<input id=\"account-password\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\"></label>"
"<label>Confirm password<input id=\"account-password-confirm\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\"></label></div>"
"<p class=\"connection-detail\">12-64 printable ASCII characters; spaces are significant. Confirm every password. Save it before submitting, especially for your own account.</p>"
"<button id=\"account-generate\" class=\"button\" type=\"button\">Generate password (not applied)</button>"
"<div id=\"account-generated-panel\" hidden><label>Generated password — not applied yet<input id=\"account-generated\" readonly autocomplete=\"off\"></label>"
"<p>One-time display, no retrieval. Copy and save now; this display and password fields expire after 60 seconds. Enter it in Confirm password.</p>"
"<label><input id=\"account-password-saved\" type=\"checkbox\">I saved this exact password for this operation and account.</label></div>"
"<p class=\"connection-detail\">Secrets are cleared on submission and when leaving or changing this form. JavaScript cannot securely zero strings or browser-managed copies.</p>"
"<p id=\"account-secret-detail\" class=\"connection-detail\" role=\"status\"></p>"
"<button id=\"account-submit-password\" class=\"button\" type=\"button\">Submit create / password change</button>"
"<p id=\"account-operation-detail\" class=\"connection-detail\" role=\"status\">Changes are submitted once. Completion is checked automatically; uncertain results require manual recovery.</p></div>"
"</section>\n"
"</section>\n"
"</main>\n"
@@ -281,6 +312,7 @@ static const char s_app_js[] =
" element('serial-result').disabled = busy;\n"
"}\n"
"function clearSettings() {\n"
" clearAccounts();\n"
" if (!serialAuto && serialOperationPending) element('serial-operation-detail').textContent = serialOutcomeWarning + 'Operation outcome pending or unknown. Select Check Result on return; navigation does not cancel backend work.';\n"
" stopSerialAuto(true);\n"
" if (settingsAbort) settingsAbort.abort();\n"
@@ -292,6 +324,7 @@ static const char s_app_js[] =
" element('refresh-settings').disabled = false; settingsDetail.textContent = 'Select Refresh to read current values.';\n"
"}\n"
"async function refreshSettings() {\n"
" if (settingsDomain === 'accounts') return refreshAccounts();\n"
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || serialAuto) return;\n"
" settingsHost.hidden = false;\n"
" const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n"
@@ -317,6 +350,7 @@ static const char s_app_js[] =
" }\n"
"}\n"
"async function serialOperation(action, automatic = false) {\n"
" if (settingsDomain !== 'serial') return;\n"
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || (action && serialOperationPending)) return;\n"
" if (!automatic && serialAuto) return;\n"
" const detail = element('serial-operation-detail');\n"
@@ -383,6 +417,163 @@ static const char s_app_js[] =
" }\n"
" }\n"
"}\n"
"let settingsDomain = 'serial', accounts = [], accountsAbort = null, accountId = 0, accountPending = false, accountAwaitingAck = false, accountWarning = '';\n"
"let secretEpoch = 0, secretAbort = null, secretTimer = null, generatedPassword = '', generatedContext = '', savedContext = '', secretExpires = 0;\n"
"function secretContext() { const t = accounts[Number(element('account-target').value)]; return JSON.stringify([element('account-purpose').value, element('account-username').value, element('account-create-role').value, t?.username, t?.user_id, t?.auth_generation]); }\n"
"function invalidateSecretRequest() { ++secretEpoch; if (secretAbort) secretAbort.abort(); secretAbort = null; savedContext = ''; element('account-password-saved').checked = false; }\n"
"function clearAccountSecret() {\n"
" invalidateSecretRequest(); window.clearTimeout(secretTimer); secretTimer = null; secretExpires = 0; generatedPassword = generatedContext = '';\n"
" for (const id of ['account-password','account-password-confirm','account-generated']) element(id).value = '';\n"
" element('account-generated-panel').hidden = true; element('account-secret-detail').textContent = '';\n"
"}\n"
"async function generateAccountPassword() {\n"
" if (!accountsLive() || accountsAbort || accountPending || secretAbort) return;\n"
" clearAccountSecret(); const context = secretContext(), epoch = secretEpoch, generation = workGeneration, controller = new AbortController(); secretAbort = controller; accountButtons();\n"
" const current = () => secretAbort === controller && secretEpoch === epoch && context === secretContext() && accountsLive();\n"
" let payload;\n"
" try {\n"
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
" ({payload} = await api('/api/settings/accounts/generate-password', generation, {method: 'POST', signal: controller.signal, limit: 96, current}));\n"
" if (!payload || Object.keys(payload).length !== 1 || typeof payload.password !== 'string' || !/^[A-Za-z0-9_-]{24}$/.test(payload.password)) throw new Error('Invalid generated password');\n"
" generatedPassword = payload.password; generatedContext = context; secretExpires = performance.now() + 60000;\n"
" element('account-password').value = element('account-generated').value = generatedPassword; element('account-generated-panel').hidden = false;\n"
" secretTimer = window.setTimeout(() => { clearAccountSecret(); accountButtons(); }, 60000);\n"
" } catch (error) { if (current()) { clearAccountSecret(); element('account-secret-detail').textContent = 'Password generation unavailable. Nothing applied. Retry only explicitly.'; } }\n"
" finally { if (payload) payload.password = ''; payload = null; if (secretAbort === controller) secretAbort = null; accountButtons(); }\n"
"}\n"
"element('account-purpose').value = 'create'; element('account-create-role').value = 'user';\n"
"function accountsLive() { return selected === 'settings' && settingsDomain === 'accounts' && accountRole === 'admin' && sessionVerified && !suspended && !unloading && !navigating && !loggingOut; }\n"
"function accountButtons() {\n"
" const busy = !!accountsAbort, target = accounts[Number(element('account-target').value)];\n"
" const blocked = busy || accountPending || !target;\n"
" element('account-delete').disabled = element('account-change-role').disabled = blocked;\n"
" element('account-target').disabled = element('account-role').disabled = busy || accountPending || !accounts.length;\n"
" element('account-result').disabled = element('refresh-accounts').disabled = busy;\n"
" const create = element('account-purpose').value === 'create';\n"
" element('account-username-label').hidden = element('account-create-role-label').hidden = !create;\n"
" element('account-generate').disabled = busy || accountPending || !!secretAbort || (!create && !target);\n"
" element('account-submit-password').disabled = busy || accountPending || !!secretAbort || (!create && !target);\n"
" for (const id of ['account-purpose','account-username','account-create-role','account-password','account-password-confirm','account-password-saved']) element(id).disabled = busy || accountPending;\n"
"}\n"
"function clearAccounts() {\n"
" clearAccountSecret();\n"
" if (accountsAbort) accountsAbort.abort();\n"
" accountsAbort = null; accounts = []; element('accounts-list').textContent = '';\n"
" for (let i = 0; i < 8; ++i) { const option = element('account-option-' + i); option.textContent = ''; option.hidden = option.disabled = true; }\n"
" if (accountPending) element('account-operation-detail').textContent = accountWarning + 'Outcome pending or unknown. Check Result on return; navigation does not cancel backend work.';\n"
" accountButtons();\n"
"}\n"
"function selectSettingsDomain(domain) {\n"
" if (!sessionVerified || accountRole !== 'admin' || selected !== 'settings' || domain === settingsDomain) return;\n"
" clearSettings(); settingsDomain = domain; settingsHost.hidden = false;\n"
" element('serial-settings-content').hidden = domain !== 'serial'; element('account-settings').hidden = domain !== 'accounts';\n"
" element('settings-serial').setAttribute('aria-pressed', String(domain === 'serial')); element('settings-accounts').setAttribute('aria-pressed', String(domain === 'accounts'));\n"
" refreshSettings();\n"
"}\n"
"async function refreshAccounts() {\n"
" if (!accountsLive() || accountsAbort) return;\n"
" clearAccountSecret();\n"
" const controller = new AbortController(), generation = workGeneration; accountsAbort = controller; accountButtons();\n"
" const current = () => accountsAbort === controller && accountsLive();\n"
" element('accounts-detail').textContent = 'Reading accounts; previous list may be stale.';\n"
" try {\n"
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
" const {payload} = await api('/api/settings/accounts', generation, {signal: controller.signal, limit: 1024, current});\n"
" const validId = n => Number.isInteger(n) && n > 0 && n <= 4294967295;\n"
" if (!payload || Object.keys(payload).length !== 1 || !Array.isArray(payload.users) || payload.users.length > 8 ||\n"
" !payload.users.every(u => u && Object.keys(u).length === 4 && typeof u.username === 'string' && /^[a-z][a-z0-9_-]{0,15}$/.test(u.username) && validId(u.user_id) && validId(u.auth_generation) && ['user','admin'].includes(u.role)) ||\n"
" new Set(payload.users.map(u => u.username)).size !== payload.users.length) throw new Error('Invalid account list');\n"
" clearAccountSecret(); accounts = payload.users;\n"
" element('accounts-list').textContent = accounts.map(u => u.username + ' — ' + u.role + (u.username === sessionIdentity.username ? ' (you)' : '')).join('\\n');\n"
" for (let i = 0; i < 8; ++i) { const option = element('account-option-' + i); option.textContent = accounts[i]?.username || ''; option.hidden = option.disabled = !accounts[i]; }\n"
" element('account-target').value = '0'; element('account-role').value = accounts[0]?.role || 'user';\n"
" element('accounts-detail').textContent = accountPending ? 'List may be stale while operation outcome is pending or unknown.' : 'Account list refreshed. Select an account before changing it.';\n"
" } catch (error) { if (live(generation) && current()) element('accounts-detail').textContent = (error.status ? error.message : 'Account list unavailable or invalid.') + ' List stale. Refresh to retry.'; }\n"
" finally { if (current()) { accountsAbort = null; accountButtons(); } }\n"
"}\n"
"async function accountOperation(action) {\n"
" if (!accountsLive() || accountsAbort || (action && accountPending)) return;\n"
" let body;\n"
" if (action) {\n"
" const target = accounts[Number(element('account-target').value)], role = element('account-role').value;\n"
" const credential = action === 'create' || action === 'password';\n"
" let request;\n"
" if (credential) {\n"
" try {\n"
" const password = element('account-password').value, username = action === 'create' ? element('account-username').value : target?.username, initialRole = element('account-create-role').value;\n"
" if (secretAbort || !/^[a-z][a-z0-9_-]{0,15}$/.test(username || '') || !['user','admin'].includes(initialRole) || typeof password !== 'string' || !/^[ -~]{12,64}$/.test(password) || password !== element('account-password-confirm').value ||\n"
" (generatedPassword && (performance.now() >= secretExpires || password !== generatedPassword || generatedContext !== secretContext() || savedContext !== generatedContext || !element('account-password-saved').checked))) {\n"
" element('account-operation-detail').textContent = 'Not submitted. Check username, password length/ASCII, confirmation and generated-password saved acknowledgement; re-enter secrets.'; return;\n"
" }\n"
" request = action === 'create' ? {action, username, role: initialRole, password} : {action, username, user_id: target.user_id, auth_generation: target.auth_generation, password};\n"
" body = JSON.stringify(request);\n"
" } finally { clearAccountSecret(); if (request) request.password = ''; request = null; }\n"
" } else {\n"
" clearAccountSecret(); if (!target || !['role','delete'].includes(action) || !['user','admin'].includes(role)) return;\n"
" body = JSON.stringify({action, username: target.username, user_id: target.user_id, auth_generation: target.auth_generation, ...(action === 'role' ? {role} : {})});\n"
" }\n"
" if (encoder.encode(body).length > 768) { body = undefined; return; }\n"
" const name = action === 'create' ? element('account-username').value : target.username;\n"
" const warning = name === sessionIdentity.username ? ' ALL this accounts web/SSH sessions, including this browser serial/admin, can close immediately (even a no-op role change). A 401 or disconnect is NOT proof of success. Save the password before submitting, then re-login and inspect if the result is lost.' : '';\n"
" if (!window.confirm((action === 'delete' ? 'Delete ' : action === 'create' ? 'Create ' + element('account-create-role').value + ' account ' : action === 'password' ? 'Change password for ' : 'Change role to ' + role + ' for ') + name + '? Saved immediately; affected account sessions may be revoked.' + warning)) { body = undefined; return; }\n"
" }\n"
" const controller = new AbortController(), generation = workGeneration; accountsAbort = controller; accountButtons();\n"
" const current = () => accountsAbort === controller && accountsLive();\n"
" controller.signal.addEventListener('abort', () => { body = undefined; }, {once: true});\n"
" const detail = element('account-operation-detail'); let deadline, refresh = false, until = Infinity;\n"
" detail.textContent = accountWarning + (action ? 'Submitting once...' : 'Reading latest result...');\n"
" element('accounts-detail').textContent = 'List may be stale until operation completes and refresh succeeds.';\n"
" const read = async (method, requestBody) => {\n"
" const response = api('/api/settings/account-operation', generation, {method, body: requestBody, signal: controller.signal, limit: 96, current: () => current() && performance.now() < until}); requestBody = undefined;\n"
" const {payload: result} = await response;\n"
" if (performance.now() >= until) throw new Error('Check deadline');\n"
" if (!result || Object.keys(result).length !== 3 || !Number.isInteger(result.id) || result.id < 0 || result.id > 4294967295 || !['none','role','delete','create','password'].includes(result.action) ||\n"
" !['idle','pending','ok','failed','cancelled','stale','protected','duplicate','full'].includes(result.state) || ((result.id === 0) !== (result.state === 'idle')) || ((result.id === 0) !== (result.action === 'none')) ||\n"
" (method === 'POST' && (!result.id || result.action !== action || result.state !== 'pending'))) throw new Error('Invalid operation result');\n"
" if (method === 'POST') accountWarning = '';\n"
" else if (accountAwaitingAck) accountWarning = 'Acknowledgement lost: latest result may belong to another request. Inspect before retrying. ';\n"
" else if (accountId && result.id !== accountId) accountWarning = 'Previous result replaced or unavailable; outcome unknown. ';\n"
" accountId = result.id; accountPending = result.state === 'pending'; accountAwaitingAck = false;\n"
" const messages = {duplicate: 'Username already exists. Refresh before retrying.', full: 'Account capacity full. Inspect accounts before retrying.', idle: 'No retained result. Inspect accounts before retrying.', pending: 'Queued or executing...', ok: 'Account change completed and saved.', failed: 'Operation failed. Inspect accounts before retrying.', cancelled: 'Cancelled before execution: login or queue deadline stale.', stale: 'Account changed or was replaced. Refresh and select it again.', protected: 'Account is protected (including the final administrator), or database unavailable.'};\n"
" detail.textContent = accountWarning + result.action + ': ' + messages[result.state];\n"
" return result.state;\n"
" };\n"
" try {\n"
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
" if (action) { accountPending = true; accountAwaitingAck = true; }\n"
" const response = read(action ? 'POST' : 'GET', body); body = undefined;\n"
" let state = await response;\n"
" if (action) {\n"
" until = performance.now() + 15000; deadline = window.setTimeout(() => controller.abort(), 15000);\n"
" controller.signal.addEventListener('abort', () => window.clearTimeout(deadline), {once: true});\n"
" for (let attempt = 0; state === 'pending' && attempt < 10; ++attempt) {\n"
" await new Promise((resolve, reject) => {\n"
" const abort = () => { window.clearTimeout(timer); controller.signal.removeEventListener('abort', abort); reject(new Error('Cancelled')); };\n"
" const timer = window.setTimeout(() => { controller.signal.removeEventListener('abort', abort); resolve(); }, 1000);\n"
" controller.signal.addEventListener('abort', abort, {once: true}); if (controller.signal.aborted) abort();\n"
" });\n"
" if (!current() || performance.now() >= until) throw new Error('Check deadline');\n"
" if (!await loadSession(generation, controller.signal, false) || !current() || performance.now() >= until) throw new Error('Session changed or check deadline');\n"
" state = await read('GET');\n"
" }\n"
" }\n"
" if (state === 'pending') detail.textContent += ' Automatic checking stopped. Use Check Result; do not resubmit.';\n"
" refresh = state !== 'pending' && state !== 'idle';\n"
" } catch (error) { if (live(generation) && current()) detail.textContent = accountWarning + (error.status ? error.message : 'Outcome unknown.') + ' Use Check Result and Refresh before an explicit retry. No automatic retry.'; }\n"
" finally { body = undefined; window.clearTimeout(deadline); if (current()) { accountsAbort = null; accountButtons(); if (refresh) await refreshAccounts(); } }\n"
"}\n"
"element('settings-serial').addEventListener('click', () => selectSettingsDomain('serial'));\n"
"element('settings-accounts').addEventListener('click', () => selectSettingsDomain('accounts'));\n"
"element('refresh-accounts').addEventListener('click', refreshAccounts);\n"
"element('account-result').addEventListener('click', () => accountOperation(null));\n"
"element('account-delete').addEventListener('click', () => accountOperation('delete'));\n"
"element('account-change-role').addEventListener('click', () => accountOperation('role'));\n"
"element('account-generate').addEventListener('click', generateAccountPassword);\n"
"element('account-submit-password').addEventListener('click', () => accountOperation(element('account-purpose').value));\n"
"element('account-password-saved').addEventListener('change', () => { savedContext = element('account-password-saved').checked && generatedPassword && element('account-password').value === generatedPassword && generatedContext === secretContext() && performance.now() < secretExpires ? generatedContext : ''; });\n"
"for (const id of ['account-password','account-password-confirm']) element(id).addEventListener('input', () => { invalidateSecretRequest(); accountButtons(); });\n"
"for (const id of ['account-purpose','account-username','account-create-role','account-role']) element(id).addEventListener(id === 'account-username' ? 'input' : 'change', () => { clearAccountSecret(); accountButtons(); });\n"
"element('account-target').addEventListener('change', () => { clearAccountSecret(); element('account-role').value = accounts[Number(element('account-target').value)]?.role || 'user'; accountButtons(); });\n"
"let accountRole = 'user', selected = 'serial';\n"
"let adminTerminal = null, adminFit = null, adminSocket = null, adminAbort = null;\n"
"let adminGeneration = 0, adminTimer = null;\n"
@@ -576,9 +767,10 @@ static const char s_app_js[] =
" const timeout = window.setTimeout(abort, 15000);\n"
" try {\n"
/* Non-CORS POST with no-referrer serializes Origin as null in browsers. */
" const response = await fetch(path, {method, credentials: 'same-origin', mode: method === 'POST' ? 'cors' : 'same-origin',\n"
" const pendingResponse = fetch(path, {method, credentials: 'same-origin', mode: method === 'POST' ? 'cors' : 'same-origin',\n"
" cache: 'no-store', redirect: 'error', signal: controller.signal,\n"
" ...(method === 'POST' ? {headers: {'X-CSRF-Token': csrf, ...(body === undefined ? {} : {'Content-Type': 'application/json'})}, body: body === undefined ? '' : body} : {})});\n"
" body = undefined; const response = await pendingResponse;\n"
" if (!live(generation) || controller.signal.aborted || !current()) throw new Error('Cancelled');\n"
" if (response.status === 401) { login(); throw new Error('Session ended.'); }\n"
" if (!response.ok) {\n"