Add Typed Admin Network Settings

This commit is contained in:
2026-09-08 20:57:27 +02:00
parent d9ac1319aa
commit 989821b7c4
31 changed files with 2568 additions and 62 deletions
+1
View File
@@ -33,6 +33,7 @@ idf_component_register(
"web_serial_transport.c"
"web_serial_settings.c"
"web_account_settings.c"
"web_network_settings.c"
"web_admin_tickets.c"
"web_admin_transport.c"
"web_assets_data.c"
+17 -2
View File
@@ -18,6 +18,7 @@
#include "user_database.h"
#include "web_serial_settings.h"
#include "web_account_settings.h"
#include "web_network_settings.h"
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
@@ -85,6 +86,7 @@ typedef enum {
ADMIN_REQUEST_DEFERRED,
ADMIN_REQUEST_SERIAL_SETTINGS,
ADMIN_REQUEST_ACCOUNT_SETTINGS,
ADMIN_REQUEST_NETWORK_SETTINGS,
} admin_request_origin_t;
typedef struct {
@@ -97,6 +99,7 @@ typedef struct {
admin_control_request_t deferred;
uint32_t serial_settings_id;
uint32_t account_settings_id;
uint32_t network_settings_id;
};
} admin_request_t;
@@ -672,6 +675,16 @@ esp_err_t admin_ssh_console_submit_account_settings(uint32_t id)
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
esp_err_t admin_ssh_console_submit_network_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_NETWORK_SETTINGS, .network_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
static void worker_task(void *context)
{
(void)context;
@@ -680,9 +693,11 @@ static void worker_task(void *context)
if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) {
continue;
}
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) {
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS ||
request.origin == ADMIN_REQUEST_NETWORK_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);
else if (request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) web_account_settings_execute(request.account_settings_id);
else web_network_settings_execute(request.network_settings_id);
secure_wipe(&request, sizeof(request));
continue;
}
+1
View File
@@ -17,6 +17,7 @@ extern "C" {
/* 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);
esp_err_t admin_ssh_console_submit_network_settings(uint32_t id);
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
+51 -6
View File
@@ -12,6 +12,7 @@
static SemaphoreHandle_t s_mutex;
static mdns_config_t s_config;
static uint32_t s_config_generation;
static bool s_component_initialized;
static bool s_initialization_failed;
static bool s_announced;
@@ -45,6 +46,7 @@ esp_err_t mdns_service_init(const mdns_config_t *config)
return ESP_ERR_NO_MEM;
}
s_config = *config;
s_config_generation = 1;
s_last_error = ESP_OK;
return ESP_OK;
}
@@ -66,27 +68,68 @@ esp_err_t mdns_service_set_config(const mdns_config_t *config)
return ESP_ERR_INVALID_ARG;
}
lock_service();
if (s_config_generation == UINT32_MAX) { unlock_service(); return ESP_ERR_INVALID_STATE; }
s_config = *config;
++s_config_generation;
unlock_service();
return ESP_OK;
}
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *snapshot)
static void snapshot_locked(mdns_service_snapshot_t *snapshot)
{
if (snapshot == NULL || s_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
lock_service();
memset(snapshot, 0, sizeof(*snapshot));
snapshot->config_generation = s_config_generation;
snapshot->initialized = true;
snapshot->announced = s_announced;
memcpy(snapshot->suffix, s_config.suffix, s_config.suffix_len);
make_hostname(&s_config, snapshot->hostname, sizeof(snapshot->hostname));
snapshot->last_error = s_last_error;
}
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *snapshot)
{
if (!snapshot || !s_mutex) return ESP_ERR_INVALID_STATE;
lock_service();
snapshot_locked(snapshot);
unlock_service();
return ESP_OK;
}
esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *snapshot)
{
if (!snapshot) return ESP_ERR_INVALID_ARG;
memset(snapshot, 0, sizeof(*snapshot));
if (!s_mutex) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
snapshot_locked(snapshot);
unlock_service();
return ESP_OK;
}
esp_err_t mdns_service_update_current(uint32_t generation, mdns_settings_action_t action,
const mdns_config_t *config, bool *stored)
{
if (!stored || action > MDNS_SETTINGS_DEFAULTS || action < MDNS_SETTINGS_SET ||
(action == MDNS_SETTINGS_SET && mdns_config_validate(config) != ESP_OK)) return ESP_ERR_INVALID_ARG;
*stored = true;
if (!s_mutex) return ESP_ERR_INVALID_STATE;
lock_service();
if (!generation || generation != s_config_generation) { unlock_service(); return ESP_ERR_NOT_FOUND; }
esp_err_t error = ESP_OK;
mdns_config_t candidate = s_config;
if (action == MDNS_SETTINGS_SAVE) error = mdns_config_save(&s_config);
else if (s_config_generation == UINT32_MAX) error = ESP_ERR_INVALID_STATE;
else {
if (action == MDNS_SETTINGS_SET) candidate = *config;
else if (action == MDNS_SETTINGS_LOAD) error = mdns_config_load(&candidate, stored);
else mdns_config_defaults(&candidate);
if (error == ESP_OK) error = mdns_config_validate(&candidate);
if (error == ESP_OK) { s_config = candidate; ++s_config_generation; }
}
unlock_service();
return error;
}
esp_err_t mdns_service_start(void)
{
if (s_mutex == NULL) {
@@ -96,7 +139,9 @@ esp_err_t mdns_service_start(void)
if (s_component_initialized) {
s_announced = true;
unlock_service();
return ESP_OK;
/* A suffix staged while offline must reach the already-created responder
* when the next STA IP arrives, even if its reannounce command ran offline. */
return mdns_service_reannounce();
}
if (s_initialization_failed) {
esp_err_t error = s_last_error;
+11
View File
@@ -9,6 +9,7 @@
#include "mdns_config.h"
typedef struct {
uint32_t config_generation;
bool initialized;
bool announced;
char suffix[MDNS_CONFIG_SUFFIX_MAX_LEN + 1U];
@@ -21,6 +22,16 @@ esp_err_t mdns_service_get_config(mdns_config_t *config);
esp_err_t mdns_service_set_config(const mdns_config_t *config);
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *snapshot);
/* Zero-wait secret-free projection for HTTPD; ESP_ERR_TIMEOUT on contention. */
esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *snapshot);
typedef enum { MDNS_SETTINGS_SET, MDNS_SETTINGS_SAVE, MDNS_SETTINGS_LOAD,
MDNS_SETTINGS_DEFAULTS } mdns_settings_action_t;
/* Dispatcher-only. Check generation and mutate/persist under the service mutex.
* ESP_ERR_NOT_FOUND is stale. LOAD may select deterministic MAC defaults (stored
* reports that distinction). Caller separately queues manager reannouncement. */
esp_err_t mdns_service_update_current(uint32_t generation, mdns_settings_action_t action,
const mdns_config_t *config, bool *stored);
/* Only wifi_manager may call these lifecycle operations. */
esp_err_t mdns_service_start(void);
void mdns_service_stop(void);
+446
View File
@@ -0,0 +1,446 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_network_settings.h"
#include <inttypes.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "mdns_service.h"
#include "secure_random.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
#include "wifi_manager.h"
enum { WIFI_PATCH, PROFILE_PATCH, WIFI_SAVE, WIFI_LOAD, START, STOP, RECONNECT,
NEXT_PROFILE, MDNS_SET, MDNS_SAVE, MDNS_LOAD, MDNS_DEFAULTS, ACTION_COUNT };
static const char *const s_actions[] = {"wifi-patch", "profile-patch", "wifi-save", "wifi-load",
"start", "stop", "reconnect", "next-profile", "mdns-set", "mdns-save", "mdns-load", "mdns-defaults"};
enum { IDLE, PENDING, ACCEPTED, OK, FAILED, CANCELLED, STALE, INVALID,
LOADED_DEFAULTS, APPLIED_NOT_QUEUED };
static const char *const s_states[] = {"idle", "pending", "accepted", "ok", "failed", "cancelled",
"stale", "invalid", "loaded_defaults", "applied_not_queued"};
typedef struct {
uint32_t id, generation;
web_session_id_t session;
user_principal_t principal;
int64_t deadline;
unsigned action, state;
esp_err_t error;
bool executing;
wifi_manager_patch_t patch;
mdns_config_t mdns;
} network_operation_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static network_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 void wipe_input(network_operation_t *operation)
{
secure_wipe(&operation->principal, sizeof(operation->principal));
secure_wipe(&operation->patch, sizeof(operation->patch));
secure_wipe(&operation->mdns, sizeof(operation->mdns));
operation->generation = 0;
}
static void expire_input(void *unused)
{
(void)unused;
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
if (s_operation.state == PENDING && !s_operation.executing && now >= s_operation.deadline) {
s_operation.state = CANCELLED;
wipe_input(&s_operation);
}
taskEXIT_CRITICAL(&s_lock);
}
static bool ensure_secret_timer(void)
{
/* Sole HTTPD admission owner; one firmware-lifetime timer, no extra task.
* Periodic inspection avoids an old captured expiry cancelling a newer ID. */
if (!s_secret_timer) {
const esp_timer_create_args_t args = {.callback = expire_input, .name = "network-input"};
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;
}
typedef struct { const char *body; size_t size, pos; } parser_t;
static void space(parser_t *p)
{
while (p->pos < p->size && (p->body[p->pos] == ' ' || p->body[p->pos] == '\r' ||
p->body[p->pos] == '\n' || p->body[p->pos] == '\t')) ++p->pos;
}
static bool take(parser_t *p, char c)
{
space(p);
return p->pos < p->size && p->body[p->pos++] == c;
}
static int hex_digit(unsigned char c)
{
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
/* Bounded byte-string decoder, deliberately not Unicode-to-UTF8 conversion.
* See the public contract: \\u00ff is exactly one SSID byte, not two. */
static bool byte_string(parser_t *p, uint8_t *out, size_t capacity, size_t *length)
{
*length = 0;
if (!take(p, '"')) return false;
while (p->pos < p->size) {
unsigned char c = (unsigned char)p->body[p->pos++];
if (c == '"') return true;
if (c < 0x20 || c > 0x7e || *length == capacity) return false;
if (c == '\\') {
if (p->pos == p->size) return false;
c = (unsigned char)p->body[p->pos++];
switch (c) {
case '"': case '\\': case '/': break;
case 'b': c = '\b'; break;
case 'f': c = '\f'; break;
case 'n': c = '\n'; break;
case 'r': c = '\r'; break;
case 't': c = '\t'; break;
case 'u': {
if (p->size - p->pos < 4 || p->body[p->pos] != '0' || p->body[p->pos + 1] != '0') return false;
int high = hex_digit(p->body[p->pos + 2]), low = hex_digit(p->body[p->pos + 3]);
if (high < 0 || low < 0) return false;
c = (unsigned char)(high * 16 + low); p->pos += 4; break;
}
default: return false;
}
}
out[(*length)++] = c;
}
return false;
}
static bool number(parser_t *p, uint32_t *out)
{
space(p); size_t start = p->pos; *out = 0;
while (p->pos < p->size && p->body[p->pos] >= '0' && p->body[p->pos] <= '9') {
unsigned digit = (unsigned)(p->body[p->pos++] - '0');
if (*out > (UINT32_MAX - digit) / 10) return false;
*out = *out * 10 + digit;
}
return p->pos > start && (p->pos - start == 1 || p->body[start] != '0');
}
static bool boolean(parser_t *p, uint32_t *out)
{
space(p);
if (p->size - p->pos >= 4 && !memcmp(p->body + p->pos, "true", 4)) { p->pos += 4; *out = 1; return true; }
if (p->size - p->pos >= 5 && !memcmp(p->body + p->pos, "false", 5)) { p->pos += 5; *out = 0; return true; }
return false;
}
static bool parse_request(const char *body, size_t length, network_operation_t *operation)
{
enum { ACTION, GENERATION, PROFILE, ENABLED, PRIORITY, SECURITY, SSID, PASSWORD,
CLEAR_PASSWORD, BOOT, POLICY, CHANNEL, SUFFIX, KEY_COUNT };
static const char *const keys[] = {"action", "generation", "profile", "enabled", "priority", "security",
"ssid", "password", "clear_password", "enabled_at_boot", "ap_policy", "channel", "suffix"};
parser_t p = {.body = body, .size = length};
uint32_t seen = 0;
operation->action = ACTION_COUNT;
operation->patch.profile = -1;
if (!take(&p, '{')) return false;
for (unsigned field = 0; field < KEY_COUNT; ++field) {
uint8_t key_text[20] = {0}; size_t n;
if ((field && !take(&p, ',')) || !byte_string(&p, key_text, sizeof(key_text), &n)) return false;
unsigned key = 0;
for (; key < KEY_COUNT; ++key) if (strlen(keys[key]) == n && !memcmp(keys[key], key_text, n)) break;
if (key == KEY_COUNT || (seen & (1U << key)) || !take(&p, ':')) return false;
seen |= 1U << key;
uint32_t value = 0;
uint8_t text[64] = {0};
bool valid;
if (key == GENERATION || key == PROFILE || key == PRIORITY || key == CHANNEL) valid = number(&p, &value);
else if (key == ENABLED || key == CLEAR_PASSWORD || key == BOOT) valid = boolean(&p, &value);
else valid = byte_string(&p, text, sizeof(text) - 1, &n);
if (!valid) { secure_wipe(text, sizeof(text)); return false; }
switch (key) {
case ACTION:
for (unsigned i = 0; i < ACTION_COUNT; ++i)
if (strlen(s_actions[i]) == n && !memcmp(s_actions[i], text, n)) operation->action = i;
valid = operation->action != ACTION_COUNT; break;
case GENERATION: operation->generation = value; valid = value != 0; break;
case PROFILE: valid = value < WIFI_CONFIG_STA_PROFILE_COUNT; operation->patch.profile = (int8_t)value; break;
case ENABLED: operation->patch.enabled = value; operation->patch.fields |= WIFI_PATCH_ENABLED; break;
case PRIORITY: valid = value <= UINT8_MAX; operation->patch.priority = value; operation->patch.fields |= WIFI_PATCH_PRIORITY; break;
case SECURITY:
valid = !memchr(text, 0, n) && wifi_config_parse_security((char *)text, &operation->patch.security);
operation->patch.fields |= WIFI_PATCH_SECURITY; break;
case SSID:
valid = n <= WIFI_CONFIG_SSID_MAX_LEN;
if (valid) { memcpy(operation->patch.ssid, text, n); operation->patch.ssid_len = n; }
operation->patch.fields |= WIFI_PATCH_SSID; break;
case PASSWORD:
valid = n >= WIFI_CONFIG_PSK_MIN_LEN && n <= WIFI_CONFIG_PSK_MAX_LEN;
for (size_t i = 0; valid && i < n; ++i) valid = text[i] >= 0x20 && text[i] <= 0x7e;
if (valid) { memcpy(operation->patch.password, text, n); operation->patch.password_len = n; }
operation->patch.fields |= WIFI_PATCH_PASSWORD; break;
case CLEAR_PASSWORD: valid = value == 1; operation->patch.fields |= WIFI_PATCH_PASSWORD; break;
case BOOT: operation->patch.enabled_at_boot = value; operation->patch.fields |= WIFI_PATCH_BOOT; break;
case POLICY:
valid = !memchr(text, 0, n) && wifi_config_parse_ap_policy((char *)text, &operation->patch.ap_policy);
operation->patch.fields |= WIFI_PATCH_POLICY; break;
case CHANNEL: valid = value >= WIFI_CONFIG_AP_CHANNEL_MIN && value <= WIFI_CONFIG_AP_CHANNEL_MAX;
operation->patch.ap_channel = value; operation->patch.fields |= WIFI_PATCH_CHANNEL; break;
case SUFFIX:
valid = n <= MDNS_CONFIG_SUFFIX_MAX_LEN;
if (valid) {
operation->mdns.schema_version = MDNS_CONFIG_SCHEMA_VERSION;
operation->mdns.blob_size = MDNS_CONFIG_BLOB_SIZE;
operation->mdns.suffix_len = n; memcpy(operation->mdns.suffix, text, n);
valid = mdns_config_validate(&operation->mdns) == ESP_OK;
}
break;
}
secure_wipe(text, sizeof(text));
if (!valid) return false;
space(&p);
if (p.pos < p.size && p.body[p.pos] == '}') break;
}
if (!take(&p, '}')) return false;
space(&p);
if (p.pos != p.size || !(seen & 1U) ||
((seen & (1U << PASSWORD)) && (seen & (1U << CLEAR_PASSWORD)))) return false;
uint32_t required = 1U, allowed = 1U;
if (operation->action == WIFI_PATCH || operation->action == PROFILE_PATCH) {
required |= 1U << GENERATION;
allowed = required | (1U << SSID) | (1U << PASSWORD) | (1U << CLEAR_PASSWORD);
if (operation->action == PROFILE_PATCH) {
required |= 1U << PROFILE;
allowed |= (1U << PROFILE) | (1U << ENABLED) | (1U << PRIORITY) | (1U << SECURITY);
} else allowed |= (1U << BOOT) | (1U << POLICY) | (1U << CHANNEL);
if (!operation->patch.fields) return false;
} else if (operation->action == WIFI_SAVE || operation->action == WIFI_LOAD || operation->action >= MDNS_SET) {
required |= 1U << GENERATION;
if (operation->action == MDNS_SET) required |= 1U << SUFFIX;
allowed = required;
}
return operation->action < ACTION_COUNT && (seen & required) == required && !(seen & ~allowed);
}
void web_network_settings_execute(uint32_t id)
{
network_operation_t operation = {0};
taskENTER_CRITICAL(&s_lock);
bool claimed = id && s_operation.id == id && s_operation.state == PENDING && !s_operation.executing;
if (claimed) {
s_operation.executing = true;
operation = s_operation;
wipe_input(&s_operation);
}
taskEXIT_CRITICAL(&s_lock);
if (!claimed) 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) {
state = ACCEPTED;
switch (operation.action) {
case WIFI_PATCH: case PROFILE_PATCH: error = wifi_manager_patch_current(operation.generation, &operation.patch); break;
case WIFI_SAVE: error = wifi_manager_save_current(operation.generation); state = OK; break;
case WIFI_LOAD: error = wifi_manager_load_current(operation.generation); break;
case START: error = wifi_manager_start(); break;
case STOP: error = wifi_manager_stop(); break;
case RECONNECT: error = wifi_manager_reconnect(); break;
case NEXT_PROFILE: error = wifi_manager_next_profile(); break;
default: {
bool stored = true;
mdns_settings_action_t action = operation.action == MDNS_SET ? MDNS_SETTINGS_SET :
operation.action == MDNS_SAVE ? MDNS_SETTINGS_SAVE :
operation.action == MDNS_LOAD ? MDNS_SETTINGS_LOAD : MDNS_SETTINGS_DEFAULTS;
error = mdns_service_update_current(operation.generation, action, &operation.mdns, &stored);
if (error == ESP_OK) {
if (action == MDNS_SETTINGS_SAVE) state = OK;
else {
error = wifi_manager_mdns_reannounce();
state = error != ESP_OK ? APPLIED_NOT_QUEUED : stored ? ACCEPTED : LOADED_DEFAULTS;
}
}
break;
}
}
if (error != ESP_OK && state != APPLIED_NOT_QUEUED)
state = error == ESP_ERR_NOT_FOUND ? STALE : error == ESP_ERR_INVALID_ARG ? INVALID : FAILED;
}
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == PENDING) {
s_operation.state = state;
s_operation.error = error;
s_operation.executing = false;
}
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 authorize(httpd_req_t *request, bool mutation, web_session_view_t *view, bool *allowed)
{
esp_err_t error = mutation ? web_cookie_auth_require_json(request, WEB_NETWORK_REQUEST_MAX, view, allowed) :
web_cookie_auth_require(request, false, false, view, allowed);
if (error == ESP_OK && *allowed && view->principal.role != USER_ROLE_ADMIN) {
*allowed = false;
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
}
return error;
}
static bool append(char *out, size_t capacity, size_t *used, const char *format, ...)
{
va_list args; va_start(args, format);
int count = vsnprintf(out + *used, capacity - *used, format, args);
va_end(args);
if (count < 0 || (size_t)count >= capacity - *used) return false;
*used += (size_t)count; return true;
}
static bool append_ssid(char *out, size_t capacity, size_t *used, const uint8_t *ssid, size_t length)
{
if (length > WIFI_CONFIG_SSID_MAX_LEN || !append(out, capacity, used, "\"")) return false;
for (size_t i = 0; i < length; ++i) {
unsigned c = ssid[i];
if (c >= 0x20 && c <= 0x7e && c != '"' && c != '\\') {
if (!append(out, capacity, used, "%c", c)) return false;
} else if (!append(out, capacity, used, "\\u%04x", c)) return false;
}
return append(out, capacity, used, "\"");
}
static const char *json_bool(bool value) { return value ? "true" : "false"; }
static esp_err_t snapshot_response(httpd_req_t *request)
{
wifi_manager_settings_t wifi;
mdns_service_snapshot_t mdns;
/* No blocking config getters, driver/NVS calls or secret-bearing copies on HTTPD. */
if (wifi_manager_get_settings(&wifi) != ESP_OK || mdns_service_get_settings(&mdns) != ESP_OK)
return respond(request, "503 Service Unavailable", "{\"error\":\"snapshot_unavailable\"}");
char response[WEB_NETWORK_SNAPSHOT_MAX]; size_t used = 0;
#define ADD(...) do { if (!append(response, sizeof(response), &used, __VA_ARGS__)) return ESP_FAIL; } while (0)
#define SSID(data, length) do { if (!append_ssid(response, sizeof(response), &used, data, length)) return ESP_FAIL; } while (0)
ADD("{\"wifi\":{\"generation\":%" PRIu32 ",\"enabled_at_boot\":%s,\"ap\":{\"policy\":\"%s\",\"channel\":%u,\"ssid\":",
wifi.runtime.config_generation, json_bool(wifi.enabled_at_boot),
wifi_config_ap_policy_to_string(wifi.ap_policy), (unsigned)wifi.ap_channel);
SSID(wifi.ap_ssid, wifi.ap_ssid_len);
ADD(",\"password_configured\":%s},\"profiles\":[", json_bool(wifi.ap_password_configured));
for (unsigned i = 0; i < WIFI_CONFIG_STA_PROFILE_COUNT; ++i) {
const wifi_manager_profile_settings_t *p = &wifi.profiles[i];
ADD("%s{\"index\":%u,\"enabled\":%s,\"priority\":%u,\"security\":\"%s\",\"ssid\":",
i ? "," : "", i, json_bool(p->enabled), (unsigned)p->priority, wifi_config_security_to_string(p->security));
SSID(p->ssid, p->ssid_len);
ADD(",\"password_configured\":%s}", json_bool(p->password_configured));
}
const wifi_manager_snapshot_t *r = &wifi.runtime;
/* IPv4 bytes are already in network order, independent of host endianness. */
const uint8_t *ip = (const uint8_t *)&r->ip;
ADD("]},\"runtime\":{\"started\":%s,\"state\":\"%s\",\"active_profile\":%d,\"ip\":\"%u.%u.%u.%u\","
"\"ap_running\":%s,\"ap_clients\":%u,\"last_error\":%d},",
json_bool(r->started), wifi_manager_state_to_string(r->state), (int)r->active_profile,
ip[0], ip[1], ip[2], ip[3], json_bool(r->ap_running), (unsigned)r->ap_client_count, (int)r->last_error);
ADD("\"mdns\":{\"generation\":%" PRIu32 ",\"suffix\":\"%s\",\"hostname\":\"%s\",\"announced\":%s,\"last_error\":%d}}",
mdns.config_generation, mdns.suffix, mdns.hostname, json_bool(mdns.announced), (int)mdns.last_error);
#undef SSID
#undef ADD
return respond(request, "200 OK", response);
}
esp_err_t web_network_snapshot_handler(httpd_req_t *request)
{
web_session_view_t view = {0}; bool allowed = false;
esp_err_t error = authorize(request, false, &view, &allowed);
if (error == ESP_OK && allowed) {
error = request->method == HTTP_GET ? snapshot_response(request) :
respond(request, "405 Method Not Allowed", "{\"error\":\"method\"}");
}
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
esp_err_t web_network_operation_handler(httpd_req_t *request)
{
web_session_view_t view = {0}; bool allowed = false;
network_operation_t operation = {0};
bool mutation = request->method == HTTP_POST;
esp_err_t error = authorize(request, mutation, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (request->method != HTTP_GET && !mutation) {
error = respond(request, "405 Method Not Allowed", "{\"error\":\"method\"}"); goto done;
}
if (mutation) {
char type[40] = {0}, body[WEB_NETWORK_REQUEST_MAX]; size_t received = 0;
bool valid = request->content_len > 0 && 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_request(body, received, &operation);
secure_wipe(body, sizeof(body));
if (!valid) {
wipe_input(&operation);
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_network_request\"}"); goto done;
}
if (!ensure_secret_timer()) {
wipe_input(&operation);
error = respond(request, "503 Service Unavailable", "{\"error\":\"timer_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);
wipe_input(&operation);
if (busy || admin_ssh_console_submit_network_settings(operation.id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
taskEXIT_CRITICAL(&s_lock);
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; operation.error = s_operation.error;
}
taskEXIT_CRITICAL(&s_lock);
}
/* Input is not needed for formatting or potentially blocking socket IO. */
wipe_input(&operation);
char response[128];
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\",\"error\":%d}",
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state], (int)operation.error);
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;
}
+32
View File
@@ -0,0 +1,32 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
#define WEB_NETWORK_REQUEST_MAX 768U
#define WEB_NETWORK_SNAPSHOT_MAX 2048U
/* Integration: optional exact GET /api/settings/network -> snapshot_handler;
* exact GET and POST /api/settings/network-operation -> operation_handler.
* All require current admin cookie, same Origin; POST additionally CSRF/JSON.
* No query strings. No changes to browser-shell command authorization.
*
* One session-bound replaceable result, no durable history/idempotency. Only an
* ID enters the existing dispatcher. A periodic one-second ESP timer wipes and
* cancels non-executing input at 30 seconds plus scheduling latency. Executing
* locals wipe on return; already-admitted work can finish after session loss.
* 'accepted' means RAM/owner queue admission, NEVER association or DHCP success.
*
* SSID JSON is a BYTE string: raw printable ASCII, standard single-character
* JSON escapes, and \\u00HH only; each decoded codepoint maps to one byte. NUL and
* non-UTF-8 bytes round-trip. No raw non-ASCII, other Unicode or surrogates. UI
* must encode UTF-8 text into bytes before encoding this field, and retain a
* reversible byte editor for existing arbitrary SSIDs. Length limit: 32 bytes.
* No saved PSK/length is returned, only password_configured. Omitted password
* preserves current bytes; clear_password:true is distinct from replacement.
* Enabled STA requires a PSK; AP clear/open is always rejected, even policy off.
* Wi-Fi Load is stored-only, no generated-default/reset/secret-delivery route.
*/
esp_err_t web_network_snapshot_handler(httpd_req_t *request);
esp_err_t web_network_operation_handler(httpd_req_t *request);
void web_network_settings_execute(uint32_t id);
+17 -2
View File
@@ -25,6 +25,7 @@
#include "web_serial_transport.h"
#include "web_serial_settings.h"
#include "web_account_settings.h"
#include "web_network_settings.h"
#include "web_admin_transport.h"
#include "web_session_store.h"
#include "web_cookie_auth.h"
@@ -409,6 +410,16 @@ static const httpd_uri_t s_account_generate_password_uri = {
.handler = web_account_generate_password_handler,
};
static const httpd_uri_t s_network_uri = {
.uri = "/api/settings/network", .method = HTTP_GET, .handler = web_network_snapshot_handler,
};
static const httpd_uri_t s_network_operation_get_uri = {
.uri = "/api/settings/network-operation", .method = HTTP_GET, .handler = web_network_operation_handler,
};
static const httpd_uri_t s_network_operation_post_uri = {
.uri = "/api/settings/network-operation", .method = HTTP_POST, .handler = web_network_operation_handler,
};
static const httpd_uri_t s_root_uri = {
.uri = "/",
.method = HTTP_GET,
@@ -624,7 +635,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]) + 10U;
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 13U;
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
config.httpd.lru_purge_enable = false;
config.httpd.recv_wait_timeout = 1;
@@ -683,7 +694,11 @@ esp_err_t web_server_start(void)
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);
(void)web_httpd_register_optional(server, &s_account_keys_uri);
(void)web_httpd_register_optional(server, &s_account_keys_uri);
if (web_httpd_register_optional_get(server, &s_network_uri) == ESP_OK &&
web_httpd_register_optional_get(server, &s_network_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_network_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_network_operation_get_uri.uri, HTTP_GET);
}
if (error != ESP_OK) {
web_cookie_auth_stop();
+300 -4
View File
@@ -85,7 +85,7 @@ static const char s_index_html[] =
".settings-values dt{color:var(--muted)}.settings-values dd{margin:0;overflow-wrap:anywhere}\n"
".serial-edit{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;max-width:600px}"
".serial-edit label{display:grid;gap:4px;color:var(--muted)}.serial-edit input,.serial-edit select{font:inherit;width:100%;min-width:0;padding:8px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px}"
".serial-actions{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}\n"
".serial-actions{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}#network-summary{white-space:pre-wrap;overflow-wrap:anywhere}\n"
".terminal-toolbar{flex-wrap:wrap}.terminal-toolbar .button{min-height:32px;padding:4px 10px}\n"
"@media(max-width:850px){html,body{overflow:auto}.page{height:auto;min-height:100dvh;grid-template-rows:auto auto minmax(280px,1fr)}"
".terminal-panel{min-height:280px}.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}"
@@ -179,7 +179,53 @@ static const char s_index_html[] =
"<div id=\"admin-terminal\" class=\"terminal-host\" hidden></div>\n"
"<section id=\"serial-settings\" class=\"settings-page\" aria-label=\"Serial settings\" hidden>"
"<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>"
"<button id=\"settings-accounts\" class=\"button\" type=\"button\" aria-pressed=\"false\">Accounts</button>"
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button></div>"
"<div id=\"network-settings\" hidden><h2>Network</h2>"
"<p>Edits apply to RAM only. Save persists the device working configuration, NOT browser drafts. Refresh discards drafts. "
"Wi-Fi Load uses stored configuration only; missing or invalid storage leaves RAM unchanged. No Wi-Fi defaults/reset. "
"Start/Stop also change RAM enabled-at-boot; Save persists that policy. Reconnect/Next do nothing while stopped. "
"Next selects the next enabled profile in priority order, wrapping.</p>"
"<p>Network changes may disconnect HTTPS, SSH and both browser terminals before acknowledgement. Accepted is NOT connected. "
"Recover through STA/AP, UART0 or network-independent native USB serial. Navigation itself preserves terminals and writer lease.</p>"
"<button id=\"network-refresh\" class=\"button\" type=\"button\">Refresh network</button>"
"<p id=\"network-detail\" role=\"status\"></p><pre id=\"network-summary\"></pre>"
"<div id=\"network-edit\" hidden><h3>Wi-Fi working configuration</h3><div class=\"serial-edit\">"
"<label>Target<select id=\"network-target\"><option value=\"ap\">Access point / boot policy</option>"
"<option value=\"0\">STA profile 0</option><option value=\"1\">STA profile 1</option>"
"<option value=\"2\">STA profile 2</option><option value=\"3\">STA profile 3</option></select></label>"
"<label id=\"network-boot-label\">Enabled at boot<input id=\"network-boot\" type=\"checkbox\"></label>"
"<label id=\"network-policy-label\">AP policy<select id=\"network-policy\"><option>off</option><option>fallback</option><option>always</option></select></label>"
"<label id=\"network-channel-label\">AP channel<input id=\"network-channel\" type=\"number\" min=\"1\" max=\"11\" step=\"1\"></label>"
"<label id=\"network-enabled-label\">Profile enabled<input id=\"network-enabled\" type=\"checkbox\"></label>"
"<label id=\"network-priority-label\">Priority<input id=\"network-priority\" type=\"number\" min=\"0\" max=\"255\" step=\"1\"></label>"
"<label id=\"network-security-label\">Security<select id=\"network-security\"><option value=\"mixed\">WPA2 or stronger (mixed)</option><option value=\"wpa3\">WPA3</option></select></label>"
"<label>SSID editor<select id=\"network-ssid-mode\"><option value=\"text\">Ordinary text (UTF-8)</option><option value=\"hex\">Exact bytes (hex)</option></select></label>"
"<label>SSID<input id=\"network-ssid\" maxlength=\"256\" autocomplete=\"off\" spellcheck=\"false\"></label>"
"<label>Password action<select id=\"network-password-mode\"><option value=\"keep\">Keep existing (default)</option>"
"<option value=\"replace\">Replace</option><option id=\"network-password-clear\" value=\"clear\">Clear (disabled STA only)</option></select></label>"
"<label>New password<input id=\"network-password\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\" disabled></label></div>"
"<p id=\"network-password-status\"></p><p id=\"network-ssid-detail\" role=\"status\"></p>"
"<p>SSID maximum: 32 bytes, not characters. Text entry encodes UTF-8 once; hex preserves arbitrary bytes including zero. "
"Switching modes is lossless or refused. Password replacement requires 863 printable ASCII characters; blank never clears. "
"AP always requires a PSK. STA clear requires disabled state. Transient passwords expire after 60 seconds and clear on submission or context change. "
"NVS is unencrypted; replacement/clear is not secure flash erasure. JavaScript cannot securely zero strings.</p>"
"<button id=\"network-apply\" class=\"button\" type=\"button\">Apply selected Wi-Fi target to RAM</button>"
"<div class=\"serial-actions\"><button id=\"network-wifi-save\" class=\"button\" type=\"button\">Save working Wi-Fi to NVS</button>"
"<button id=\"network-wifi-load\" class=\"button\" type=\"button\">Load stored Wi-Fi</button>"
"<button id=\"network-start\" class=\"button\" type=\"button\">Start</button><button id=\"network-stop\" class=\"button\" type=\"button\">Stop</button>"
"<button id=\"network-reconnect\" class=\"button\" type=\"button\">Reconnect</button><button id=\"network-next-profile\" class=\"button\" type=\"button\">Next profile</button></div>"
"<h3>mDNS</h3><p>STA-only responder. Expected announcement is not client-verified DNS. "
"Suffix edits, Load and Defaults change RAM and request live reannouncement; queue failure can leave RAM changed. "
"Offline edits are used on the next STA IP. mDNS Load may select deterministic MAC defaults. Save explicitly persists.</p>"
"<div class=\"serial-edit\"><label>Hostname suffix (sak-… .local)<input id=\"network-suffix\" maxlength=\"55\" autocomplete=\"off\" spellcheck=\"false\"></label></div>"
"<p>155 lowercase ASCII letters, digits or hyphens; no leading/trailing hyphen.</p><div class=\"serial-actions\">"
"<button id=\"network-mdns-set\" class=\"button\" type=\"button\">Apply mDNS suffix to RAM</button>"
"<button id=\"network-mdns-save\" class=\"button\" type=\"button\">Save working mDNS to NVS</button>"
"<button id=\"network-mdns-load\" class=\"button\" type=\"button\">Load mDNS</button>"
"<button id=\"network-mdns-defaults\" class=\"button\" type=\"button\">mDNS defaults in RAM</button></div></div>"
"<button id=\"network-result\" class=\"button\" type=\"button\">Check network operation result</button>"
"<p id=\"network-operation-detail\" role=\"status\">After uncertainty, Check Result and Refresh. Never assume timeout or navigation cancels work; no automatic mutation retry.</p></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>"
@@ -319,7 +365,7 @@ static const char s_app_js[] =
" element('serial-result').disabled = busy;\n"
"}\n"
"function clearSettings() {\n"
" clearAccounts();\n"
" clearAccounts(); clearNetwork();\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"
@@ -332,6 +378,7 @@ static const char s_app_js[] =
"}\n"
"async function refreshSettings() {\n"
" if (settingsDomain === 'accounts') return refreshAccounts();\n"
" if (settingsDomain === 'network') return refreshNetwork();\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"
@@ -501,7 +548,8 @@ static const char s_app_js[] =
"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('serial-settings-content').hidden = domain !== 'serial'; element('account-settings').hidden = domain !== 'accounts'; element('network-settings').hidden = domain !== 'network';\n"
" element('settings-network').setAttribute('aria-pressed', String(domain === 'network'));\n"
" element('settings-serial').setAttribute('aria-pressed', String(domain === 'serial')); element('settings-accounts').setAttribute('aria-pressed', String(domain === 'accounts'));\n"
" refreshSettings();\n"
"}\n"
@@ -624,6 +672,254 @@ static const char s_app_js[] =
"for (const action of ['key-add','key-delete','key-clear']) element('account-' + action).addEventListener('click', () => accountOperation(action));\n"
"element('account-key-index').addEventListener('change', clearAccountSecret);\n"
"element('account-target').addEventListener('change', () => { clearAccountSecret(); clearAccountKeys(); element('account-role').value = accounts[Number(element('account-target').value)]?.role || 'user'; accountButtons(); });\n"
"// Network has independent request ownership and a replaceable, login-bound result.\n"
"const networkActions = ['wifi-patch','profile-patch','wifi-save','wifi-load','start','stop','reconnect','next-profile','mdns-set','mdns-save','mdns-load','mdns-defaults'];\n"
"const networkControls = ['apply', ...networkActions.filter(a => !a.endsWith('-patch'))];\n"
"const networkFields = ['target','boot','policy','channel','enabled','priority','security','ssid-mode','ssid','password-mode','suffix'];\n"
"let networkSnapshot = null, networkFresh = false, networkAbort = null, networkId = 0, networkPending = false, networkAwaitingAck = false, networkWarning = '';\n"
"let networkSecretTimer = null, networkSecretUntil = 0, networkSecretContext = '', networkSSIDMode = 'text', networkAction = '';\n"
"const net = id => element('network-' + id);\n"
"const netInteger = (n, low, high) => Number.isInteger(n) && n >= low && n <= high;\n"
"const netShape = (v, keys) => v !== null && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === keys.length && keys.every(k => Object.hasOwn(v, k));\n"
"const netSuffix = s => typeof s === 'string' && /^[a-z0-9](?:[a-z0-9-]{0,53}[a-z0-9])?$/.test(s);\n"
"const netBytes = s => typeof s === 'string' && s.length <= 32 && /^[\\x00-\\xff]*$/.test(s);\n"
"function networkLive() { return selected === 'settings' && settingsDomain === 'network' && accountRole === 'admin' && sessionVerified && !suspended && !unloading && !navigating && !loggingOut; }\n"
"function validateNetwork(v) {\n"
" if (!netShape(v, ['wifi','runtime','mdns'])) return false;\n"
" const w = v.wifi, r = v.runtime, m = v.mdns;\n"
" return netShape(w, ['generation','enabled_at_boot','ap','profiles']) && netInteger(w.generation, 1, 4294967295) && typeof w.enabled_at_boot === 'boolean' &&\n"
" netShape(w.ap, ['policy','channel','ssid','password_configured']) && ['off','fallback','always'].includes(w.ap.policy) && netInteger(w.ap.channel, 1, 11) && netBytes(w.ap.ssid) && w.ap.ssid.length > 0 && w.ap.password_configured === true &&\n"
" Array.isArray(w.profiles) && w.profiles.length === 4 && w.profiles.every((p, i) => netShape(p, ['index','enabled','priority','security','ssid','password_configured']) && p.index === i && typeof p.enabled === 'boolean' && netInteger(p.priority, 0, 255) && ['mixed','wpa3'].includes(p.security) && netBytes(p.ssid) && typeof p.password_configured === 'boolean' && (!p.enabled || p.ssid.length > 0 && p.password_configured) && (p.ssid.length > 0 || !p.password_configured)) &&\n"
" netShape(r, ['started','state','active_profile','ip','ap_running','ap_clients','last_error']) && typeof r.started === 'boolean' && ['stopped','starting','connecting','waiting-ip','online','backoff','ap-only','error','unknown'].includes(r.state) && netInteger(r.active_profile, -1, 3) && typeof r.ip === 'string' && r.ip.length <= 15 && (r.ip === '' || /^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$/.test(r.ip) && r.ip.split('.').every(n => Number(n) <= 255)) && typeof r.ap_running === 'boolean' && netInteger(r.ap_clients, 0, 255) && netInteger(r.last_error, -2147483648, 2147483647) &&\n"
" netShape(m, ['generation','suffix','hostname','announced','last_error']) && netInteger(m.generation, 1, 4294967295) && netSuffix(m.suffix) && m.hostname === 'sak-' + m.suffix && typeof m.announced === 'boolean' && netInteger(m.last_error, -2147483648, 2147483647);\n"
"}\n"
"function networkContext() { return JSON.stringify([networkSnapshot?.wifi.generation, networkSnapshot?.mdns.generation, ...networkFields.map(id => [net(id).value, net(id).checked])]); }\n"
"function clearNetworkSecret() {\n"
" window.clearTimeout(networkSecretTimer); networkSecretTimer = null; networkSecretUntil = 0; networkSecretContext = '';\n"
" net('password').value = ''; net('password-mode').value = 'keep'; net('password').disabled = true;\n"
"}\n"
"function changeNetworkContext() {\n"
" clearNetworkSecret();\n"
" if (networkAbort) {\n"
" networkAbort.abort(); networkAbort = null; networkFresh = false;\n"
" net('detail').textContent = 'Context changed; snapshot stale. Refresh before editing.';\n"
" if (networkPending) net('operation-detail').textContent = networkWarning + 'Outcome pending or unknown. Context changes do not cancel backend work. Check Result and Refresh; never automatically resubmit.';\n"
" }\n"
"}\n"
"function networkButtons() {\n"
" const busy = !!networkAbort, blocked = busy || networkPending || !networkFresh || !networkSnapshot;\n"
" for (const id of networkControls) net(id).disabled = blocked;\n"
" for (const id of networkFields) net(id).disabled = blocked;\n"
" net('password').disabled = blocked || net('password-mode').value !== 'replace';\n"
" net('refresh').disabled = net('result').disabled = busy;\n"
" net('password-clear').hidden = net('password-clear').disabled = net('target').value === 'ap';\n"
"}\n"
"function clearNetwork() {\n"
" clearNetworkSecret();\n"
" if (networkAbort) networkAbort.abort(); networkAbort = null;\n"
" networkSnapshot = null; networkFresh = false;\n"
" net('summary').textContent = ''; net('edit').hidden = true;\n"
" for (const id of ['ssid','suffix','channel','priority']) net(id).value = '';\n"
" net('detail').textContent = 'Refresh to read current network settings.';\n"
" if (networkPending) net('operation-detail').textContent = networkWarning + 'Outcome pending or unknown. Check Result on return. Navigation does not cancel work. Recover through STA/AP, UART0 or native USB; never automatically resubmit.';\n"
" networkButtons();\n"
"}\n"
"function networkHex(bytes) { return Array.from(bytes, c => c.charCodeAt(0).toString(16).padStart(2, '0')).join(' '); }\n"
"function networkSSIDSummary(bytes) { return /^[ -~]*$/.test(bytes) ? 'SSID: ' + JSON.stringify(bytes) : 'SSID hex: ' + networkHex(bytes); }\n"
"function networkText(bytes) {\n"
" // Fatal decode plus exact round-trip (including UTF-8 BOM) prevents replacement/double encoding.\n"
" const text = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(Uint8Array.from(bytes, c => c.charCodeAt(0)));\n"
" if (String.fromCharCode(...encoder.encode(text)) !== bytes || /[\\x00-\\x1f\\x7f]/.test(text)) throw new Error('Use hex for control or non-UTF-8 bytes.');\n"
" return text;\n"
"}\n"
"function networkSSID(mode = networkSSIDMode) {\n"
" const input = net('ssid').value;\n"
" let bytes;\n"
" if (mode === 'hex') {\n"
" if (input.length > 96 || !/^(?:[0-9a-fA-F]{2}(?: ?[0-9a-fA-F]{2})*)?$/.test(input.trim())) throw new Error('Enter hex byte pairs, optionally separated by single spaces.');\n"
" bytes = input.trim().replace(/ /g, '').match(/../g) || [];\n"
" bytes = String.fromCharCode(...bytes.map(b => parseInt(b, 16)));\n"
" } else {\n"
" if (input.length > 64 || /[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|(?:^|[^\\uD800-\\uDBFF])[\\uDC00-\\uDFFF]/.test(input)) throw new Error('Invalid UTF-8 text.');\n"
" bytes = String.fromCharCode(...encoder.encode(input));\n"
" }\n"
" if (!netBytes(bytes)) throw new Error('SSID exceeds 32 bytes.');\n"
" return bytes;\n"
"}\n"
"function networkTarget() {\n"
" if (!networkSnapshot) return null;\n"
" const target = net('target').value;\n"
" return target === 'ap' ? networkSnapshot.wifi.ap : /^[0-3]$/.test(target) ? networkSnapshot.wifi.profiles[Number(target)] : null;\n"
"}\n"
"function renderNetworkTarget() {\n"
" clearNetworkSecret();\n"
" const p = networkTarget(); if (!p) return;\n"
" const ap = net('target').value === 'ap';\n"
" for (const id of ['boot','policy','channel']) net(id + '-label').hidden = !ap;\n"
" for (const id of ['enabled','priority','security']) net(id + '-label').hidden = ap;\n"
" net('boot').checked = networkSnapshot.wifi.enabled_at_boot;\n"
" net('policy').value = networkSnapshot.wifi.ap.policy; net('channel').value = String(networkSnapshot.wifi.ap.channel);\n"
" net('enabled').checked = !ap && p.enabled; net('priority').value = ap ? '0' : String(p.priority); net('security').value = ap ? 'mixed' : p.security;\n"
" try { net('ssid').value = networkText(p.ssid); networkSSIDMode = 'text'; }\n"
" catch (_) { net('ssid').value = networkHex(p.ssid); networkSSIDMode = 'hex'; }\n"
" net('ssid-mode').value = networkSSIDMode;\n"
" net('ssid-detail').textContent = 'Loaded ' + p.ssid.length + ' exact bytes. Text means UTF-8; hex means literal bytes.';\n"
" net('password-status').textContent = 'Password configured: ' + (p.password_configured ? 'yes' : 'no') + '. Saved passwords are never returned or prefilled.';\n"
" networkButtons();\n"
"}\n"
"async function refreshNetwork() {\n"
" if (!networkLive() || networkAbort) return;\n"
" clearNetworkSecret(); networkFresh = false;\n"
" const controller = new AbortController(), generation = workGeneration; networkAbort = controller; networkButtons();\n"
" const current = () => networkAbort === controller && networkLive();\n"
" net('detail').textContent = 'Reading network. Previous snapshot is stale; refresh discards drafts.';\n"
" try {\n"
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
" const {payload, status} = await api('/api/settings/network', generation, {signal: controller.signal, limit: 2048, current});\n"
" if (status !== 200 || !validateNetwork(payload)) throw new Error('Invalid network snapshot');\n"
" networkSnapshot = payload; networkFresh = true;\n"
" const w = payload.wifi, r = payload.runtime, m = payload.mdns;\n"
" net('summary').textContent = 'Wi-Fi generation ' + w.generation + '; boot enabled: ' + w.enabled_at_boot + '\\nAP ' + w.ap.policy + ', channel ' + w.ap.channel + ', ' + networkSSIDSummary(w.ap.ssid) + ', password configured: ' + w.ap.password_configured + '\\n' +\n"
" w.profiles.map(p => 'STA ' + p.index + ': enabled ' + p.enabled + ', priority ' + p.priority + ', ' + p.security + ', ' + networkSSIDSummary(p.ssid) + ', password configured: ' + p.password_configured).join('\\n') + '\\nRuntime: ' + r.state + ', started ' + r.started + ', active profile ' + r.active_profile + ', IP ' + (r.ip || 'none') + ', AP running ' + r.ap_running + ', AP clients ' + r.ap_clients + ', last error ' + r.last_error + '\\nmDNS generation ' + m.generation + ': ' + m.hostname + '.local; expected announcement ' + m.announced + ', last error ' + m.last_error + '. Not client-verified DNS.';\n"
" if (!['ap','0','1','2','3'].includes(net('target').value)) net('target').value = 'ap';\n"
" renderNetworkTarget(); net('suffix').value = m.suffix; net('edit').hidden = false;\n"
" net('detail').textContent = (networkPending ? 'Snapshot may be stale: outcome pending or unknown. ' : 'Working snapshot refreshed (Wi-Fi and mDNS are separate consistent copies). ') + 'Browser drafts are not saved; Save persists device working state.';\n"
" } catch (error) { if (live(generation) && current()) net('detail').textContent = 'Network snapshot stale or unavailable/invalid. Refresh explicitly to retry. No values inferred.'; }\n"
" finally { if (current()) { networkAbort = null; networkButtons(); } }\n"
"}\n"
"function networkRequest(action) {\n"
" if (!networkFresh || !networkSnapshot) throw new Error('Refresh the network snapshot first.');\n"
" const w = networkSnapshot.wifi, m = networkSnapshot.mdns, p = networkTarget();\n"
" const request = {action};\n"
" if (action.startsWith('wifi-') || action === 'profile-patch') request.generation = w.generation;\n"
" if (action.startsWith('mdns-')) request.generation = m.generation;\n"
" if (action === 'mdns-set') {\n"
" if (!netSuffix(net('suffix').value)) throw new Error('Invalid mDNS suffix.');\n"
" request.suffix = net('suffix').value;\n"
" }\n"
" if (action.endsWith('-patch')) {\n"
" const ap = action === 'wifi-patch';\n"
" if (!p || ap !== (net('target').value === 'ap')) throw new Error('Select a valid target.');\n"
" if (!ap) request.profile = Number(net('target').value);\n"
" const changed = (key, value, previous) => { if (value !== previous) request[key] = value; };\n"
" const integer = (id, max, min = 0) => { const s = net(id).value; if (!/^(?:0|[1-9][0-9]{0,2})$/.test(s) || !netInteger(Number(s), min, max)) throw new Error('Invalid ' + id + '.'); return Number(s); };\n"
" if (ap) {\n"
" changed('enabled_at_boot', net('boot').checked, w.enabled_at_boot);\n"
" if (!['off','fallback','always'].includes(net('policy').value)) throw new Error('Invalid AP policy.');\n"
" changed('ap_policy', net('policy').value, p.policy); changed('channel', integer('channel', 11, 1), p.channel);\n"
" } else {\n"
" changed('enabled', net('enabled').checked, p.enabled); changed('priority', integer('priority', 255), p.priority);\n"
" if (!['mixed','wpa3'].includes(net('security').value)) throw new Error('Invalid security.');\n"
" changed('security', net('security').value, p.security);\n"
" }\n"
" const ssid = networkSSID(); changed('ssid', ssid, p.ssid);\n"
" const mode = net('password-mode').value, password = net('password').value;\n"
" let configured = p.password_configured;\n"
" if (mode === 'replace') {\n"
" if (!networkSecretUntil || performance.now() >= networkSecretUntil || networkSecretContext !== networkContext() || !/^[ -~]{8,63}$/.test(password)) throw new Error('Re-enter an unexpired, context-bound 8\u201363 printable ASCII password. Blank never clears.');\n"
" configured = true;\n"
" } else if (mode === 'clear') {\n"
" if (ap || net('enabled').checked) throw new Error('Only a disabled STA profile password can be cleared.');\n"
" configured = false; request.clear_password = true;\n"
" } else if (mode !== 'keep') throw new Error('Select Keep, Replace or disabled-STA Clear.');\n"
" if ((ap || net('enabled').checked) && (!ssid.length || !configured) || !ssid.length && configured) throw new Error('AP/enabled STA requires SSID and PSK; empty STA SSID requires disabled with no password.');\n"
" // Add the transient secret last, after all validation that could throw.\n"
" if (mode === 'replace') request.password = password;\n"
" if (Object.keys(request).length === (ap ? 2 : 3)) throw new Error('No selected-target changes to apply.');\n"
" }\n"
" return request;\n"
"}\n"
"function networkWire(request) {\n"
" // Escape byte codepoints, not Unicode text: every non-ASCII SSID byte is \\u00HH.\n"
" const body = JSON.stringify(request).replace(/[\\u007f-\\uffff]/g, c => '\\\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));\n"
" if (encoder.encode(body).length > 768) throw new Error('Request exceeds 768 bytes.');\n"
" return body;\n"
"}\n"
"async function networkOperation(action) {\n"
" if (!networkLive() || networkAbort || action && networkPending) return;\n"
" let body, request;\n"
" const detail = net('operation-detail');\n"
" if (action) {\n"
" try {\n"
" if (!networkActions.includes(action)) throw new Error('Invalid action.');\n"
" request = networkRequest(action); body = networkWire(request);\n"
" const disruptive = ['start','stop','reconnect','next-profile','wifi-load'].includes(action) || action === 'wifi-patch' && Object.keys(request).some(k => !['action','generation','enabled_at_boot'].includes(k)) || action === 'profile-patch' && (networkTarget().enabled || net('enabled').checked);\n"
" if ((disruptive || ['mdns-load','mdns-defaults'].includes(action)) && !window.confirm(action + ': ' + (disruptive ? 'May disconnect HTTPS/SSH and BOTH browser terminals before acknowledgement. Accepted is NOT online. Recover through STA/AP, UART0 or native USB. ' : 'Replace working mDNS with loaded/default settings and request reannouncement. ') + 'RAM changes require explicit Save. Continue?')) { body = undefined; return; }\n"
" } catch (error) { body = undefined; detail.textContent = 'Not submitted. ' + error.message; return; }\n"
" finally { clearNetworkSecret(); if (request) request.password = ''; request = null; }\n"
" } else clearNetworkSecret();\n"
" const controller = new AbortController(), generation = workGeneration; networkAbort = controller; networkButtons();\n"
" const current = () => networkAbort === controller && networkLive();\n"
" controller.signal.addEventListener('abort', () => { body = undefined; }, {once: true});\n"
" let deadline, until = Infinity, refresh = false;\n"
" detail.textContent = networkWarning + (action ? 'Submitting once; acknowledgement is not completion or connection.' : 'Reading latest result for this login...');\n"
" net('detail').textContent = 'Snapshot stale until outcome is known and refresh succeeds.'; networkFresh = false;\n"
" const read = async (method, input) => {\n"
" const response = api('/api/settings/network-operation', generation, {method, body: input, signal: controller.signal, limit: 128, current: () => current() && performance.now() < until}); input = undefined;\n"
" const {payload: r, status} = await response;\n"
" if (performance.now() >= until || status !== (method === 'POST' ? 202 : 200) || !netShape(r, ['id','action','state','error']) || !netInteger(r.id, 0, 4294967295) || !netInteger(r.error, -2147483648, 2147483647) || !['none',...networkActions].includes(r.action) || !['idle','pending','accepted','ok','failed','cancelled','stale','invalid','loaded_defaults','applied_not_queued'].includes(r.state) || ((r.id === 0) !== (r.state === 'idle')) || ((r.id === 0) !== (r.action === 'none')) || ['idle','pending'].includes(r.state) && r.error !== 0 || method === 'POST' && (!r.id || r.action !== action || r.state !== 'pending') || r.state === 'ok' && !['wifi-save','mdns-save'].includes(r.action) || r.state === 'accepted' && ['wifi-save','mdns-save'].includes(r.action) || r.state === 'loaded_defaults' && r.action !== 'mdns-load' || r.state === 'applied_not_queued' && !['mdns-set','mdns-load','mdns-defaults'].includes(r.action)) throw new Error('Invalid network result');\n"
" if (method === 'GET' && networkAwaitingAck) networkWarning = 'Acknowledgement lost: latest result may belong to an earlier request or another tab. Inspect before retrying. ';\n"
" else if (method === 'GET' && networkId && r.id !== networkId) networkWarning = 'Previous result replaced or unavailable; its outcome is unknown. Inspect before retrying. ';\n"
" else if (method === 'POST') networkWarning = '';\n"
" if (method === 'GET' && networkId && networkId === r.id && networkAction && networkAction !== r.action) throw new Error('Operation action changed for the same ID');\n"
" const matched = method === 'POST' || !networkId || networkId === r.id; networkAction = r.action;\n"
" networkId = r.id; networkPending = r.state === 'pending'; networkAwaitingAck = false;\n"
" const messages = {idle: 'No retained result; outcome may be unknown. Inspect before retrying.', pending: 'Queued or executing; do not resubmit.', accepted: 'Accepted: RAM apply / owner queue request only. NOT association, DHCP, online, radio completion or verified DNS. Inspect refreshed runtime; recover through STA/AP, UART0 or native USB if disconnected.', ok: 'Explicit Save completed successfully; device working state persisted, not browser drafts.', failed: 'Canonical/owner/storage failure. Inspect refreshed working state and runtime before retrying.', cancelled: 'Cancelled before canonical admission (queue deadline or session/currentness).', stale: 'Generation stale. No automatic retry. Refresh and review changes before a new explicit submission.', invalid: 'Canonical configuration rejected. Inspect refreshed values and correct the draft.', loaded_defaults: 'mDNS Load selected deterministic defaults in RAM and queued reannouncement. NVS unchanged.', applied_not_queued: 'mDNS RAM changed but live reannouncement queue failed. NOT rolled back. Refresh; do not assume DNS changed.'};\n"
" detail.textContent = networkWarning + r.action + ': ' + messages[r.state] + ' Error: ' + r.error + '.';\n"
" // A replaced result is not completion of our acknowledged operation; never auto-follow it.\n"
" return {state: r.state, matched};\n"
" };\n"
" try {\n"
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
" if (action) { networkPending = true; networkAwaitingAck = true; }\n"
" const response = read(action ? 'POST' : 'GET', body); body = undefined;\n"
" let result = 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; result.state === 'pending' && result.matched && 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 || !await loadSession(generation, controller.signal, false) || !current() || performance.now() >= until) throw new Error('Session changed or deadline');\n"
" result = await read('GET');\n"
" }\n"
" }\n"
" if (result.state === 'pending') detail.textContent += ' Automatic checking stopped. Use Check Result; do not resubmit.';\n"
" refresh = result.state !== 'pending' && result.state !== 'idle';\n"
" } catch (error) { if (live(generation) && current()) detail.textContent = networkWarning + (error.status ? error.message : 'Operation outcome unknown.') + ' Check Result and Refresh manually. No automatic mutation retry. Connection loss/401 is NOT success or cancellation; recover through STA/AP, UART0 or native USB.'; }\n"
" finally { body = undefined; window.clearTimeout(deadline); if (current()) { networkAbort = null; networkButtons(); if (refresh) await refreshNetwork(); } }\n"
"}\n"
"net('target').value = 'ap';\n"
"element('settings-network').addEventListener('click', () => selectSettingsDomain('network'));\n"
"net('refresh').addEventListener('click', refreshNetwork);\n"
"net('result').addEventListener('click', () => networkOperation(null));\n"
"net('apply').addEventListener('click', () => networkOperation(net('target').value === 'ap' ? 'wifi-patch' : 'profile-patch'));\n"
"for (const action of networkActions.filter(a => !a.endsWith('-patch'))) net(action).addEventListener('click', () => networkOperation(action));\n"
"net('target').addEventListener('change', () => { changeNetworkContext(); renderNetworkTarget(); });\n"
"net('password-mode').addEventListener('change', () => {\n"
" const mode = net('password-mode').value; changeNetworkContext();\n"
" net('password-mode').value = ['keep','replace','clear'].includes(mode) && !(mode === 'clear' && net('target').value === 'ap') ? mode : 'keep'; networkButtons();\n"
"});\n"
"net('password').addEventListener('input', () => {\n"
" if (net('password-mode').value !== 'replace' || !networkLive() || networkAbort || networkPending) { clearNetworkSecret(); return; }\n"
" if (!networkSecretTimer) { networkSecretContext = networkContext(); networkSecretUntil = performance.now() + 60000; networkSecretTimer = window.setTimeout(() => { clearNetworkSecret(); networkButtons(); net('ssid-detail').textContent = 'Transient password expired; Keep restored. Re-enter explicitly to replace.'; }, 60000); }\n"
"});\n"
"net('ssid-mode').addEventListener('change', () => {\n"
" changeNetworkContext(); const next = net('ssid-mode').value;\n"
" try {\n"
" if (!['text','hex'].includes(next)) throw new Error('Select text or hex.');\n"
" const bytes = networkSSID(); net('ssid').value = next === 'hex' ? networkHex(bytes) : networkText(bytes); networkSSIDMode = next;\n"
" net('ssid-detail').textContent = bytes.length + ' bytes; lossless mode change.';\n"
" } catch (error) { net('ssid-mode').value = networkSSIDMode; net('ssid-detail').textContent = error.message + ' Original input retained.'; }\n"
" networkButtons();\n"
"});\n"
"for (const id of ['boot','policy','channel','enabled','priority','security','ssid','suffix']) net(id).addEventListener(['ssid','suffix','channel','priority'].includes(id) ? 'input' : 'change', () => {\n"
" changeNetworkContext(); networkButtons();\n"
" if (id === 'ssid') { try { net('ssid-detail').textContent = networkSSID().length + ' / 32 bytes (' + (networkSSIDMode === 'text' ? 'UTF-8' : 'exact hex') + ').'; } catch (error) { net('ssid-detail').textContent = error.message; } }\n"
"});\n"
"let accountRole = 'user', selected = 'serial';\n"
"let adminTerminal = null, adminFit = null, adminSocket = null, adminAbort = null;\n"
"let adminGeneration = 0, adminTimer = null;\n"
+133 -19
View File
@@ -18,6 +18,7 @@
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "mdns_service.h"
#include "nvs.h"
#define WIFI_MANAGER_QUEUE_LENGTH 16U
#define WIFI_MANAGER_TASK_STACK_SIZE 6144U
@@ -1414,36 +1415,147 @@ esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config)
return ESP_OK;
}
esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config)
/* Caller holds s_mutex; publication and owner admission are one transaction. */
static esp_err_t apply_config_locked(const wifi_app_config_t *config)
{
esp_err_t error = wifi_config_validate(config);
if (error != ESP_OK) {
return error;
}
if (s_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
lock_shared();
if (error != ESP_OK) return error;
if (s_shared.snapshot.config_generation == UINT32_MAX) return ESP_ERR_INVALID_STATE;
bool restart_radio = config_requires_radio_restart(&s_shared.config, config);
if (restart_radio) {
manager_message_t message = {.type = MESSAGE_COMMAND_APPLY};
if (!enqueue_message(&message)) {
unlock_shared();
return ESP_ERR_TIMEOUT;
}
if (!enqueue_message(&message)) return ESP_ERR_TIMEOUT;
}
s_shared.config = *config;
++s_shared.snapshot.config_generation;
if (s_shared.snapshot.config_generation == 0U) {
s_shared.snapshot.config_generation = 1U;
}
s_shared.snapshot.ap_policy = config->ap_policy;
++s_shared.snapshot.counters.applies;
return ESP_OK;
}
esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config)
{
if (!config) return ESP_ERR_INVALID_ARG;
if (!s_mutex) return ESP_ERR_INVALID_STATE;
lock_shared();
esp_err_t error = apply_config_locked(config);
unlock_shared();
return error;
}
esp_err_t wifi_manager_get_settings(wifi_manager_settings_t *settings)
{
if (!settings) return ESP_ERR_INVALID_ARG;
memset(settings, 0, sizeof(*settings));
if (!s_mutex) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
settings->runtime = s_shared.snapshot;
portENTER_CRITICAL(&s_drop_mux);
settings->runtime.counters.queue_drops = s_queue_drops;
portEXIT_CRITICAL(&s_drop_mux);
settings->enabled_at_boot = s_shared.config.enabled_at_boot;
settings->ap_policy = s_shared.config.ap_policy;
settings->ap_channel = s_shared.config.ap_channel;
settings->ap_ssid_len = s_shared.config.ap_ssid_len;
memcpy(settings->ap_ssid, s_shared.config.ap_ssid, sizeof(settings->ap_ssid));
settings->ap_password_configured = s_shared.config.ap_psk_len != 0;
for (unsigned i = 0; i < WIFI_CONFIG_STA_PROFILE_COUNT; ++i) {
const wifi_config_sta_profile_t *source = &s_shared.config.profiles[i];
wifi_manager_profile_settings_t *target = &settings->profiles[i];
target->enabled = source->enabled;
target->priority = source->priority;
target->security = source->security;
target->ssid_len = source->ssid_len;
memcpy(target->ssid, source->ssid, sizeof(target->ssid));
target->password_configured = source->psk_len != 0;
}
unlock_shared();
return ESP_OK;
}
static bool generation_matches(uint32_t generation)
{
return generation && generation == s_shared.snapshot.config_generation;
}
esp_err_t wifi_manager_patch_current(uint32_t generation, const wifi_manager_patch_t *patch)
{
if (!patch || patch->profile < -1 || patch->profile >= (int)WIFI_CONFIG_STA_PROFILE_COUNT ||
!patch->fields || patch->ssid_len > WIFI_CONFIG_SSID_MAX_LEN ||
patch->password_len > WIFI_CONFIG_PSK_MAX_LEN) return ESP_ERR_INVALID_ARG;
uint32_t allowed = WIFI_PATCH_SSID | WIFI_PATCH_PASSWORD |
(patch->profile < 0 ? WIFI_PATCH_BOOT | WIFI_PATCH_POLICY | WIFI_PATCH_CHANNEL :
WIFI_PATCH_ENABLED | WIFI_PATCH_PRIORITY | WIFI_PATCH_SECURITY);
if (patch->fields & ~allowed) return ESP_ERR_INVALID_ARG;
if (!s_mutex) return ESP_ERR_INVALID_STATE;
lock_shared();
if (!generation_matches(generation)) { unlock_shared(); return ESP_ERR_NOT_FOUND; }
wifi_app_config_t candidate = s_shared.config;
uint8_t *ssid, *ssid_len, *password, *password_len;
if (patch->profile < 0) {
if (patch->fields & WIFI_PATCH_BOOT) candidate.enabled_at_boot = patch->enabled_at_boot;
if (patch->fields & WIFI_PATCH_POLICY) candidate.ap_policy = patch->ap_policy;
if (patch->fields & WIFI_PATCH_CHANNEL) candidate.ap_channel = patch->ap_channel;
ssid = candidate.ap_ssid; ssid_len = &candidate.ap_ssid_len;
password = candidate.ap_psk; password_len = &candidate.ap_psk_len;
} else {
wifi_config_sta_profile_t *profile = &candidate.profiles[(unsigned)patch->profile];
if (patch->fields & WIFI_PATCH_ENABLED) profile->enabled = patch->enabled;
if (patch->fields & WIFI_PATCH_PRIORITY) profile->priority = patch->priority;
if (patch->fields & WIFI_PATCH_SECURITY) profile->security = patch->security;
ssid = profile->ssid; ssid_len = &profile->ssid_len;
password = profile->psk; password_len = &profile->psk_len;
}
if (patch->fields & WIFI_PATCH_SSID) {
memset(ssid, 0, WIFI_CONFIG_SSID_MAX_LEN);
memcpy(ssid, patch->ssid, patch->ssid_len); *ssid_len = patch->ssid_len;
}
if (patch->fields & WIFI_PATCH_PASSWORD) {
wifi_config_secure_wipe(password, WIFI_CONFIG_PSK_MAX_LEN);
memcpy(password, patch->password, patch->password_len); *password_len = patch->password_len;
}
esp_err_t error = apply_config_locked(&candidate);
wifi_config_secure_wipe(&candidate, sizeof(candidate));
unlock_shared();
return error;
}
esp_err_t wifi_manager_save_current(uint32_t generation)
{
if (!s_mutex) return ESP_ERR_INVALID_STATE;
lock_shared();
/* Hold the config lock through persistence, not HTTPD. Local controls cannot
* change the selected generation while its bytes are being committed. */
esp_err_t error = generation_matches(generation) ? wifi_config_save(&s_shared.config) : ESP_ERR_NOT_FOUND;
unlock_shared();
return error;
}
esp_err_t wifi_manager_load_current(uint32_t generation)
{
if (!s_mutex) return ESP_ERR_INVALID_STATE;
lock_shared();
if (!generation_matches(generation)) { unlock_shared(); return ESP_ERR_NOT_FOUND; }
/* wifi_config_load intentionally generates fallback credentials. Browser
* load must instead read the same canonical blob without that fallback. */
wifi_app_config_t candidate = {0};
esp_err_t error = wifi_config_storage_init();
nvs_handle_t handle;
if (error == ESP_OK) {
error = nvs_open(WIFI_CONFIG_NVS_NAMESPACE, NVS_READONLY, &handle);
if (error == ESP_OK) {
size_t size = sizeof(candidate);
error = nvs_get_blob(handle, WIFI_CONFIG_NVS_BLOB_KEY, &candidate, &size);
nvs_close(handle);
if (error == ESP_OK && size != sizeof(candidate)) error = ESP_ERR_INVALID_SIZE;
}
}
if (error == ESP_OK) error = apply_config_locked(&candidate);
wifi_config_secure_wipe(&candidate, sizeof(candidate));
unlock_shared();
return error;
}
static esp_err_t enqueue_lifecycle_command(manager_message_type_t type,
int enabled_at_boot)
{
@@ -1453,6 +1565,11 @@ static esp_err_t enqueue_lifecycle_command(manager_message_type_t type,
manager_message_t message = {.type = type};
lock_shared();
if (enabled_at_boot >= 0 && s_shared.config.enabled_at_boot != (uint8_t)enabled_at_boot &&
s_shared.snapshot.config_generation == UINT32_MAX) {
unlock_shared();
return ESP_ERR_INVALID_STATE;
}
if (!enqueue_message(&message)) {
unlock_shared();
return ESP_ERR_TIMEOUT;
@@ -1462,9 +1579,6 @@ static esp_err_t enqueue_lifecycle_command(manager_message_type_t type,
s_shared.config.enabled_at_boot != (uint8_t)enabled_at_boot) {
s_shared.config.enabled_at_boot = (uint8_t)enabled_at_boot;
++s_shared.snapshot.config_generation;
if (s_shared.snapshot.config_generation == 0U) {
s_shared.snapshot.config_generation = 1U;
}
}
unlock_shared();
return ESP_OK;
+46
View File
@@ -89,6 +89,52 @@ esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config);
*/
esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config);
/* Secret-free working projection, copied together with runtime under the mutex.
* Zero wait: ESP_ERR_TIMEOUT means no snapshot was obtained. Password presence
* is the only credential metadata, needed to stage/enable disabled profiles. */
typedef struct {
uint8_t enabled, priority;
wifi_config_security_t security;
uint8_t ssid_len, ssid[WIFI_CONFIG_SSID_MAX_LEN];
bool password_configured;
} wifi_manager_profile_settings_t;
typedef struct {
wifi_manager_snapshot_t runtime;
uint8_t enabled_at_boot, ap_channel;
wifi_config_ap_policy_t ap_policy;
uint8_t ap_ssid_len, ap_ssid[WIFI_CONFIG_SSID_MAX_LEN];
bool ap_password_configured;
wifi_manager_profile_settings_t profiles[WIFI_CONFIG_STA_PROFILE_COUNT];
} wifi_manager_settings_t;
esp_err_t wifi_manager_get_settings(wifi_manager_settings_t *settings);
enum {
WIFI_PATCH_BOOT = 1U << 0, WIFI_PATCH_POLICY = 1U << 1,
WIFI_PATCH_CHANNEL = 1U << 2, WIFI_PATCH_ENABLED = 1U << 3,
WIFI_PATCH_PRIORITY = 1U << 4, WIFI_PATCH_SECURITY = 1U << 5,
WIFI_PATCH_SSID = 1U << 6, WIFI_PATCH_PASSWORD = 1U << 7,
};
/* profile=-1 selects AP/global fields; 0..3 selects a station profile.
* Absent bits preserve CURRENT bytes, never a stale caller's secret copy.
* PASSWORD with length zero clears only when canonical validation permits it.
* Caller owns and must wipe this transient input after every exit path. */
typedef struct {
uint32_t fields;
int8_t profile;
uint8_t enabled_at_boot, ap_channel, enabled, priority;
wifi_config_ap_policy_t ap_policy;
wifi_config_security_t security;
uint8_t ssid_len, ssid[WIFI_CONFIG_SSID_MAX_LEN];
uint8_t password_len, password[WIFI_CONFIG_PSK_MAX_LEN];
} wifi_manager_patch_t;
/* Dispatcher-only conditional operations. A nonzero expected generation must
* match under the mutation mutex; ESP_ERR_NOT_FOUND denotes stale selection.
* No generation wrap/reuse. Queue failure leaves RAM untouched. */
esp_err_t wifi_manager_patch_current(uint32_t generation, const wifi_manager_patch_t *patch);
esp_err_t wifi_manager_save_current(uint32_t generation);
/* Stored-only load: never generates or installs unknown default credentials. */
esp_err_t wifi_manager_load_current(uint32_t generation);
/* Lifecycle requests are asynchronous and serialized by the manager task. */
esp_err_t wifi_manager_start(void);
esp_err_t wifi_manager_stop(void);