/* SPDX-License-Identifier: GPL-3.0-only */ #include "web_network_settings.h" #include #include #include #include #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, ¤t); 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; }