Files
ESP32_Serial_Swiss_Army_Knife/src/web_serial_settings.c
T
Commander1024 42548f6334 Add typed serial settings operations
Route bounded admin mutations through the existing administration
dispatcher,
covering apply, lifecycle, persistence, authorization, and result
tracking.
Add the browser controls, automatic result refresh, regression coverage,
and
phase documentation.
2026-09-08 00:25:31 +02:00

241 lines
11 KiB
C

/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_serial_settings.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "secure_random.h"
#include "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, &current);
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;
}