/* SPDX-License-Identifier: GPL-3.0-only */ #include "web_serial_settings.h" #include #include #include #include "admin_ssh_console.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "secure_random.h" #include "serial_service.h" #include "web_cookie_auth.h" #include "web_httpd_adapter.h" enum { APPLY, START, STOP, SAVE, LOAD, DEFAULTS, RESET, ACTION_COUNT }; static const char *const s_actions[] = {"apply", "start", "stop", "save", "load", "defaults", "reset"}; enum { IDLE, PENDING, OK, FAILED, CANCELLED, LOADED_DEFAULTS, ROLLBACK_FAILED }; static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "loaded_defaults", "rollback_failed"}; typedef struct { uint32_t id; web_session_id_t session; user_principal_t principal; int64_t deadline; serial_config_t config; unsigned action, state; } serial_operation_t; static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; static serial_operation_t s_operation; static uint32_t s_next_id; /* Deliberately narrow flat JSON: ASCII names/enums, unsigned decimal integers, * no escapes, nesting, duplicate/unknown fields, exponent or fractional values. */ static bool parse(const char *body, size_t length, serial_operation_t *operation) { const char *keys[] = {"action", "baud", "data_bits", "parity", "stop_bits", "flow", "dtr", "rts_threshold"}; unsigned seen = 0; size_t pos = 0; serial_config_defaults(&operation->config); operation->action = ACTION_COUNT; #define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos #define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0) TAKE('{'); for (unsigned field = 0; field < 8; ++field) { if (field) { TAKE(','); } TAKE('"'); size_t start = pos; while (pos < length && body[pos] != '"') ++pos; if (pos == length) return false; unsigned key = 0; for (; key < 8; ++key) if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break; if (key == 8 || (seen & (1U << key))) return false; ++pos; TAKE(':'); SPACE(); uint32_t number = 0; char value[16] = {0}; if (key == 1 || key == 7) { start = pos; while (pos < length && body[pos] >= '0' && body[pos] <= '9') { if (number > 1000000U) return false; number = number * 10 + (unsigned)(body[pos++] - '0'); } if (pos == start || (pos - start > 1 && body[start] == '0')) return false; } else { TAKE('"'); start = pos; while (pos < length && body[pos] != '"') { if (body[pos] < ' ' || body[pos] > '~' || body[pos] == '\\' || pos - start >= sizeof(value) - 1) return false; ++pos; } if (pos == length) return false; memcpy(value, body + start, pos - start); ++pos; } switch (key) { case 0: for (unsigned i = 0; i < ACTION_COUNT; ++i) if (!strcmp(value, s_actions[i])) operation->action = i; if (operation->action == ACTION_COUNT) return false; break; case 1: operation->config.baud_rate = number; break; case 2: if (!serial_config_parse_data_bits(value, &operation->config.data_bits)) return false; break; case 3: if (!serial_config_parse_parity(value, &operation->config.parity)) return false; break; case 4: if (!serial_config_parse_stop_bits(value, &operation->config.stop_bits)) return false; break; case 5: if (!serial_config_parse_flow_control(value, &operation->config.flow_control)) return false; break; case 6: if (!serial_config_parse_dtr_behavior(value, &operation->config.dtr_behavior)) return false; break; case 7: operation->config.rts_threshold = number; break; } seen |= 1U << key; SPACE(); if (pos < length && body[pos] == '}') break; } TAKE('}'); SPACE(); #undef TAKE #undef SPACE return pos == length && seen == (operation->action == APPLY ? 255U : 1U) && serial_config_validate(&operation->config) == ESP_OK; } void web_serial_settings_execute(uint32_t id) { serial_operation_t operation; taskENTER_CRITICAL(&s_lock); operation = s_operation; taskEXIT_CRITICAL(&s_lock); if (!id || operation.id != id || operation.state != PENDING) { secure_wipe(&operation, sizeof(operation)); 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) { /* Operation-admission currentness, not cancellation of an admitted NVS * commit. CLI commands cannot interleave on this single dispatcher. */ serial_config_t config, previous; bool stored = true; state = OK; switch (operation.action) { case APPLY: error = serial_service_apply_config(&operation.config); break; case START: error = serial_service_start(); break; case STOP: error = serial_service_stop(); break; case SAVE: error = serial_service_get_config(&config); if (error == ESP_OK) error = serial_config_save(&config); break; case LOAD: error = serial_config_load(&config, &stored); if (error == ESP_OK) error = serial_service_apply_config(&config); if (!stored) state = LOADED_DEFAULTS; break; case DEFAULTS: serial_config_defaults(&config); error = serial_service_apply_config(&config); break; case RESET: serial_config_defaults(&config); error = serial_service_get_config(&previous); if (error == ESP_OK) error = serial_service_apply_config(&config); if (error == ESP_OK) { error = serial_config_reset_storage(); if (error != ESP_OK && serial_service_apply_config(&previous) != ESP_OK) state = ROLLBACK_FAILED; } break; default: error = ESP_ERR_INVALID_ARG; break; } if (error != ESP_OK && state != ROLLBACK_FAILED) state = FAILED; } taskENTER_CRITICAL(&s_lock); if (s_operation.id == id && s_operation.state == PENDING) { s_operation.state = state; secure_wipe(&s_operation.principal, sizeof(s_operation.principal)); secure_wipe(&s_operation.config, sizeof(s_operation.config)); } 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; } esp_err_t web_serial_settings_handler(httpd_req_t *request) { web_session_view_t view = {0}; bool allowed = false; bool mutation = request->method == HTTP_POST; esp_err_t error = mutation ? web_cookie_auth_require_json(request, 256, &view, &allowed) : web_cookie_auth_require(request, false, false, &view, &allowed); if (error != ESP_OK || !allowed) goto done; if (view.principal.role != USER_ROLE_ADMIN) { error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); goto done; } serial_operation_t operation = {0}; if (mutation) { char type[40] = {0}, body[256]; size_t received = 0; bool valid = request->content_len && 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")); /* Finite bytes and receive calls; timeout/error closes, never retry/drain. */ for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) { int count = httpd_req_recv(request, body + received, request->content_len - received); if (count <= 0 || (size_t)count > request->content_len - received) valid = false; else received += (size_t)count; } valid = valid && received == request->content_len && parse(body, received, &operation); secure_wipe(body, sizeof(body)); if (!valid) { error = respond(request, "400 Bad Request", "{\"error\":\"invalid_serial_request\"}"); goto done; } operation.session = view.id; operation.principal = view.principal; operation.deadline = esp_timer_get_time() + 30000000LL; operation.state = PENDING; taskENTER_CRITICAL(&s_lock); bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX; if (!busy) { operation.id = ++s_next_id; s_operation = operation; } taskEXIT_CRITICAL(&s_lock); if (busy || admin_ssh_console_submit_serial_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\"}"); secure_wipe(&operation, sizeof(operation)); goto done; } } else { taskENTER_CRITICAL(&s_lock); if (s_operation.session == view.id) { operation.id = s_operation.id; operation.action = s_operation.action; operation.state = s_operation.state; } taskEXIT_CRITICAL(&s_lock); } char response[96]; int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}", operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]); error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : respond(request, mutation ? "202 Accepted" : "200 OK", response); secure_wipe(&operation, sizeof(operation)); done: secure_wipe(&view, sizeof(view)); web_httpd_wipe_request(request, web_httpd_unread_body(request)); return error; }